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
23 changes: 13 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ anexia network address create --prefix <prefix-id> --address 192.0.2.10 --descri
anexia network address update <address-id> --description "web (retired)" --role Default --rdns old-web.example.com
anexia network address delete <address-id> --yes
anexia network address reserve --location <location-id> --vlan <vlan-id> --count 2 --prefix <prefix-id> --reservation-period 1h
anexia vsphere location list --code ANX04
anexia vsphere template list --location <location-id> --type templates
anexia vsphere disk-type list --location <location-id>
```

Boolean payload flags such as `--vm-provisioning` are switched off on `update` with an explicit
Expand Down Expand Up @@ -202,8 +205,8 @@ Filter client-side for now:
anexia core location list -o tsv --no-headers | grep ANX04
```

The vSphere-specific location list, with its human-readable country name and server-side filters,
returns with the `vsphere` group.
The vSphere-specific location list is `anexia vsphere location list`. It has a human-readable
country name and server-side `--code` and `--organization` filters.

## Feature coverage

Expand All @@ -212,11 +215,11 @@ cannot reach it: either the library says the Engine has no such operation, or it
implemented one. The distinction matters to whoever picks the work up, so the tables say which
when the library says which, but a `-` is never evidence about the Engine on its own.

The `core`, `network`, `dns` and `kubernetes` groups below are implemented. Within `network`, `vlan` has every
verb because go-anxcloud models it generically; `prefix` and `address` have every verb hand-written against the
older client.
The remaining groups, starting with `vsphere`, are roadmap items read off go-anxcloud v0.14.5 and
not verified against the Engine.
The `core`, `network`, `dns` and `kubernetes` groups below are implemented. `vsphere` is partially
implemented with location, template and disk-type reads. Within `network`, `vlan` has every verb because
go-anxcloud models it generically; `prefix` and `address` have every verb hand-written against the older client.
The remaining groups, starting with `lbaas`, are roadmap items read off go-anxcloud v0.14.5 and not verified
against the Engine.

### core

Expand All @@ -240,9 +243,9 @@ not verified against the Engine.
| Resource | list | get | create | update | delete | extra |
| --- | :-: | :-: | :-: | :-: | :-: | --- |
| `vsphere vm` | [ ] | [ ] | [ ] | [ ] | [ ] | `power get`/`set` [ ] |
| `vsphere template` | [ ] | [ ] | - | - | - | |
| `vsphere location` | [ ] | - | - | - | - | |
| `vsphere disk-type` | [ ] | - | - | - | - | |
| `vsphere template` | [x] | [x] | - | - | - | |
| `vsphere location` | [x] | - | - | - | - | |
| `vsphere disk-type` | [x] | - | - | - | - | |
| `vsphere nic-type` | [ ] | - | - | - | - | |
| `vsphere cpu-performance-type` | [ ] | - | - | - | - | |
| `vsphere availability-zone` | [ ] | - | - | - | - | |
Expand Down
17 changes: 10 additions & 7 deletions docs/cli-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Group, noun, verb, in that order, always. The noun is singular so the sentence r
`resource.Noun` rather than `resource.Group` to build a noun and the alias comes with it.

Groups mirror the Anexia Engine's own API areas rather than inventing a taxonomy: `core`,
`network`, `dns` and `kubernetes`; roadmap groups include `vsphere`, `lbaas`, `e5e`, `frontier`,
`network`, `dns`, `kubernetes` and `vsphere`; roadmap groups include `lbaas`, `e5e`, `frontier`,
and `storage`.
The singular rule does not apply to them, because Anexia named them, not us. Two commands sit
outside this scheme because they never talk to the Engine: `anexia config` and `anexia version`.
Expand Down Expand Up @@ -375,12 +375,15 @@ formats, `--no-headers`, the plural alias, the empty-result note on stderr and t
`listing <plural>: %w` error prefix, identical to every other resource.

Some Engine areas have no generic object in go-anxcloud yet, so their commands are written by
hand against the legacy client (`core tag`, `core service`, `network prefix`, `network address` and
the two `dns zone` document verbs are the current examples). They
follow the same rules by sharing the same pieces rather than by copying them: `resource.Noun` for
the plural alias, `RegisterPagingFlags` and `ValidatePaging` for paging, `FetchPages` for `--all`,
`RenderList` for output. Reach for those before writing a variant. When the generic client gains
the object, the hand-written command is replaced by a `Spec` and the behavior does not change.
hand against the legacy client (`core tag`, `core service`, `network prefix`, `network address`,
the vSphere location and disk-type lists, and the two `dns zone` document verbs are the current examples).
These hand-written commands follow the same rules by sharing the same pieces rather than by copying
them: `resource.Noun` for the plural alias, `RegisterPagingFlags` and `ValidatePaging` for paging,
`FetchPages` for `--all`, `RenderList` for output. Reach for those before writing a variant. When the
generic client gains the object, the hand-written command is replaced by a `Spec`; the user-facing
verbs and flags stay the same, but the generic model may differ from the legacy one. The vSphere
template list and get already use the generic unpaged `Spec`; its model does not expose the legacy
`param` provisioning parameters, so structured output does not include them yet.

Every divergence between the two halves that users could observe has been a bug so far: a missing
plural alias, an exit code that depended on the client, a `--all` flag present on one half only, a
Expand Down
12 changes: 9 additions & 3 deletions internal/cli/conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ var engineActions = map[string]string{
"anexia core tag create": `creating tag "placeholder"`,
"anexia core tag delete": `deleting tag "placeholder"`,
"anexia core service list": "listing services",
"anexia vsphere location list": "listing locations",
"anexia vsphere template list": "listing templates",
"anexia vsphere template get": `reading template "placeholder"`,
"anexia vsphere disk-type list": "listing disk types",
"anexia network vlan list": "listing vlans",
"anexia network vlan get": `reading vlan "placeholder"`,
"anexia network vlan create": `creating vlan "placeholder"`,
Expand Down Expand Up @@ -442,11 +446,13 @@ var invocationFlags = map[string]string{
"disk": "20",
}

// commandInvocationFlags override invocationFlags for one command where a
// flag name is shared with a different vocabulary: "type" is a record type on
// a DNS record and public or private on a prefix.
// commandInvocationFlags override invocationFlags where a flag name is shared
// with a different vocabulary: "type" is a record type on a DNS record,
// public or private on a prefix, and a template source on vSphere templates.
var commandInvocationFlags = map[string]map[string]string{
"anexia network prefix create": {"type": "private"},
"anexia vsphere template list": {"type": "templates"},
"anexia vsphere template get": {"type": "templates"},
}

// documentFile writes a file for the commands that read one, so --file names
Expand Down
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ func NewRootCommand(d Deps) *cobra.Command {
newNetworkCommand(opts),
newDNSCommand(opts),
newKubernetesCommand(opts),
newVSphereCommand(opts),
newConfigCommand(opts),
newVersionCommand(),
newMovedCommand("location", "anexia core location list"),
Expand Down
176 changes: 176 additions & 0 deletions internal/cli/vsphere.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package cli

import (
"fmt"
"strconv"

"github.com/spf13/cobra"
"github.com/spf13/pflag"
corev1 "go.anx.io/go-anxcloud/pkg/apis/core/v1"
vspherev1 "go.anx.io/go-anxcloud/pkg/apis/vsphere/v1"
"go.anx.io/go-anxcloud/pkg/vsphere/provisioning/disktype"
"go.anx.io/go-anxcloud/pkg/vsphere/provisioning/location"

"github.com/ProbstenHias/anexia-cli/internal/errmap"
"github.com/ProbstenHias/anexia-cli/internal/resource"
)

func newVSphereCommand(opts *globalOptions) *cobra.Command {
return resource.Group("vsphere", "vSphere provisioning resources",
newVSphereLocationCommand(opts),
newVSphereTemplateCommand(opts),
newVSphereDiskTypeCommand(opts),
)
}

func newVSphereLocationCommand(opts *globalOptions) *cobra.Command {
return resource.Noun("location", "locations", "Work with vSphere locations",
newVSphereLocationListCommand(opts),
)
}

func newVSphereLocationListCommand(opts *globalOptions) *cobra.Command {
var page, limit int
var all bool
var code, organization string

cmd := &cobra.Command{
Use: "list",
Short: "List locations",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := resource.ValidatePaging(page, limit, all); err != nil {
return err
}
w, err := opts.Writer(cmd.OutOrStdout())
if err != nil {
return err
}
c, err := opts.Client(cmd.Flags())
if err != nil {
return err
}
ctx, cancel := opts.Context(cmd.Context())
defer cancel()
a := location.NewAPI(c)
found, err := resource.FetchPages(cmd.ErrOrStderr(), "locations", page, limit, all, func(p int) ([]location.Location, error) {
return a.List(ctx, p, limit, queryValue(code), queryValue(organization))
})
if err != nil {
return opts.Fail(fmt.Errorf("listing locations: %w", err))
}
return resource.RenderList(cmd, w, "locations", found,
[]string{"identifier", "code", "name", "country"},
func(l *location.Location) []string { return []string{l.ID, l.Code, l.Name, locationCountry(l)} })
},
}
flags := cmd.Flags()
resource.RegisterPagingFlags(flags, &page, &limit, &all, "locations")
flags.StringVar(&code, "code", "", "filter by location code")
flags.StringVar(&organization, "organization", "", "filter by organization identifier")
return cmd
}

func newVSphereTemplateCommand(opts *globalOptions) *cobra.Command {
return resource.Command(opts, resource.Spec[vspherev1.Template, *vspherev1.Template]{
Noun: "template",
Short: "Work with vSphere templates",
List: true,
Get: true,
Identify: func(t *vspherev1.Template, id string) {
t.Identifier = id
},
Scope: templateScope,
Columns: []resource.Column[vspherev1.Template]{
{Name: "identifier", Value: func(t *vspherev1.Template) string { return t.Identifier }},
{Name: "name", Value: func(t *vspherev1.Template) string { return t.Name }},
{Name: "build", Value: func(t *vspherev1.Template) string { return t.Build }},
{Name: "bit", Value: func(t *vspherev1.Template) string { return t.Bit }},
},
})
}

func templateScope(flags *pflag.FlagSet) func(*vspherev1.Template) error {
locationID := flags.String("location", "", "location identifier")
templateType := flags.String("type", string(vspherev1.TypeTemplate), "template type: templates or from_scratch")

return func(t *vspherev1.Template) error {
if *locationID == "" {
return errmap.Usagef("--location is required")
}
if err := resource.ValidateIdentifier("location", *locationID); err != nil {
return err
}
if *templateType != string(vspherev1.TypeTemplate) && *templateType != string(vspherev1.TypeFromScratch) {
return errmap.Usagef(`--type must be %q or %q`, vspherev1.TypeTemplate, vspherev1.TypeFromScratch)
}

t.Location = corev1.Location{Identifier: pathValue(*locationID)}
t.Type = vspherev1.TemplateType(*templateType)

return nil
}
}

func newVSphereDiskTypeCommand(opts *globalOptions) *cobra.Command {
return resource.Noun("disk-type", "disk-types", "Work with vSphere disk types",
newVSphereDiskTypeListCommand(opts),
)
}

func newVSphereDiskTypeListCommand(opts *globalOptions) *cobra.Command {
var page, limit int
var all bool
var locationID string

cmd := &cobra.Command{
Use: "list",
Short: "List disk types",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := resource.ValidatePaging(page, limit, all); err != nil {
return err
}
if locationID == "" {
return errmap.Usagef("--location is required")
}
if err := resource.ValidateIdentifier("location", locationID); err != nil {
return err
}
w, err := opts.Writer(cmd.OutOrStdout())
if err != nil {
return err
}
c, err := opts.Client(cmd.Flags())
if err != nil {
return err
}
ctx, cancel := opts.Context(cmd.Context())
defer cancel()
a := disktype.NewAPI(c)
found, err := resource.FetchPages(cmd.ErrOrStderr(), "disk types", page, limit, all, func(p int) ([]disktype.DiskType, error) {
return a.List(ctx, pathValue(locationID), p, limit)
})
if err != nil {
return opts.Fail(fmt.Errorf("listing disk types: %w", err))
}
return resource.RenderList(cmd, w, "disk types", found,
[]string{"identifier", "storage-type", "bandwidth", "iops", "latency"},
func(d *disktype.DiskType) []string {
return []string{d.ID, d.StorageType, strconv.Itoa(d.Bandwidth), strconv.Itoa(d.IOPS), strconv.Itoa(d.Latency)}
})
},
}
flags := cmd.Flags()
resource.RegisterPagingFlags(flags, &page, &limit, &all, "disk types")
flags.StringVar(&locationID, "location", "", "location identifier")
return cmd
}

func locationCountry(l *location.Location) string {
if l.CountryName != "" {
return l.CountryName
}

return l.Country
}
Loading