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
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 0 additions & 5 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,3 @@ func GetSessionConfig() (*SessionConfig, error) {
return sessionConfig, nil
}

func init() {
if os.Getenv("GPCORE_CONFIG") != "" {
FilePath = os.Getenv("GPCORE_CONFIG")
}
}
9 changes: 9 additions & 0 deletions pkg/config/sources.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
45 changes: 39 additions & 6 deletions pkg/generator/definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ package generator

import (
"fmt"
"github.com/G-PORTAL/gpcore-cli/pkg/config"
"gopkg.in/yaml.v3"
"regexp"
"strings"

"github.com/stoewer/go-strcase"
"gopkg.in/yaml.v3"
)

type Action struct {
Expand All @@ -19,11 +19,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 {
Expand All @@ -40,6 +56,23 @@ 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"`
// 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
Expand Down
29 changes: 20 additions & 9 deletions pkg/generator/definition/admin-project.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,37 @@ 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.

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
Expand All @@ -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
Expand All @@ -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
Expand Down
11 changes: 9 additions & 2 deletions pkg/generator/definition/flavour.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>" (see "datacenter list") to list the flavours available to your project
description: List all available flavours
root-key: Flavours
fields:
Expand All @@ -23,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
Expand Down
17 changes: 12 additions & 5 deletions pkg/generator/definition/image.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -16,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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions pkg/generator/definition/operating-systems.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 5 additions & 9 deletions pkg/generator/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down
11 changes: 7 additions & 4 deletions pkg/generator/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down Expand Up @@ -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, ", "))
Expand Down
Loading
Loading