From afb984cd3195865c615d6ac9af34a6c0f0739bab Mon Sep 17 00:00:00 2001 From: Aaron Fischer Date: Mon, 17 Aug 2026 16:19:22 +0200 Subject: [PATCH 1/2] feat(cli): use user-facing endpoints for admin commands in user sessions Commands backed by admin gRPC endpoints previously failed with a raw Keycloak role error for normal users, even when the API offers an equivalent user-facing endpoint. Actions can now declare a fallback in the YAML definition: in sessions without admin credentials the generated command transparently calls the cloud endpoint instead (admin-project list/get/nodes, operating-systems list). All other admin-only commands now fail fast with a helpful hint (e.g. flavour list points to flavour list-project) instead of a server-side permission error. - add fallback/fallback-hint/admin-only to the generator YAML schema and emit a runtime dispatcher (RunAdmin/RunUser) per fallback action - reject admin-only flags such as --search in user sessions with a clear error instead of silently dropping them - register all commands regardless of the local admin configuration, so one binary serves both session types (removes generate-time CanCall) - fix GPCORE_CONFIG being ignored: a second init() in pkg/config overwrote the env-provided config path with the default - clarify the no-project-selected error to point to "project use" Ticket: GPCO-49 Co-Authored-By: Claude Fable 5 --- README.md | 13 +- pkg/config/config.go | 5 - pkg/config/sources.go | 9 + pkg/generator/definition.go | 28 ++- pkg/generator/definition/admin-project.yaml | 29 ++- pkg/generator/definition/flavour.yaml | 4 + pkg/generator/definition/image.yaml | 7 + .../definition/operating-systems.yaml | 4 + pkg/generator/generator.go | 14 +- pkg/generator/helpers.go | 11 +- pkg/generator/sub_command.go | 172 +++++++++++++++++- 11 files changed, 258 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 925a48c..aedff57 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,18 @@ and used to execute admin actions. NOTE: The tool does not support 2FA at the moment, so you have to disable it for your GPCORE account to use the CLI. You still can use Passkey. +Commands that have a user-facing equivalent on the API (for example +```admin-project list```, ```admin-project get```, ```admin-project nodes``` +and ```operating-systems list```) transparently use that equivalent when no +admin credentials are configured, scoped to your own projects. All other +admin-only commands are still visible in ```help``` but fail fast in user +sessions with a hint (either the matching user command, such as +```flavour list-project```, or how to set up admin credentials). Flags that +only the admin endpoint understands (such as ```--search``` on +```admin-project list```) are rejected in user sessions. + To make sure that you have admin permissions, you can use the ```user details``` -command, which will show the admin flag. You also see more actions with -the ```help``` command, if you have admin permissions. If you have the admin +command, which will show the admin flag. If you have the admin flag but get an unauthorized error, you probably have to set the ```super-admin``` role on your service account in Keycloak. Ask a GPCORE administrator for help. diff --git a/pkg/config/config.go b/pkg/config/config.go index 3179d9e..d755780 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -135,8 +135,3 @@ func GetSessionConfig() (*SessionConfig, error) { return sessionConfig, nil } -func init() { - if os.Getenv("GPCORE_CONFIG") != "" { - FilePath = os.Getenv("GPCORE_CONFIG") - } -} diff --git a/pkg/config/sources.go b/pkg/config/sources.go index 50c553a..f2aa076 100644 --- a/pkg/config/sources.go +++ b/pkg/config/sources.go @@ -12,6 +12,15 @@ import ( ) func init() { + // The GPCORE_CONFIG environment variable overrides the default config + // file location. This must live in a single init function: with two + // init functions in this package, the file processed later would + // silently overwrite the value set by the other one. + if env := os.Getenv("GPCORE_CONFIG"); env != "" { + FilePath = env + return + } + dirname, err := os.UserHomeDir() if err != nil { panic(err) diff --git a/pkg/generator/definition.go b/pkg/generator/definition.go index 17ff2bf..8c85ebb 100644 --- a/pkg/generator/definition.go +++ b/pkg/generator/definition.go @@ -2,10 +2,8 @@ package generator import ( "fmt" - "github.com/G-PORTAL/gpcore-cli/pkg/config" "gopkg.in/yaml.v3" "regexp" - "strings" ) type Action struct { @@ -19,11 +17,27 @@ type Action struct { IdentifierKey string `yaml:"identifier-key"` Fields []string `yaml:"fields"` NoPagination bool `yaml:"no-pagination"` + // Fallback, when set on an admin.* action, declares a semantically + // equivalent user-facing (cloud.*) endpoint that is called at runtime + // instead of the admin endpoint when the session has no admin credentials + // (config.HasAdminConfig() == false). Only true equivalents may be wired + // up here: the fallback must accept the same parameters (params marked + // admin-only are rejected in user sessions) and return the same response + // item type. + Fallback *Fallback `yaml:"fallback"` + // FallbackHint customizes the error shown when an admin.* action without + // a Fallback is invoked in a user session (e.g. pointing to an existing + // user-facing command like "flavour list-project"). + FallbackHint string `yaml:"fallback-hint"` } -func (action *Action) CanCall() bool { - adminCall := strings.HasPrefix(action.APICall.Client, "admin") - return !adminCall || config.HasAdminConfig() +// Fallback describes the user-facing endpoint used instead of an admin.* +// api-call when the session has no admin credentials. RootKey and Fields +// default to the action's values when left empty. +type Fallback struct { + APICall APICall `yaml:"api-call"` + RootKey string `yaml:"root-key"` + Fields []string `yaml:"fields"` } type Param struct { @@ -40,6 +54,10 @@ type Param struct { // command errors out. This lets project-scoped commands omit --project-id // once a project has been selected. Source string `yaml:"source"` + // AdminOnly marks a param that only the admin endpoint understands. On + // actions with a Fallback, setting such a flag in a user session is a + // runtime error (the fallback request has no matching field). + AdminOnly bool `yaml:"admin-only"` } // APICall maps a CLI action to a gRPC endpoint via the "api-call" field in the diff --git a/pkg/generator/definition/admin-project.yaml b/pkg/generator/definition/admin-project.yaml index 0dbf811..b3aaab6 100644 --- a/pkg/generator/definition/admin-project.yaml +++ b/pkg/generator/definition/admin-project.yaml @@ -3,12 +3,15 @@ group: admin # NOTE: This resource intentionally exposes the admin.* project endpoints under # a dedicated "admin-project" command, separate from the user-facing "project" -# command (which is bound to cloud.* endpoints). The two cannot be merged -# because: -# - The gRPC connection is session-global (admin OR client-credentials, see -# pkg/api/connection.go), so we cannot switch credentials per-call. -# - Generated init() functions register subcommands unconditionally, so two -# actions with the same name under one root command would collide in cobra. +# command (which is bound to cloud.* endpoints). The two command groups stay +# separate because generated init() functions register subcommands +# unconditionally, so two actions with the same name under one root command +# would collide in cobra. However, actions here declare a "fallback" to the +# equivalent cloud.* endpoint: in a session without admin credentials the +# fallback is used at runtime (scoped to the user's own projects), so the +# commands work for normal users too. The gRPC connection is session-global +# (admin OR client-credentials, see pkg/api/connection.go) — the fallback +# switches the called service, not the credentials. # The admin.UpdateProject action is implemented manually in update.go, because # its request contains a nested BillingProfile message which the generator # cannot express as command flags. @@ -16,17 +19,21 @@ group: admin actions: list: api-call: admin.ListProjects - description: List all projects (admin) + fallback: + api-call: cloud.ListProjects + description: List all projects (admin sees all, users their own) root-key: Projects params: - name: search type: string description: Optional search term required: false + admin-only: true - name: user_id type: string description: Optional filter by user UUID required: false + admin-only: true fields: - Id - Name @@ -36,7 +43,9 @@ actions: get: api-call: admin.GetProject - description: Get details for a project (admin) + fallback: + api-call: cloud.GetProject + description: Get details for a project params: - name: id type: string @@ -45,7 +54,9 @@ actions: nodes: api-call: admin.ListProjectNodes - description: List all nodes in a project (admin) + fallback: + api-call: cloud.ListNodes + description: List all nodes in a project root-key: Nodes params: - name: id diff --git a/pkg/generator/definition/flavour.yaml b/pkg/generator/definition/flavour.yaml index 5960432..289bb3d 100644 --- a/pkg/generator/definition/flavour.yaml +++ b/pkg/generator/definition/flavour.yaml @@ -4,6 +4,10 @@ group: resources actions: list: api-call: admin.ListFlavours + # No fallback: admin.ListFlavours is the global flavour catalog, while + # cloud.ListProjectFlavours is the project-scoped offer (with availability + # and prices) — not the same operation. + fallback-hint: use "flavour list-project --datacenter-id " to list the flavours available to your project description: List all available flavours root-key: Flavours fields: diff --git a/pkg/generator/definition/image.yaml b/pkg/generator/definition/image.yaml index c999cc3..d51f48a 100644 --- a/pkg/generator/definition/image.yaml +++ b/pkg/generator/definition/image.yaml @@ -4,6 +4,10 @@ group: resources actions: list: api-call: admin.ListImages + # No fallback: admin.ListImages is the global image catalog, while + # cloud.ListProjectImages only covers a project's own images — not the + # same operation. + fallback-hint: use "image list-public" for public images or "project-image list" for your project's images description: List all images root-key: Images fields: @@ -33,6 +37,7 @@ actions: get: api-call: admin.GetImage + fallback-hint: use "project-image get" for your project's images description: Get details for an image params: - name: id @@ -126,6 +131,7 @@ actions: delete: api-call: admin.DeleteImage + fallback-hint: use "project-image delete" for your project's images description: Delete an image params: - name: id @@ -135,6 +141,7 @@ actions: delete-version: api-call: admin.DeleteImageVersion + fallback-hint: use "project-image delete-version" for your project's images description: Delete an image version params: - name: id diff --git a/pkg/generator/definition/operating-systems.yaml b/pkg/generator/definition/operating-systems.yaml index 3e11456..835153a 100644 --- a/pkg/generator/definition/operating-systems.yaml +++ b/pkg/generator/definition/operating-systems.yaml @@ -4,6 +4,10 @@ group: resources actions: list: api-call: admin.ListOperatingSystems + # cloud.ListPublicImages returns the same OperatingSystems list for normal + # users (its request-only FlavourId field is deprecated and sent empty). + fallback: + api-call: cloud.ListPublicImages description: List all operating systems root-key: OperatingSystems fields: diff --git a/pkg/generator/generator.go b/pkg/generator/generator.go index d83ee63..94c36be 100644 --- a/pkg/generator/generator.go +++ b/pkg/generator/generator.go @@ -72,8 +72,10 @@ func main() { } } - // Generate all subcommands - addedSubcommands := len(metadata.Actions) + // Generate all subcommands. All commands are registered regardless of + // the local admin configuration: admin-only commands guard themselves + // at runtime (or fall back to a user-facing endpoint), so one binary + // serves both session types. for action, meta := range metadata.Actions { // Check if the subcommand is overwritten by the user if _, err := os.Stat("./cmd/" + subcommandName + "/" + strcase.SnakeCase(action) + ".go"); !os.IsNotExist(err) { @@ -90,15 +92,9 @@ func main() { if err != nil { log.Fatal(err) } - - if !meta.CanCall() { - addedSubcommands-- - } } - if addedSubcommands > 0 { - commandList = append(commandList, subcommandName) - } + commandList = append(commandList, subcommandName) } // Generate the Helper functions file diff --git a/pkg/generator/helpers.go b/pkg/generator/helpers.go index 30eacd8..e0bbef7 100644 --- a/pkg/generator/helpers.go +++ b/pkg/generator/helpers.go @@ -20,10 +20,10 @@ var arrayDatatypes = make([]string, 0) func warningComment(f *File) { f.HeaderComment("Code generated DO NOT EDIT") f.HeaderComment("This code is AUTOGENERATED and will be overwritten by \"go generate\", so") - f.HeaderComment("editing this file is a waste of time. To make changes, edit the template") - f.HeaderComment("in pkg/generator/template/subcommand.tmpl. If you want to execute things") - f.HeaderComment("before or after the command is executed, use a hook. See the usage_hook.go") - f.HeaderComment("as an example.") + f.HeaderComment("editing this file is a waste of time. To make changes, edit the YAML") + f.HeaderComment("definitions in pkg/generator/definition/ or the generator in") + f.HeaderComment("pkg/generator/. If you want to execute things before or after the") + f.HeaderComment("command is executed, use a hook. See the usage_hook.go as an example.") f.Line() } @@ -266,6 +266,9 @@ func parameterDescription(param Param) string { if param.Required { flags = append(flags, "required") } + if param.AdminOnly { + flags = append(flags, "admin sessions only") + } if len(flags) > 0 { return fmt.Sprintf("%s (%s)", param.Description, strings.Join(flags, ", ")) diff --git a/pkg/generator/sub_command.go b/pkg/generator/sub_command.go index 7a1fb46..fda186c 100644 --- a/pkg/generator/sub_command.go +++ b/pkg/generator/sub_command.go @@ -1,6 +1,7 @@ package generator import ( + "fmt" "github.com/G-PORTAL/gpcore-cli/pkg/api" . "github.com/dave/jennifer/jen" "github.com/stoewer/go-strcase" @@ -23,8 +24,7 @@ func GenerateSubCommand(metadata SubcommandMetadata, targetFilename string) erro name := strcase.LowerCamelCase(metadata.Name) // Imports - apiClientImport = "buf.build/gen/go/gportal/gpcore/protocolbuffers/go/gpcore/api/" + metadata.Action.APICall.Client + "/" + metadata.Action.APICall.Version - apiGRPCImport = "buf.build/gen/go/gportal/gpcore/grpc/go/gpcore/api/" + metadata.Action.APICall.Client + "/" + metadata.Action.APICall.Version + "/" + metadata.Action.APICall.Client + metadata.Action.APICall.Version + "grpc" + setAPIImports(metadata.Action.APICall) apiTypesImport = "buf.build/gen/go/gportal/gpcore/protocolbuffers/go/gpcore/type/v1" f.ImportName("github.com/spf13/cobra", "cobra") @@ -40,6 +40,24 @@ func GenerateSubCommand(metadata SubcommandMetadata, targetFilename string) erro f.ImportAlias(apiClientImport, apiClient(metadata)) f.ImportAlias(apiTypesImport, "typesv1") + // When the action declares a user-facing fallback endpoint, validate it + // and register the imports for the second (cloud.*) branch as well. + if fb := metadata.Action.Fallback; fb != nil { + if strings.HasPrefix(fb.APICall.Client, "admin") { + return fmt.Errorf("%s %s: fallback must point to a user-facing endpoint, got %s.%s", + metadata.Definition.Name, metadata.Name, fb.APICall.Client, fb.APICall.Endpoint) + } + if hasListOutput(fb.APICall) != hasListOutput(metadata.Action.APICall) { + return fmt.Errorf("%s %s: fallback %s.%s and api-call %s.%s disagree on list output", + metadata.Definition.Name, metadata.Name, + fb.APICall.Client, fb.APICall.Endpoint, + metadata.Action.APICall.Client, metadata.Action.APICall.Endpoint) + } + clientImport, grpcImport := apiImportsFor(fb.APICall) + f.ImportName(grpcImport, fb.APICall.Client+fb.APICall.Version+"grpc") + f.ImportAlias(clientImport, fb.APICall.Client+fb.APICall.Version) + } + // Parameters (variables) for _, param := range metadata.Action.Params { f.Var().Add(variableDefinition(name, param)) @@ -83,6 +101,27 @@ func GenerateSubCommand(metadata SubcommandMetadata, targetFilename string) erro } } + // RunE body. Three cases: + // 1. Action with a fallback: dispatch to RunAdmin/RunUser + // based on the session credentials at runtime. + // 2. Admin-backed action without a fallback: fail fast in user sessions + // with a friendly hint instead of a server-side role error. + // 3. Everything else: the plain command body. + var runE []Code + isAdminCall := strings.HasPrefix(metadata.Action.APICall.Client, "admin") + if metadata.Action.Fallback != nil { + runE = []Code{ + If(Qual("github.com/G-PORTAL/gpcore-cli/pkg/config", "HasAdminConfig").Call()).Block( + Return(Id(name + "RunAdmin").Call(Id("cobraCmd"), Id("args")))), + Return(Id(name + "RunUser").Call(Id("cobraCmd"), Id("args"))), + } + } else { + if isAdminCall { + runE = append(runE, adminOnlyGuard(metadata)) + } + runE = append(runE, runCommand(name, metadata)...) + } + // Build up the command values := Dict{ Id("Use"): Lit(metadata.Name), @@ -94,7 +133,7 @@ func GenerateSubCommand(metadata SubcommandMetadata, targetFilename string) erro Id("RunE"): Func().Params( Id("cobraCmd").Op("*").Qual("github.com/spf13/cobra", "Command"), Id("args").Index().String()).Error(). - Block(runCommand(name, metadata)...), + Block(runE...), } // Final command @@ -102,11 +141,136 @@ func GenerateSubCommand(metadata SubcommandMetadata, targetFilename string) erro Op("&").Qual("github.com/spf13/cobra", "Command"). Values(values)) + // For actions with a fallback, emit the two run functions: the admin + // branch uses the admin.* endpoint, the user branch the user-facing + // fallback endpoint (with admin-only flags rejected). + if metadata.Action.Fallback != nil { + runParams := func() (*Statement, *Statement) { + return Id("cobraCmd").Op("*").Qual("github.com/spf13/cobra", "Command"), + Id("args").Index().String() + } + + setAPIImports(metadata.Action.APICall) + p1, p2 := runParams() + f.Func().Id(name + "RunAdmin").Params(p1, p2).Error(). + Block(runCommand(name, metadata)...) + + userMeta := userMetadata(metadata) + setAPIImports(userMeta.Action.APICall) + userBody := adminOnlyParamGuards(name, metadata) + userBody = append(userBody, runCommand(name, userMeta)...) + p1, p2 = runParams() + f.Func().Id(name + "RunUser").Params(p1, p2).Error(). + Block(userBody...) + } + f.Func().Id("init").Params().Block(initFunc(name, metadata)...) return f.Save(targetFilename) } +// setAPIImports points the package-global import paths at the packages of the +// given API call. runCommand() captures these while building code, so they +// must be set right before generating each branch body. +func setAPIImports(call APICall) { + apiClientImport, apiGRPCImport = apiImportsFor(call) +} + +// apiImportsFor returns the protocolbuffers (message types) and grpc (service +// client) import paths for a given API call. +func apiImportsFor(call APICall) (string, string) { + clientImport := "buf.build/gen/go/gportal/gpcore/protocolbuffers/go/gpcore/api/" + call.Client + "/" + call.Version + grpcImport := "buf.build/gen/go/gportal/gpcore/grpc/go/gpcore/api/" + call.Client + "/" + call.Version + "/" + call.Client + call.Version + "grpc" + return clientImport, grpcImport +} + +// userMetadata derives the metadata for the user (fallback) branch of an +// action: the api-call is replaced by the fallback endpoint, root-key and +// fields are overridden when set, and admin-only params are dropped from the +// request (their flags stay registered and are rejected at runtime by +// adminOnlyParamGuards). Note that there is no generation-time check that the +// fallback request's required fields are all covered by the remaining params — +// a fallback must only be declared for endpoints whose request is a subset of +// (or equal to) the admin request. +func userMetadata(metadata SubcommandMetadata) SubcommandMetadata { + fb := metadata.Action.Fallback + userMeta := metadata + userMeta.Action.APICall = fb.APICall + if fb.RootKey != "" { + userMeta.Action.RootKey = fb.RootKey + } + if len(fb.Fields) > 0 { + userMeta.Action.Fields = fb.Fields + } + params := make([]Param, 0, len(metadata.Action.Params)) + for _, param := range metadata.Action.Params { + if !param.AdminOnly { + params = append(params, param) + } + } + userMeta.Action.Params = params + return userMeta +} + +// adminOnlyGuard emits the fail-fast check for admin-backed actions without a +// fallback: in a user session they return a friendly error (optionally the +// action's fallback-hint) instead of a server-side Keycloak role error. +func adminOnlyGuard(metadata SubcommandMetadata) Code { + commandName := strings.ReplaceAll(metadata.Definition.Name, "_", "-") + " " + metadata.Name + hint := "set up admin credentials with \"gpcore agent setup --admin\"" + if metadata.Action.FallbackHint != "" { + hint = metadata.Action.FallbackHint + } + return If(Op("!").Qual("github.com/G-PORTAL/gpcore-cli/pkg/config", "HasAdminConfig").Call()).Block( + Return(Qual("errors", "New").Call( + Lit("\"" + commandName + "\" is an admin-only command; " + hint)))) +} + +// adminOnlyParamGuards emits runtime rejections for admin-only flags in the +// user branch of an action with a fallback: the fallback request has no +// matching field, so silently dropping the flag would return misleading +// results. +func adminOnlyParamGuards(name string, metadata SubcommandMetadata) []Code { + c := make([]Code, 0) + for _, param := range metadata.Action.Params { + if !param.AdminOnly { + continue + } + variable := strcase.LowerCamelCase(name) + title(strcase.LowerCamelCase(param.Name)) + flagName := strcase.KebabCase(param.Name) + + // NOTE: "flag was set" is approximated by "flag is not the zero + // value". For bool/int admin-only params a legitimate zero value is + // indistinguishable from an unset flag; only non-zero values get + // rejected in user sessions. + var condition *Statement + switch param.Type { + case "string": + condition = Id(variable).Op("!=").Lit("") + case "bool": + condition = Id(variable) + case "int", "int32", "int64": + condition = Id(variable).Op("!=").Lit(0) + default: + // Enum-typed flags are bound to string variables. + if isEnumType(param.Type) && !isArrayType(param.Type) { + condition = Id(variable).Op("!=").Lit("") + } else { + panic(fmt.Sprintf("%s %s: unsupported admin-only param type %q", + metadata.Definition.Name, metadata.Name, param.Type)) + } + } + + c = append(c, If(condition).Block( + Return(Qual("errors", "New").Call( + Lit("--"+flagName+" requires admin credentials; run \"gpcore agent setup --admin\" to use it"))))) + } + if len(c) > 0 { + c = append(c, Line()) + } + return c +} + // runCommand generates the code for the RunE function of the command. This // function will call the API and print the response. func runCommand(name string, metadata SubcommandMetadata) []Code { @@ -166,7 +330,7 @@ func runCommand(name string, metadata SubcommandMetadata) []Code { )) } else { c = append(c, If(Id(identifier).Op("==").Nil()).Block( - Return(Qual("fmt", "Errorf").Call(Lit("no identifier found, please set the identifier first"))))) + Return(Qual("fmt", "Errorf").Call(Lit("no project selected; run \"gpcore project use \" first"))))) } c = append(c, Line()) } From 8ed6b08a2abecc467c549deaf5a748df2723a028 Mon Sep 17 00:00:00 2001 From: Aaron Fischer Date: Tue, 18 Aug 2026 10:41:01 +0200 Subject: [PATCH 2/2] fix(cli): remove dead ends from the user-session command hints Live testing the admin-only hints from GPCO-49 showed that both hinted alternatives were unusable as suggested. This makes them work out of the box in user sessions. - "flavour list-project" now takes --project-id (instead of --id) and defaults to the project selected via "project use", like the other project-scoped commands - add an optional "field:" key to the generator param schema so a flag name can differ from the gRPC request field it fills (here: a --project-id flag filling the request field "Id") - "image list-public" no longer requires the --flavour-id flag (marked deprecated and unused in the API) and defaults to no cloud-provider filter, so the bare command lists all public images instead of an empty AWS-filtered result - the "flavour list" hint now points to "datacenter list" to resolve the required datacenter id Breaking: "flavour list-project --id " becomes --project-id. Ticket: GPCO-49 Co-Authored-By: Claude Fable 5 --- pkg/generator/definition.go | 17 ++++++++++++++++- pkg/generator/definition/flavour.yaml | 9 ++++++--- pkg/generator/definition/image.yaml | 10 +++++----- pkg/generator/sub_command.go | 2 +- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/pkg/generator/definition.go b/pkg/generator/definition.go index 8c85ebb..c510ceb 100644 --- a/pkg/generator/definition.go +++ b/pkg/generator/definition.go @@ -2,8 +2,10 @@ package generator import ( "fmt" - "gopkg.in/yaml.v3" "regexp" + + "github.com/stoewer/go-strcase" + "gopkg.in/yaml.v3" ) type Action struct { @@ -58,6 +60,19 @@ type Param struct { // actions with a Fallback, setting such a flag in a user session is a // runtime error (the fallback request has no matching field). AdminOnly bool `yaml:"admin-only"` + // Field overrides the gRPC request field this param maps to. It defaults + // to the UpperCamelCase of Name; set it when the flag should be named + // differently than the proto field (e.g. a --project-id flag filling a + // request field called just "Id"). + Field string `yaml:"field"` +} + +// RequestField returns the gRPC request field the param maps to. +func (p Param) RequestField() string { + if p.Field != "" { + return p.Field + } + return title(strcase.LowerCamelCase(p.Name)) } // APICall maps a CLI action to a gRPC endpoint via the "api-call" field in the diff --git a/pkg/generator/definition/flavour.yaml b/pkg/generator/definition/flavour.yaml index 289bb3d..c2268b1 100644 --- a/pkg/generator/definition/flavour.yaml +++ b/pkg/generator/definition/flavour.yaml @@ -7,7 +7,7 @@ actions: # No fallback: admin.ListFlavours is the global flavour catalog, while # cloud.ListProjectFlavours is the project-scoped offer (with availability # and prices) — not the same operation. - fallback-hint: use "flavour list-project --datacenter-id " to list the flavours available to your project + fallback-hint: use "flavour list-project --datacenter-id " (see "datacenter list") to list the flavours available to your project description: List all available flavours root-key: Flavours fields: @@ -27,10 +27,13 @@ actions: description: List all available flavours for a project root-key: Flavours params: - - name: id + # The request field is called "id" but holds the project UUID; expose it + # as --project-id with the usual "project use" session fallback. + - name: project_id type: string description: Project UUID - required: true + field: Id + source: session.CurrentProject - name: datacenter_id type: string description: Datacenter UUID diff --git a/pkg/generator/definition/image.yaml b/pkg/generator/definition/image.yaml index d51f48a..f6d8c10 100644 --- a/pkg/generator/definition/image.yaml +++ b/pkg/generator/definition/image.yaml @@ -20,13 +20,13 @@ actions: description: List all public images root-key: OperatingSystems params: - - name: flavour_id - type: string - description: Flavour ID (deprecated) - required: true + # flavour_id is marked deprecated ("no longer in use") in the proto and + # is intentionally not exposed as a flag. + # Default to UNSPECIFIED (no filter): the server then returns all public + # images, while e.g. AWS would silently narrow the list to cloud images. - name: cloud_provider_type type: typev1.CloudProviderType - default: typev1.CLOUD_PROVIDER_TYPE_AWS + default: typev1.CLOUD_PROVIDER_TYPE_UNSPECIFIED description: Filter by cloud provider type required: false optional: true diff --git a/pkg/generator/sub_command.go b/pkg/generator/sub_command.go index fda186c..145c49f 100644 --- a/pkg/generator/sub_command.go +++ b/pkg/generator/sub_command.go @@ -420,7 +420,7 @@ func runCommand(name string, metadata SubcommandMetadata) []Code { val = Id(variable) } } - apiCallParams[Id(title(strcase.LowerCamelCase(param.Name)))] = val + apiCallParams[Id(param.RequestField())] = val } // Pagination