From 8986bdcf428000167f5070387ac8c2ffdc545035 Mon Sep 17 00:00:00 2001 From: ProbstenHias Date: Sun, 13 Sep 2026 14:59:38 +0200 Subject: [PATCH 1/3] feat(vsphere): add location, template and disk-type list commands Add the vsphere command group with paged read-only lists backed by the go-anxcloud legacy provisioning clients. Filters and path identifiers are escaped before reaching the clients, which interpolate them raw. --- README.md | 9 +- docs/cli-design.md | 6 +- internal/cli/conformance_test.go | 7 +- internal/cli/root.go | 1 + internal/cli/vsphere.go | 176 +++++++++++++++++++++++++++++ internal/cli/vsphere_test.go | 188 +++++++++++++++++++++++++++++++ 6 files changed, 380 insertions(+), 7 deletions(-) create mode 100644 internal/cli/vsphere.go create mode 100644 internal/cli/vsphere_test.go diff --git a/README.md b/README.md index b21abc5..e2b8a1b 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 @@ -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] | [ ] | - | - | - | | +| `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..e5e2241 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,8 +375,8 @@ 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 +hand against the legacy client (`core tag`, `core service`, `network prefix`, `network address`, +the vSphere provisioning lists 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 diff --git a/internal/cli/conformance_test.go b/internal/cli/conformance_test.go index 341afda..287e308 100644 --- a/internal/cli/conformance_test.go +++ b/internal/cli/conformance_test.go @@ -108,6 +108,9 @@ 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 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"`, @@ -446,7 +449,9 @@ var invocationFlags = map[string]string{ // flag name is shared with a different vocabulary: "type" is a record type on // a DNS record and public or private on a prefix. var commandInvocationFlags = map[string]map[string]string{ - "anexia network prefix create": {"type": "private"}, + "anexia network prefix create": {"type": "private"}, + "anexia vsphere template list": {"location": "placeholder", "type": "templates"}, + "anexia vsphere disk-type list": {"location": "placeholder"}, } // 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..6b98bd2 --- /dev/null +++ b/internal/cli/vsphere.go @@ -0,0 +1,176 @@ +package cli + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + "go.anx.io/go-anxcloud/pkg/vsphere/provisioning/disktype" + "go.anx.io/go-anxcloud/pkg/vsphere/provisioning/location" + "go.anx.io/go-anxcloud/pkg/vsphere/provisioning/templates" + + "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 vSphere 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{"id", "code", "name", "country"}, + func(l *location.Location) []string { return []string{l.ID, l.Code, l.Name, l.CountryName} }) + }, + } + 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.Noun("template", "templates", "Work with vSphere templates", + newVSphereTemplateListCommand(opts), + ) +} + +func newVSphereTemplateListCommand(opts *globalOptions) *cobra.Command { + var page, limit int + var all bool + var locationID, templateType string + + cmd := &cobra.Command{ + Use: "list", + Short: "List vSphere templates", + 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 templateType != templates.TemplateTypeTemplates && templateType != templates.TemplateTypeFromScratch { + return errmap.Usagef(`--type must be %q or %q`, templates.TemplateTypeTemplates, templates.TemplateTypeFromScratch) + } + 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 := templates.NewAPI(c) + found, err := resource.FetchPages(cmd.ErrOrStderr(), "templates", page, limit, all, func(p int) ([]templates.Template, error) { + return a.List(ctx, pathValue(locationID), templateType, p, limit) + }) + if err != nil { + return opts.Fail(fmt.Errorf("listing templates: %w", err)) + } + return resource.RenderList(cmd, w, "templates", found, + []string{"id", "name", "build", "bit"}, + func(t *templates.Template) []string { return []string{t.ID, t.Name, t.Build, t.WordSize} }) + }, + } + flags := cmd.Flags() + resource.RegisterPagingFlags(flags, &page, &limit, &all, "templates") + flags.StringVar(&locationID, "location", "", "location identifier") + flags.StringVar(&templateType, "type", templates.TemplateTypeTemplates, "template type: templates or from_scratch") + return cmd +} + +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 vSphere 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") + } + 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{"id", "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 +} diff --git a/internal/cli/vsphere_test.go b/internal/cli/vsphere_test.go new file mode 100644 index 0000000..7d796dd --- /dev/null +++ b/internal/cli/vsphere_test.go @@ -0,0 +1,188 @@ +package cli_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestVSphereLocationList(t *testing.T) { + isolate(t) + srv, last := server(t, http.StatusOK, + `{"data":[{"id":"l-1","code":"ANX01","name":"Vienna","country_name":"Austria"}]}`) + + 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) + require.Equal(t, "ID CODE NAME COUNTRY\n"+ + "l-1 ANX01 Vienna Austria\n", stdout) +} + +func TestVSphereLocationListEscapesFilters(t *testing.T) { + isolate(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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 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) + require.Equal(t, "ID NAME BUILD BIT\n"+ + "t-1 Ubuntu 24.04 64\n", stdout) +} + +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"`) +} + +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) + require.Equal(t, "ID STORAGE TYPE BANDWIDTH IOPS LATENCY\n"+ + "d-1 ssd 100 200 3\n", stdout) +} + +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") + _, _, err = run(t, append(tt.args, "--limit", "0", "--token", "tok")...) + require.ErrorContains(t, err, "--limit 0 must be between 1 and 1000") + }) + } +} + +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") + }) + } +} + +func TestVSphereListsWalkPages(t *testing.T) { + tests := []struct { + name string + args []string + body string + }{ + {"location", []string{"vsphere", "location", "list"}, `{"data":[{"id":"l-%d"}]}`}, + {"template", []string{"vsphere", "template", "list", "--location", "l-1"}, `[{"id":"t-%d"}]`}, + {"disk type", []string{"vsphere", "disk-type", "list", "--location", "l-1"}, `[{"id":"d-%d"}]`}, + } + 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) + w.Header().Set("Content-Type", "application/json") + 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) + var got []struct { + ID string `json:"id"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &got)) + require.Len(t, got, 2) + }) + } +} From ed9b3c9d876a7b3af2aed36ac1fad1ba291be735 Mon Sep 17 00:00:00 2001 From: ProbstenHias Date: Sun, 13 Sep 2026 15:30:46 +0200 Subject: [PATCH 2/3] fix(resource): reject unservable --page before authenticating Unpaged list commands checked pagination support only inside the fetch callback, after the API client was built. Running such a list with --page 2 and no token exited with the auth code instead of the usage code. Check paging support before building the client and share the error with the fetch path. --- internal/resource/verbs.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) 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 From abd1995f73541dce866a6bd0c6af9b98f352b87b Mon Sep 17 00:00:00 2001 From: ProbstenHias Date: Sun, 13 Sep 2026 15:30:46 +0200 Subject: [PATCH 3/3] refactor(vsphere): move template onto the generic resource spec The Engine template endpoint is not paginated; the legacy client sent --page values the API ignored, so --page 2 repeated page 1 and --all looped. Use the generic vspherev1.Template spec, which handles the unpaged endpoint and adds template get. Validate --location as an identifier, fall back to the country code when a location has no country name, align column headers with the rest of the CLI and pin paging, escaping, not-found and usage behaviour in tests. --- README.md | 16 +- docs/cli-design.md | 13 +- internal/cli/conformance_test.go | 13 +- internal/cli/vsphere.go | 106 ++++++------ internal/cli/vsphere_test.go | 286 ++++++++++++++++++++++++++++--- 5 files changed, 340 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index e2b8a1b..4612145 100644 --- a/README.md +++ b/README.md @@ -205,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 @@ -215,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 @@ -243,7 +243,7 @@ not verified against the Engine. | Resource | list | get | create | update | delete | extra | | --- | :-: | :-: | :-: | :-: | :-: | --- | | `vsphere vm` | [ ] | [ ] | [ ] | [ ] | [ ] | `power get`/`set` [ ] | -| `vsphere template` | [x] | [ ] | - | - | - | | +| `vsphere template` | [x] | [x] | - | - | - | | | `vsphere location` | [x] | - | - | - | - | | | `vsphere disk-type` | [x] | - | - | - | - | | | `vsphere nic-type` | [ ] | - | - | - | - | | diff --git a/docs/cli-design.md b/docs/cli-design.md index e5e2241..9a00ab3 100644 --- a/docs/cli-design.md +++ b/docs/cli-design.md @@ -376,11 +376,14 @@ formats, `--no-headers`, the plural alias, the empty-result note on stderr and t 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`, -the vSphere provisioning lists 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. +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 287e308..119648a 100644 --- a/internal/cli/conformance_test.go +++ b/internal/cli/conformance_test.go @@ -110,6 +110,7 @@ var engineActions = map[string]string{ "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"`, @@ -445,13 +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": {"location": "placeholder", "type": "templates"}, - "anexia vsphere disk-type list": {"location": "placeholder"}, + "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/vsphere.go b/internal/cli/vsphere.go index 6b98bd2..0ce4c29 100644 --- a/internal/cli/vsphere.go +++ b/internal/cli/vsphere.go @@ -5,9 +5,11 @@ import ( "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" - "go.anx.io/go-anxcloud/pkg/vsphere/provisioning/templates" "github.com/ProbstenHias/anexia-cli/internal/errmap" "github.com/ProbstenHias/anexia-cli/internal/resource" @@ -34,7 +36,7 @@ func newVSphereLocationListCommand(opts *globalOptions) *cobra.Command { cmd := &cobra.Command{ Use: "list", - Short: "List vSphere locations", + Short: "List locations", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { if err := resource.ValidatePaging(page, limit, all); err != nil { @@ -58,8 +60,8 @@ func newVSphereLocationListCommand(opts *globalOptions) *cobra.Command { return opts.Fail(fmt.Errorf("listing locations: %w", err)) } return resource.RenderList(cmd, w, "locations", found, - []string{"id", "code", "name", "country"}, - func(l *location.Location) []string { return []string{l.ID, l.Code, l.Name, l.CountryName} }) + []string{"identifier", "code", "name", "country"}, + func(l *location.Location) []string { return []string{l.ID, l.Code, l.Name, locationCountry(l)} }) }, } flags := cmd.Flags() @@ -70,57 +72,44 @@ func newVSphereLocationListCommand(opts *globalOptions) *cobra.Command { } func newVSphereTemplateCommand(opts *globalOptions) *cobra.Command { - return resource.Noun("template", "templates", "Work with vSphere templates", - newVSphereTemplateListCommand(opts), - ) + 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 newVSphereTemplateListCommand(opts *globalOptions) *cobra.Command { - var page, limit int - var all bool - var locationID, templateType string +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") - cmd := &cobra.Command{ - Use: "list", - Short: "List vSphere templates", - 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 templateType != templates.TemplateTypeTemplates && templateType != templates.TemplateTypeFromScratch { - return errmap.Usagef(`--type must be %q or %q`, templates.TemplateTypeTemplates, templates.TemplateTypeFromScratch) - } - 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 := templates.NewAPI(c) - found, err := resource.FetchPages(cmd.ErrOrStderr(), "templates", page, limit, all, func(p int) ([]templates.Template, error) { - return a.List(ctx, pathValue(locationID), templateType, p, limit) - }) - if err != nil { - return opts.Fail(fmt.Errorf("listing templates: %w", err)) - } - return resource.RenderList(cmd, w, "templates", found, - []string{"id", "name", "build", "bit"}, - func(t *templates.Template) []string { return []string{t.ID, t.Name, t.Build, t.WordSize} }) - }, + 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 } - flags := cmd.Flags() - resource.RegisterPagingFlags(flags, &page, &limit, &all, "templates") - flags.StringVar(&locationID, "location", "", "location identifier") - flags.StringVar(&templateType, "type", templates.TemplateTypeTemplates, "template type: templates or from_scratch") - return cmd } func newVSphereDiskTypeCommand(opts *globalOptions) *cobra.Command { @@ -136,7 +125,7 @@ func newVSphereDiskTypeListCommand(opts *globalOptions) *cobra.Command { cmd := &cobra.Command{ Use: "list", - Short: "List vSphere disk types", + Short: "List disk types", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { if err := resource.ValidatePaging(page, limit, all); err != nil { @@ -145,6 +134,9 @@ func newVSphereDiskTypeListCommand(opts *globalOptions) *cobra.Command { 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 @@ -163,7 +155,7 @@ func newVSphereDiskTypeListCommand(opts *globalOptions) *cobra.Command { return opts.Fail(fmt.Errorf("listing disk types: %w", err)) } return resource.RenderList(cmd, w, "disk types", found, - []string{"id", "storage type", "bandwidth", "iops", "latency"}, + []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)} }) @@ -174,3 +166,11 @@ func newVSphereDiskTypeListCommand(opts *globalOptions) *cobra.Command { 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 index 7d796dd..fababe2 100644 --- a/internal/cli/vsphere_test.go +++ b/internal/cli/vsphere_test.go @@ -5,27 +5,55 @@ import ( "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"}]}`) + 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) - require.Equal(t, "ID CODE NAME COUNTRY\n"+ - "l-1 ANX01 Vienna Austria\n", stdout) + // 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") @@ -38,6 +66,19 @@ func TestVSphereLocationListEscapesFilters(t *testing.T) { 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"}]`) @@ -45,8 +86,48 @@ func TestVSphereTemplateList(t *testing.T) { 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) - require.Equal(t, "ID NAME BUILD BIT\n"+ - "t-1 Ubuntu 24.04 64\n", stdout) + 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) { @@ -54,6 +135,77 @@ func TestVSphereTemplateListRejectsInvalidType(t *testing.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) { @@ -64,8 +216,53 @@ func TestVSphereDiskTypeList(t *testing.T) { 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) - require.Equal(t, "ID STORAGE TYPE BANDWIDTH IOPS LATENCY\n"+ - "d-1 ssd 100 200 3\n", stdout) + // 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) { @@ -125,34 +322,47 @@ func TestVSphereListsRejectInvalidPaging(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"}, - } { + 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 TestVSphereListsWalkPages(t *testing.T) { +func TestVSphereLegacyListsWalkPages(t *testing.T) { tests := []struct { name string args []string body string + want []string }{ - {"location", []string{"vsphere", "location", "list"}, `{"data":[{"id":"l-%d"}]}`}, - {"template", []string{"vsphere", "template", "list", "--location", "l-1"}, `[{"id":"t-%d"}]`}, - {"disk type", []string{"vsphere", "disk-type", "list", "--location", "l-1"}, `[{"id":"d-%d"}]`}, + {"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) { @@ -162,7 +372,6 @@ func TestVSphereListsWalkPages(t *testing.T) { page, err := strconv.Atoi(r.URL.Query().Get("page")) require.NoError(t, err) seen = append(seen, page) - w.Header().Set("Content-Type", "application/json") if page < 3 { _, _ = fmt.Fprintf(w, tt.body, page) return @@ -178,11 +387,44 @@ func TestVSphereListsWalkPages(t *testing.T) { 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) - var got []struct { - ID string `json:"id"` - } - require.NoError(t, json.Unmarshal([]byte(stdout), &got)) - require.Len(t, got, 2) + 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 +}