diff --git a/README.md b/README.md index b21abc5..4612145 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,9 @@ anexia network address create --prefix --address 192.0.2.10 --descri anexia network address update --description "web (retired)" --role Default --rdns old-web.example.com anexia network address delete --yes anexia network address reserve --location --vlan --count 2 --prefix --reservation-period 1h +anexia vsphere location list --code ANX04 +anexia vsphere template list --location --type templates +anexia vsphere disk-type list --location ``` Boolean payload flags such as `--vm-provisioning` are switched off on `update` with an explicit @@ -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 @@ -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 @@ -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` | [ ] | - | - | - | - | | diff --git a/docs/cli-design.md b/docs/cli-design.md index 7c3b5b8..9a00ab3 100644 --- a/docs/cli-design.md +++ b/docs/cli-design.md @@ -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`. @@ -375,12 +375,15 @@ formats, `--no-headers`, the plural alias, the empty-result note on stderr and t `listing : %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 diff --git a/internal/cli/conformance_test.go b/internal/cli/conformance_test.go index 341afda..119648a 100644 --- a/internal/cli/conformance_test.go +++ b/internal/cli/conformance_test.go @@ -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"`, @@ -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 diff --git a/internal/cli/root.go b/internal/cli/root.go index 93a9e81..c93b9af 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -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"), diff --git a/internal/cli/vsphere.go b/internal/cli/vsphere.go new file mode 100644 index 0000000..0ce4c29 --- /dev/null +++ b/internal/cli/vsphere.go @@ -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 +} diff --git a/internal/cli/vsphere_test.go b/internal/cli/vsphere_test.go new file mode 100644 index 0000000..fababe2 --- /dev/null +++ b/internal/cli/vsphere_test.go @@ -0,0 +1,430 @@ +package cli_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ProbstenHias/anexia-cli/internal/cli" + "github.com/ProbstenHias/anexia-cli/internal/errmap" +) + +func TestVSphereLocationList(t *testing.T) { + isolate(t) + srv, last := server(t, http.StatusOK, `{"data":[`+ + `{"id":"l-1","code":"ANX01","name":"Vienna","country_name":"Austria"},`+ + `{"id":"l-2","code":"ANX02","name":"Graz","country":"AT"}]}`) + + // A user sees the ISO country code when the Engine omits its display name. + stdout, _, err := run(t, "vsphere", "location", "list", "--token", "tok", "--api-base-url", srv.URL) + require.NoError(t, err) + require.Equal(t, "/api/vsphere/v1/provisioning/location.json", last.path) + // A user gets the standard identifier header in scripts and table output. + require.Equal(t, "IDENTIFIER CODE NAME COUNTRY\n"+ + "l-1 ANX01 Vienna Austria\n"+ + "l-2 ANX02 Graz AT\n", stdout) +} + +func TestVSphereListShortDescriptions(t *testing.T) { + root := cli.NewRootCommand(cli.Deps{}) + for _, tt := range []struct { + path []string + short string + }{ + {[]string{"vsphere", "location", "list"}, "List locations"}, + {[]string{"vsphere", "template", "list"}, "List templates"}, + {[]string{"vsphere", "disk-type", "list"}, "List disk types"}, + } { + t.Run(tt.short, func(t *testing.T) { + // A user sees the same concise list description across vSphere resources. + cmd, _, err := root.Find(tt.path) + require.NoError(t, err) + require.Equal(t, tt.short, cmd.Short) + }) + } +} + +func TestVSphereLocationListEscapesFilters(t *testing.T) { + isolate(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // A user can filter with punctuation without it becoming another query parameter. + require.Equal(t, "Vienna & East", r.URL.Query().Get("location_code")) + require.Equal(t, "org & team", r.URL.Query().Get("organization")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + t.Cleanup(srv.Close) + + _, _, err := run(t, "vsphere", "location", "list", "--code", "Vienna & East", "--organization", "org & team", + "--token", "tok", "--api-base-url", srv.URL) + require.NoError(t, err) +} + +func TestVSphereLocationListPassesPaging(t *testing.T) { + isolate(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // A user can select an exact page size and number. + require.Equal(t, url.Values{"limit": {"7"}, "page": {"3"}}, r.URL.Query()) + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + t.Cleanup(srv.Close) + + _, _, err := run(t, "vsphere", "location", "list", "--page", "3", "--limit", "7", "--token", "tok", "--api-base-url", srv.URL) + require.NoError(t, err) +} + +func TestVSphereTemplateList(t *testing.T) { + isolate(t) + srv, last := server(t, http.StatusOK, `[{"id":"t-1","name":"Ubuntu","build":"24.04","bit":"64"}]`) + + stdout, _, err := run(t, "vsphere", "template", "list", "--location", "l-1", "--token", "tok", "--api-base-url", srv.URL) + require.NoError(t, err) + require.Equal(t, "/api/vsphere/v1/provisioning/templates.json/l-1/templates", last.path) + query, err := url.ParseQuery(last.query) + require.NoError(t, err) + require.Equal(t, url.Values{"limit": {"1000"}, "page": {"1"}}, query) + require.Equal(t, "IDENTIFIER NAME BUILD BIT\n"+ + "t-1 Ubuntu 24.04 64\n", stdout) +} + +func TestVSphereTemplateListPassesType(t *testing.T) { + isolate(t) + srv, last := server(t, http.StatusOK, `[]`) + + _, _, err := run(t, "vsphere", "template", "list", "--location", "l-1", "--type", "from_scratch", "--token", "tok", "--api-base-url", srv.URL) + require.NoError(t, err) + require.Equal(t, "/api/vsphere/v1/provisioning/templates.json/l-1/from_scratch", last.path) +} + +func TestVSphereTemplateListAllFetchesOnce(t *testing.T) { + isolate(t) + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + // A user asking for all templates receives the one complete response without a loop. + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"id":"t-1"},{"id":"t-2"}]`)) + })) + t.Cleanup(srv.Close) + + stdout, _, err := run(t, "vsphere", "template", "list", "--location", "l-1", "--all", "-o", "json", "--token", "tok", "--api-base-url", srv.URL) + require.NoError(t, err) + require.Equal(t, 1, requests) + require.Equal(t, []string{"t-1", "t-2"}, vsphereIdentifiers(t, stdout)) +} + +func TestVSphereTemplateListRejectsLaterPage(t *testing.T) { + isolate(t) + srv, last := server(t, http.StatusOK, `[]`) + + // A user is told that templates have no second page instead of seeing page one twice. + _, _, err := run(t, "vsphere", "template", "list", "--location", "l-1", "--page", "2", "--token", "tok", "--api-base-url", srv.URL) + require.ErrorContains(t, err, "does not support paging") + require.Equal(t, errmap.ExitUsage, errmap.ExitCode(err)) + require.Empty(t, last.path) +} + +func TestVSphereTemplateListRejectsInvalidType(t *testing.T) { + isolate(t) + + _, _, err := run(t, "vsphere", "template", "list", "--location", "l-1", "--type", "invalid", "--token", "tok") + require.ErrorContains(t, err, `--type must be "templates" or "from_scratch"`) + require.Equal(t, errmap.ExitUsage, errmap.ExitCode(err)) +} + +func TestVSphereTemplateGet(t *testing.T) { + isolate(t) + srv, last := server(t, http.StatusOK, `[`+ + `{"id":"t-other","name":"Other","build":"23.10","bit":"32"},`+ + `{"id":"t-1","name":"Ubuntu","build":"24.04","bit":"64"}]`) + + // A user can retrieve one template from the scoped template collection. + stdout, _, err := run(t, "vsphere", "template", "get", "t-1", "--location", "l-1", "--token", "tok", "--api-base-url", srv.URL) + require.NoError(t, err) + require.Equal(t, "/api/vsphere/v1/provisioning/templates.json/l-1/templates", last.path) + query, err := url.ParseQuery(last.query) + require.NoError(t, err) + require.Equal(t, url.Values{"limit": {"1000"}, "page": {"1"}}, query) + require.Equal(t, "IDENTIFIER NAME BUILD BIT\n"+ + "t-1 Ubuntu 24.04 64\n", stdout) +} + +func TestVSphereTemplateGetMissingFromCollection(t *testing.T) { + isolate(t) + srv, _ := server(t, http.StatusOK, `[]`) + + // A user sees not found when the requested template is absent from the scoped collection. + _, _, err := run(t, "vsphere", "template", "get", "t-missing", "--location", "l-1", "--token", "tok", "--api-base-url", srv.URL) + require.Error(t, err) + require.Equal(t, errmap.ExitNotFound, errmap.ExitCode(err)) + require.ErrorContains(t, err, `reading template "t-missing":`) +} + +func TestVSphereTemplateRejectsInvalidLocation(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {"list dot dot", []string{"vsphere", "template", "list", "--location", ".."}}, + {"list whitespace", []string{"vsphere", "template", "list", "--location", " "}}, + {"list slash", []string{"vsphere", "template", "list", "--location", "a/b"}}, + {"get dot dot", []string{"vsphere", "template", "get", "t-1", "--location", ".."}}, + {"get whitespace", []string{"vsphere", "template", "get", "t-1", "--location", " "}}, + {"get slash", []string{"vsphere", "template", "get", "t-1", "--location", "a/b"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isolate(t) + srv, last := server(t, http.StatusOK, `[]`) + + // A user cannot turn a template location into a different URL path. + _, _, err := run(t, append(tt.args, "--token", "tok", "--api-base-url", srv.URL)...) + require.Error(t, err) + require.Equal(t, errmap.ExitUsage, errmap.ExitCode(err)) + require.Empty(t, last.path) + }) + } +} + +func TestVSphereTemplateListEscapesLocation(t *testing.T) { + isolate(t) + location := "loc ?#% name" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // A user can address a template location with URL punctuation as one path segment. + require.Equal(t, "/api/vsphere/v1/provisioning/templates.json/"+location+"/templates", r.URL.Path) + require.Equal(t, url.Values{"limit": {"1000"}, "page": {"1"}}, r.URL.Query()) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + t.Cleanup(srv.Close) + + _, _, err := run(t, "vsphere", "template", "list", "--location", location, "--token", "tok", "--api-base-url", srv.URL) + require.NoError(t, err) +} + +func TestVSphereDiskTypeList(t *testing.T) { + isolate(t) + srv, last := server(t, http.StatusOK, + `[{"id":"d-1","storage_type":"ssd","bandwidth":100,"iops":200,"latency":3}]`) + + stdout, _, err := run(t, "vsphere", "disk-type", "list", "--location", "l-1", "--token", "tok", "--api-base-url", srv.URL) + require.NoError(t, err) + require.Equal(t, "/api/vsphere/v1/provisioning/disk_type.json/l-1", last.path) + // A user gets the documented hyphenated storage-type header. + require.Equal(t, "IDENTIFIER STORAGE-TYPE BANDWIDTH IOPS LATENCY\n"+ + "d-1 ssd 100 200 3\n", stdout) +} + +func TestVSphereDiskTypeListPassesPaging(t *testing.T) { + isolate(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // A user can select an exact page size and number. + require.Equal(t, "3", r.URL.Query().Get("page")) + require.Equal(t, "7", r.URL.Query().Get("limit")) + _, _ = w.Write([]byte(`[]`)) + })) + t.Cleanup(srv.Close) + + _, _, err := run(t, "vsphere", "disk-type", "list", "--location", "l-1", "--page", "3", "--limit", "7", "--token", "tok", "--api-base-url", srv.URL) + require.NoError(t, err) +} + +func TestVSphereDiskTypeListEscapesLocation(t *testing.T) { + isolate(t) + location := "loc ?#% name" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // A user can address a location containing URL punctuation as one path segment. + require.Equal(t, "/api/vsphere/v1/provisioning/disk_type.json/"+location, r.URL.Path) + require.Equal(t, url.Values{"limit": {"7"}, "page": {"3"}}, r.URL.Query()) + _, _ = w.Write([]byte(`[]`)) + })) + t.Cleanup(srv.Close) + + _, _, err := run(t, "vsphere", "disk-type", "list", "--location", location, "--page", "3", "--limit", "7", "--token", "tok", "--api-base-url", srv.URL) + require.NoError(t, err) +} + +func TestVSphereDiskTypeListRejectsInvalidLocation(t *testing.T) { + for _, location := range []string{".", ".."} { + t.Run(location, func(t *testing.T) { + isolate(t) + srv, last := server(t, http.StatusOK, `[]`) + + // A user cannot turn a location identifier into a relative path. + _, _, err := run(t, "vsphere", "disk-type", "list", "--location", location, "--token", "tok", "--api-base-url", srv.URL) + require.Error(t, err) + require.Equal(t, errmap.ExitUsage, errmap.ExitCode(err)) + require.Empty(t, last.path) + }) + } +} + +func TestVSphereListsReportEmptyResults(t *testing.T) { + tests := []struct { + name string + args []string + body string + want string + }{ + {"location", []string{"vsphere", "location", "list"}, `{"data":[]}`, "no locations found\n"}, + {"template", []string{"vsphere", "template", "list", "--location", "l-1"}, `[]`, "no templates found\n"}, + {"disk type", []string{"vsphere", "disk-type", "list", "--location", "l-1"}, `[]`, "no disk types found\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isolate(t) + srv, _ := server(t, http.StatusOK, tt.body) + _, stderr, err := run(t, append(tt.args, "--token", "tok", "--api-base-url", srv.URL)...) + require.NoError(t, err) + require.Equal(t, tt.want, stderr) + }) + } +} + +func TestVSphereListsRenderEmptyJSON(t *testing.T) { + tests := []struct { + name string + args []string + body string + }{ + {"location", []string{"vsphere", "location", "list"}, `{"data":[]}`}, + {"template", []string{"vsphere", "template", "list", "--location", "l-1"}, `[]`}, + {"disk type", []string{"vsphere", "disk-type", "list", "--location", "l-1"}, `[]`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isolate(t) + srv, _ := server(t, http.StatusOK, tt.body) + stdout, _, err := run(t, append(tt.args, "-o", "json", "--token", "tok", "--api-base-url", srv.URL)...) + require.NoError(t, err) + require.Equal(t, "[]\n", stdout) + }) + } +} + +func TestVSphereListsRejectInvalidPaging(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {"location", []string{"vsphere", "location", "list"}}, + {"template", []string{"vsphere", "template", "list", "--location", "l-1"}}, + {"disk type", []string{"vsphere", "disk-type", "list", "--location", "l-1"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isolate(t) + _, _, err := run(t, append(tt.args, "--page", "0", "--token", "tok")...) + require.ErrorContains(t, err, "--page 0 must be 1 or greater") + require.Equal(t, errmap.ExitUsage, errmap.ExitCode(err)) + _, _, err = run(t, append(tt.args, "--limit", "0", "--token", "tok")...) + require.ErrorContains(t, err, "--limit 0 must be between 1 and 1000") + require.Equal(t, errmap.ExitUsage, errmap.ExitCode(err)) + }) + } +} + +// TestVSphereTemplateListReportsUnservablePageBeforeAMissingToken pins the +// order the checks run in. Asking for page two of an endpoint that never pages +// is the user's actual mistake; reporting a missing token ahead of it sends +// them after the wrong problem and exits 3 where the contract calls for 2. +func TestVSphereTemplateListReportsUnservablePageBeforeAMissingToken(t *testing.T) { + isolate(t) + + _, _, err := run(t, "vsphere", "template", "list", "--location", "l-1", "--page", "2") + require.Error(t, err) + require.Equal(t, errmap.ExitUsage, errmap.ExitCode(err)) + require.Contains(t, errmap.Message(err), "does not support paging") +} + +func TestVSphereListsRequireLocation(t *testing.T) { + for _, args := range [][]string{{"vsphere", "template", "list"}, {"vsphere", "disk-type", "list"}} { + t.Run(args[1], func(t *testing.T) { + isolate(t) + _, _, err := run(t, append(args, "--token", "tok")...) + require.ErrorContains(t, err, "--location is required") + require.Equal(t, errmap.ExitUsage, errmap.ExitCode(err)) + }) + } +} + +func TestVSphereLegacyListsWalkPages(t *testing.T) { + tests := []struct { + name string + args []string + body string + want []string + }{ + {"location", []string{"vsphere", "location", "list"}, `{"data":[{"id":"l-%d"}]}`, []string{"l-1", "l-2"}}, + {"disk type", []string{"vsphere", "disk-type", "list", "--location", "l-1"}, `[{"id":"d-%d"}]`, []string{"d-1", "d-2"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isolate(t) + var seen []int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page, err := strconv.Atoi(r.URL.Query().Get("page")) + require.NoError(t, err) + seen = append(seen, page) + if page < 3 { + _, _ = fmt.Fprintf(w, tt.body, page) + return + } + if tt.name == "location" { + _, _ = w.Write([]byte(`{"data":[]}`)) + return + } + _, _ = w.Write([]byte(`[]`)) + })) + t.Cleanup(srv.Close) + + stdout, _, err := run(t, append(tt.args, "--all", "--limit", "1", "-o", "json", "--token", "tok", "--api-base-url", srv.URL)...) + require.NoError(t, err) + require.Equal(t, []int{1, 2, 3}, seen) + require.Equal(t, tt.want, vsphereIdentifiers(t, stdout)) + }) + } +} + +func TestVSphereListNotFound(t *testing.T) { + tests := []struct { + name string + args []string + prefix string + }{ + {"location", []string{"vsphere", "location", "list"}, "listing locations:"}, + {"template", []string{"vsphere", "template", "list", "--location", "l-1"}, "listing templates:"}, + {"disk type", []string{"vsphere", "disk-type", "list", "--location", "l-1"}, "listing disk types:"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isolate(t) + srv, _ := server(t, http.StatusNotFound, `{"error":{"code":404,"message":"missing"}}`) + + // A user receives a not-found result with the operation that failed. + _, _, err := run(t, append(tt.args, "--token", "tok", "--api-base-url", srv.URL)...) + require.Error(t, err) + require.Equal(t, errmap.ExitNotFound, errmap.ExitCode(err)) + require.ErrorContains(t, err, tt.prefix) + }) + } +} + +func vsphereIdentifiers(t *testing.T, text string) []string { + t.Helper() + var objects []struct { + Identifier string `json:"id"` + } + require.NoError(t, json.Unmarshal([]byte(text), &objects)) + identifiers := make([]string, 0, len(objects)) + for _, object := range objects { + identifiers = append(identifiers, object.Identifier) + } + return identifiers +} diff --git a/internal/resource/verbs.go b/internal/resource/verbs.go index 1cf50a8..aa25bff 100644 --- a/internal/resource/verbs.go +++ b/internal/resource/verbs.go @@ -67,6 +67,17 @@ func newListCommand[O any, PO Pointer[O]](env Env, spec Spec[O, PO]) *cobra.Comm } } + // Same reasoning for a page the endpoint can never serve: it is a + // usage mistake and has to be reported as one, not as a missing token. + paginated, err := paginates(cmd.Context(), PO(&filter)) + if err != nil { + return err + } + + if !paginated && page > 1 { + return unservablePageError(spec.plural(), page) + } + a, err := env.API(cmd.Flags()) if err != nil { return err @@ -131,7 +142,7 @@ func fetch[O any, PO Pointer[O]](ctx context.Context, a api.API, notices io.Writ // returns the same full result set: asking again is pointless, and // asking for a later page cannot be answered at all. if page > 1 { - return nil, errmap.Usagef("%s does not support paging, so --page %d cannot be served", plural, page) + return nil, unservablePageError(plural, page) } all = false @@ -206,6 +217,12 @@ func endOfWalk(err error, page, current int, all bool) bool { return all && current > page && errmap.IsNotFound(err) } +// unservablePageError reports a page request an endpoint that never pages +// cannot answer. +func unservablePageError(plural string, page int) error { + return errmap.Usagef("%s does not support paging, so --page %d cannot be served", plural, page) +} + // paginates reports whether the object's endpoint pages at all. go-anxcloud // asks the object the same question through PaginationSupportHook and, for the // ones that answer no, stops sending the page parameter entirely. A caller that