diff --git a/README.md b/README.md index c1370d1..795b91a 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,19 @@ anexia dns zone apply example.com --file changeset.json records to create and records to delete. Both confirm first, and both read stdin with `--file -`, which needs `--yes` since the prompt would otherwise read the document. +### Kubernetes + +```sh +anexia kubernetes cluster create --name demo --location +anexia kubernetes node-pool create --name workers --cluster --cpus 4 --memory 8 --disk 100 +anexia kubernetes cluster kubeconfig get > kubeconfig +anexia kubernetes node-pool list --cluster +``` + +Sizes are entered in GiB on the command line and sent as bytes to the Engine. Passing an existing +prefix turns off the Engine's automatic management of that prefix. `kubeconfig get` polls for the +generated document and is bounded by `--timeout`. + ### Global flags | Flag | Default | Description | @@ -195,11 +208,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` and `dns` groups below are implemented. Within `network`, `vlan` has every +The `core`, `network`, `dns` and `kubernetes` groups below are implemented. Within `network`, `vlan` has every verb because go-anxcloud models it generically; `prefix` has every verb hand-written against the older client, and `address` is read only for now because its write verbs are still to be declared. -Everything after those three groups is a roadmap of what the library can reach, read off -go-anxcloud v0.14.5 and not verified against the Engine. +The remaining groups, starting with `vsphere`, are roadmap items read off go-anxcloud v0.14.5 and +not verified against the Engine. ### core @@ -242,8 +255,8 @@ go-anxcloud v0.14.5 and not verified against the Engine. | Resource | list | get | create | update | delete | extra | | --- | :-: | :-: | :-: | :-: | :-: | --- | -| `kubernetes cluster` | [ ] | [ ] | [ ] | [ ] | [ ] | `kubeconfig get`/`delete` [ ] | -| `kubernetes node-pool` | [ ] | [ ] | [ ] | [ ] | [ ] | | +| `kubernetes cluster` | [x] | [x] | [x] | - | [x] | `kubeconfig get`/`delete` [x]; updates unsupported by the library | +| `kubernetes node-pool` | [x] | [x] | [x] | - | [x] | `--cluster` filter [x]; updates unsupported by the library | | `kubernetes disk` | [ ] | [ ] | [ ] | [ ] | [ ] | legacy client only | | `kubernetes network` | [ ] | [ ] | [ ] | [ ] | [ ] | legacy client only | diff --git a/docs/cli-design.md b/docs/cli-design.md index 58d0a30..32d44ab 100644 --- a/docs/cli-design.md +++ b/docs/cli-design.md @@ -28,7 +28,8 @@ 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` and `dns`, and later `vsphere`, `kubernetes`, `lbaas`, `e5e`, `frontier`, `storage`. +`network`, `dns` and `kubernetes`; roadmap groups include `vsphere`, `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`. diff --git a/internal/cli/conformance_test.go b/internal/cli/conformance_test.go index 533b0e9..29e3cb1 100644 --- a/internal/cli/conformance_test.go +++ b/internal/cli/conformance_test.go @@ -96,41 +96,51 @@ var flagNamePattern = regexp.MustCompile(`^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$`) // a missing separator that would otherwise be mistaken for a colon in the // nested cause. var engineActions = map[string]string{ - "anexia core location list": "listing locations", - "anexia core location get": `reading location "placeholder"`, - "anexia core resource list": "listing resources", - "anexia core resource get": `reading resource "placeholder"`, - "anexia core resource tag list": `listing tags of resource "placeholder"`, - "anexia core resource tag add": `tagging resource "placeholder"`, - "anexia core resource tag remove": `untagging resource "placeholder"`, - "anexia core tag list": "listing tags", - "anexia core tag get": `reading tag "placeholder"`, - "anexia core tag create": `creating tag "placeholder"`, - "anexia core tag delete": `deleting tag "placeholder"`, - "anexia core service list": "listing services", - "anexia network vlan list": "listing vlans", - "anexia network vlan get": `reading vlan "placeholder"`, - "anexia network vlan create": `creating vlan "placeholder"`, - "anexia network vlan update": `reading vlan "placeholder"`, - "anexia network vlan delete": `deleting vlan "placeholder"`, - "anexia network prefix list": "listing prefixes", - "anexia network prefix get": `reading prefix "placeholder"`, - "anexia network prefix create": "creating prefix", - "anexia network prefix update": `updating prefix "placeholder"`, - "anexia network prefix delete": `deleting prefix "placeholder"`, - "anexia network address list": "listing addresses", - "anexia network address get": `reading address "placeholder"`, - "anexia dns zone list": "listing zones", - "anexia dns zone get": `reading zone "placeholder"`, - "anexia dns zone create": `creating zone "placeholder"`, - "anexia dns zone update": `reading zone "placeholder"`, - "anexia dns zone delete": `deleting zone "placeholder"`, - "anexia dns zone import": `importing zone "placeholder"`, - "anexia dns zone apply": `applying changeset to zone "placeholder"`, - "anexia dns record list": "listing records", - "anexia dns record create": `creating record "placeholder"`, - "anexia dns record update": `reading record "placeholder"`, - "anexia dns record delete": `deleting record "placeholder"`, + "anexia core location list": "listing locations", + "anexia core location get": `reading location "placeholder"`, + "anexia core resource list": "listing resources", + "anexia core resource get": `reading resource "placeholder"`, + "anexia core resource tag list": `listing tags of resource "placeholder"`, + "anexia core resource tag add": `tagging resource "placeholder"`, + "anexia core resource tag remove": `untagging resource "placeholder"`, + "anexia core tag list": "listing tags", + "anexia core tag get": `reading tag "placeholder"`, + "anexia core tag create": `creating tag "placeholder"`, + "anexia core tag delete": `deleting tag "placeholder"`, + "anexia core service list": "listing services", + "anexia network vlan list": "listing vlans", + "anexia network vlan get": `reading vlan "placeholder"`, + "anexia network vlan create": `creating vlan "placeholder"`, + "anexia network vlan update": `reading vlan "placeholder"`, + "anexia network vlan delete": `deleting vlan "placeholder"`, + "anexia network prefix list": "listing prefixes", + "anexia network prefix get": `reading prefix "placeholder"`, + "anexia network prefix create": "creating prefix", + "anexia network prefix update": `updating prefix "placeholder"`, + "anexia network prefix delete": `deleting prefix "placeholder"`, + "anexia network address list": "listing addresses", + "anexia network address get": `reading address "placeholder"`, + "anexia dns zone list": "listing zones", + "anexia dns zone get": `reading zone "placeholder"`, + "anexia dns zone create": `creating zone "placeholder"`, + "anexia dns zone update": `reading zone "placeholder"`, + "anexia dns zone delete": `deleting zone "placeholder"`, + "anexia dns zone import": `importing zone "placeholder"`, + "anexia dns zone apply": `applying changeset to zone "placeholder"`, + "anexia dns record list": "listing records", + "anexia dns record create": `creating record "placeholder"`, + "anexia dns record update": `reading record "placeholder"`, + "anexia dns record delete": `deleting record "placeholder"`, + "anexia kubernetes cluster list": "listing clusters", + "anexia kubernetes cluster get": `reading cluster "placeholder"`, + "anexia kubernetes cluster create": `creating cluster "placeholder"`, + "anexia kubernetes cluster delete": `deleting cluster "placeholder"`, + "anexia kubernetes cluster kubeconfig get": `reading kubeconfig of cluster "placeholder"`, + "anexia kubernetes cluster kubeconfig delete": `deleting kubeconfig of cluster "placeholder"`, + "anexia kubernetes node-pool list": "listing node-pools", + "anexia kubernetes node-pool get": `reading node-pool "placeholder"`, + "anexia kubernetes node-pool create": `creating node-pool "placeholder"`, + "anexia kubernetes node-pool delete": `deleting node-pool "placeholder"`, } // engineCommand reports whether cmd is a leaf that talks to the Engine. @@ -244,11 +254,14 @@ func TestConformanceLeafAliasesUseKnownVerbs(t *testing.T) { t.Parallel() allowed := map[string]map[string]bool{ - "anexia core tag delete": {"destroy": true}, - "anexia network vlan delete": {"destroy": true}, - "anexia network prefix delete": {"destroy": true}, - "anexia dns zone delete": {"destroy": true}, - "anexia dns record delete": {"destroy": true}, + "anexia core tag delete": {"destroy": true}, + "anexia network vlan delete": {"destroy": true}, + "anexia network prefix delete": {"destroy": true}, + "anexia dns zone delete": {"destroy": true}, + "anexia dns record delete": {"destroy": true}, + "anexia kubernetes cluster delete": {"destroy": true}, + "anexia kubernetes cluster kubeconfig delete": {"destroy": true}, + "anexia kubernetes node-pool delete": {"destroy": true}, } checked := 0 @@ -416,6 +429,10 @@ var invocationFlags = map[string]string{ "version": "4", "netmask": "24", "vlan": "placeholder", + "cluster": "placeholder", + "cpus": "1", + "memory": "2", + "disk": "20", } // commandInvocationFlags override invocationFlags for one command where a diff --git a/internal/cli/kubernetes.go b/internal/cli/kubernetes.go new file mode 100644 index 0000000..40e27eb --- /dev/null +++ b/internal/cli/kubernetes.go @@ -0,0 +1,189 @@ +package cli + +import ( + "fmt" + "io" + "net/url" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "go.anx.io/go-anxcloud/pkg/apis/common" + corev1 "go.anx.io/go-anxcloud/pkg/apis/core/v1" + kubernetesv1 "go.anx.io/go-anxcloud/pkg/apis/kubernetes/v1" + "go.anx.io/go-anxcloud/pkg/utils/pointer" + + "github.com/ProbstenHias/anexia-cli/internal/confirm" + "github.com/ProbstenHias/anexia-cli/internal/errmap" + "github.com/ProbstenHias/anexia-cli/internal/resource" +) + +// newKubernetesCommand groups cluster and node-pool operations under one +// command tree so both resources use the standard Engine verbs. +func newKubernetesCommand(opts *globalOptions) *cobra.Command { + return resource.Group("kubernetes", "Kubernetes clusters and node pools", + newKubernetesClusterCommand(opts), + newKubernetesNodePoolCommand(opts), + ) +} + +// newKubernetesClusterCommand exposes the lifecycle supported by the library. +// Clusters do not support updates in go-anxcloud, so no update verb is added. +func newKubernetesClusterCommand(opts *globalOptions) *cobra.Command { + cmd := resource.Command(opts, resource.Spec[kubernetesv1.Cluster, *kubernetesv1.Cluster]{ + Noun: "cluster", + Short: "Work with Kubernetes clusters", + List: true, + Get: true, + Delete: true, + Identify: func(cluster *kubernetesv1.Cluster, identifier string) { + cluster.Identifier = identifier + }, + CreatePayload: clusterCreateFlags, + // The library says the resource does not support updates. + Columns: []resource.Column[kubernetesv1.Cluster]{ + {Name: "identifier", Value: func(cluster *kubernetesv1.Cluster) string { return cluster.Identifier }}, + {Name: "name", Value: func(cluster *kubernetesv1.Cluster) string { return cluster.Name }}, + }, + }) + cmd.AddCommand(newKubernetesKubeconfigCommand(opts)) + return cmd +} + +// clusterCreateFlags builds the cluster payload and leaves omitted boolean +// settings unset so the Engine can apply its own defaults. +func clusterCreateFlags(flags *pflag.FlagSet) func(*kubernetesv1.Cluster) error { + name := flags.String("name", "", "cluster name") + location := flags.String("location", "", "location identifier where the cluster is created") + version := flags.String("version", "", "Kubernetes version, empty uses the Engine default") + needsServiceVMs := flags.Bool("needs-service-vms", false, "create Service VMs") + natGateways := flags.Bool("enable-nat-gateways", false, "enable NAT gateways") + lbaas := flags.Bool("enable-lbaas", false, "enable LBaaS") + autoscaling := flags.Bool("enable-autoscaling", false, "enable autoscaling") + internalPrefix := flags.String("internal-ipv4-prefix", "", "existing prefix identifier; turns off automatic management of this prefix") + externalPrefix := flags.String("external-ipv4-prefix", "", "existing prefix identifier; turns off automatic management of this prefix") + externalIPv6Prefix := flags.String("external-ipv6-prefix", "", "existing prefix identifier; turns off automatic management of this prefix") + allowlist := flags.String("api-server-allowlist", "", "space-separated CIDRs allowed to access the API server") + + return func(cluster *kubernetesv1.Cluster) error { + if *name == "" { + return errmap.Usagef("--name is required") + } + if *location == "" { + return errmap.Usagef("--location is required") + } + + cluster.Name = *name + cluster.Location = corev1.Location{Identifier: *location} + cluster.Version = *version + cluster.ApiServerAllowlist = *allowlist + if flags.Changed("needs-service-vms") { + cluster.NeedsServiceVMs = pointer.Bool(*needsServiceVMs) + } + if flags.Changed("enable-nat-gateways") { + cluster.EnableNATGateways = pointer.Bool(*natGateways) + } + if flags.Changed("enable-lbaas") { + cluster.EnableLBaaS = pointer.Bool(*lbaas) + } + if flags.Changed("enable-autoscaling") { + cluster.EnableAutoscaling = pointer.Bool(*autoscaling) + } + if *internalPrefix != "" { + cluster.InternalIPv4Prefix = &common.PartialResource{Identifier: *internalPrefix} + cluster.ManageInternalIPv4Prefix = pointer.Bool(false) + } + if *externalPrefix != "" { + cluster.ExternalIPv4Prefix = &common.PartialResource{Identifier: *externalPrefix} + cluster.ManageExternalIPv4Prefix = pointer.Bool(false) + } + if *externalIPv6Prefix != "" { + cluster.ExternalIPv6Prefix = &common.PartialResource{Identifier: *externalIPv6Prefix} + cluster.ManageExternalIPv6Prefix = pointer.Bool(false) + } + return nil + } +} + +// newKubernetesKubeconfigCommand adds the document operations under a cluster. +func newKubernetesKubeconfigCommand(opts *globalOptions) *cobra.Command { + return resource.Noun("kubeconfig", "kubeconfigs", "Manage the kubeconfig of a cluster", + newKubernetesKubeconfigGetCommand(opts), + newKubernetesKubeconfigDeleteCommand(opts), + ) +} + +// newKubernetesKubeconfigGetCommand prints the document verbatim after the +// library requests and polls for it when the cluster has none. +func newKubernetesKubeconfigGetCommand(opts *globalOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "Get a cluster kubeconfig", + Long: "Get a cluster kubeconfig. This triggers the Engine's request-kubeconfig rule and polls until the kubeconfig appears, bounded by --timeout.", + Args: cobra.ExactArgs(1), + } + cmd.RunE = func(cmd *cobra.Command, args []string) error { + if err := validateKubeconfigCluster(args[0]); err != nil { + return err + } + apiClient, err := opts.API(cmd.Flags()) + if err != nil { + return err + } + ctx, cancel := opts.Context(cmd.Context()) + defer cancel() + config, err := kubernetesv1.GetKubeConfig(ctx, apiClient, args[0]) + if err != nil { + return opts.Fail(fmt.Errorf("reading kubeconfig of cluster %q: %w", args[0], err)) + } + if _, err := io.WriteString(cmd.OutOrStdout(), config); err != nil { + return fmt.Errorf("writing kubeconfig: %w", err) + } + return nil + } + return cmd +} + +// validateKubeconfigCluster is stricter than ValidateIdentifier because +// go-anxcloud interpolates the cluster ID into the kubeconfig rule URL. +func validateKubeconfigCluster(id string) error { + if err := resource.ValidateIdentifier("cluster", id); err != nil { + return err + } + if url.PathEscape(id) != id { + return errmap.Usagef("invalid cluster identifier %q", id) + } + return nil +} + +// newKubernetesKubeconfigDeleteCommand confirms before firing the remove rule. +func newKubernetesKubeconfigDeleteCommand(opts *globalOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Aliases: []string{"destroy"}, + Short: "Delete a cluster kubeconfig", + Args: cobra.ExactArgs(1), + } + cmd.RunE = func(cmd *cobra.Command, args []string) error { + if err := validateKubeconfigCluster(args[0]); err != nil { + return err + } + question := fmt.Sprintf("delete kubeconfig of cluster %q", args[0]) + if err := confirm.Prompt(cmd.InOrStdin(), cmd.ErrOrStderr(), question, opts.AssumeYes()); err != nil { + return err + } + apiClient, err := opts.API(cmd.Flags()) + if err != nil { + return err + } + ctx, cancel := opts.Context(cmd.Context()) + defer cancel() + if err := kubernetesv1.RemoveKubeConfig(ctx, apiClient, args[0]); err != nil { + return opts.Fail(fmt.Errorf("deleting kubeconfig of cluster %q: %w", args[0], err)) + } + if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "deleted kubeconfig of cluster %s\n", args[0]); err != nil { + return fmt.Errorf("writing status: %w", err) + } + return nil + } + return cmd +} diff --git a/internal/cli/kubernetes_node_pool.go b/internal/cli/kubernetes_node_pool.go new file mode 100644 index 0000000..2759f5f --- /dev/null +++ b/internal/cli/kubernetes_node_pool.go @@ -0,0 +1,88 @@ +package cli + +import ( + "github.com/spf13/cobra" + "github.com/spf13/pflag" + kubernetesv1 "go.anx.io/go-anxcloud/pkg/apis/kubernetes/v1" + "go.anx.io/go-anxcloud/pkg/utils/pointer" + + "github.com/ProbstenHias/anexia-cli/internal/errmap" + "github.com/ProbstenHias/anexia-cli/internal/resource" +) + +const ( + gibibyte = 1 << 30 + minCPUs = 1 + maxCPUs = 16 + minMemoryGiB = 2 + maxMemoryGiB = 64 + minDiskGiB = 20 + maxDiskGiB = 1600 +) + +// newKubernetesNodePoolCommand exposes node-pool lifecycle verbs. The library +// says node pools do not support updates, so update is intentionally absent. +func newKubernetesNodePoolCommand(opts *globalOptions) *cobra.Command { + return resource.Command(opts, resource.Spec[kubernetesv1.NodePool, *kubernetesv1.NodePool]{ + Noun: "node-pool", + Short: "Work with Kubernetes node pools", + List: true, + Get: true, + Delete: true, + Identify: func(nodePool *kubernetesv1.NodePool, identifier string) { + nodePool.Identifier = identifier + }, + CreatePayload: nodePoolCreateFlags, + Filters: func(flags *pflag.FlagSet) func(*kubernetesv1.NodePool) { + cluster := flags.String("cluster", "", "only list node pools in this cluster") + return func(nodePool *kubernetesv1.NodePool) { + if *cluster != "" { + nodePool.Cluster.Identifier = *cluster + } + } + }, + Columns: []resource.Column[kubernetesv1.NodePool]{ + {Name: "identifier", Value: func(nodePool *kubernetesv1.NodePool) string { return nodePool.Identifier }}, + {Name: "name", Value: func(nodePool *kubernetesv1.NodePool) string { return nodePool.Name }}, + }, + }) +} + +// nodePoolCreateFlags validates human-sized resource values and converts them +// to the byte representation required by the Engine. +func nodePoolCreateFlags(flags *pflag.FlagSet) func(*kubernetesv1.NodePool) error { + name := flags.String("name", "", "node pool name") + cluster := flags.String("cluster", "", "cluster identifier") + cpus := flags.Int("cpus", 0, "CPU cores per node") + memory := flags.Int("memory", 0, "memory per node in GiB") + disk := flags.Int("disk", 0, "disk per node in GiB") + replicas := flags.Int("replicas", 0, "number of node replicas") + + return func(nodePool *kubernetesv1.NodePool) error { + if *name == "" { + return errmap.Usagef("--name is required") + } + if *cluster == "" { + return errmap.Usagef("--cluster is required") + } + if *cpus < minCPUs || *cpus > maxCPUs { + return errmap.Usagef("--cpus must be between %d and %d", minCPUs, maxCPUs) + } + if *memory < minMemoryGiB || *memory > maxMemoryGiB { + return errmap.Usagef("--memory must be between %d and %d", minMemoryGiB, maxMemoryGiB) + } + if *disk < minDiskGiB || *disk > maxDiskGiB { + return errmap.Usagef("--disk must be between %d and %d", minDiskGiB, maxDiskGiB) + } + + nodePool.Name = *name + nodePool.Cluster.Identifier = *cluster + nodePool.CPUs = *cpus + nodePool.Memory = *memory * gibibyte + nodePool.DiskSize = *disk * gibibyte + if flags.Changed("replicas") { + nodePool.Replicas = pointer.Int(*replicas) + } + return nil + } +} diff --git a/internal/cli/kubernetes_test.go b/internal/cli/kubernetes_test.go new file mode 100644 index 0000000..c2c9935 --- /dev/null +++ b/internal/cli/kubernetes_test.go @@ -0,0 +1,359 @@ +package cli_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ProbstenHias/anexia-cli/internal/errmap" +) + +func TestKubernetesHelpListsResourcesAndVerbs(t *testing.T) { + isolate(t) + + stdout, _, err := run(t, "kubernetes") + require.NoError(t, err) + require.Contains(t, stdout, "cluster") + require.Contains(t, stdout, "node-pool") + + tests := []struct { + name string + args []string + }{ + {name: "cluster", args: []string{"kubernetes", "cluster"}}, + {name: "node-pool", args: []string{"kubernetes", "node-pool"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stdout, _, err := run(t, tt.args...) + require.NoError(t, err) + for _, verb := range []string{"list", "get", "create", "delete"} { + require.Contains(t, stdout, verb) + } + require.NotContains(t, stdout, "update") + }) + } +} + +func TestKubernetesClusterCreateFlags(t *testing.T) { + tests := []struct { + name string + flags []string + wantError string + wantFields map[string]any + absentField string + }{ + {name: "missing name", flags: []string{"--location", "l-1"}, wantError: "--name is required"}, + {name: "missing location", flags: []string{"--name", "demo"}, wantError: "--location is required"}, + { + name: "payload", + flags: []string{ + "--name", "demo", "--location", "l-1", "--version", "1.29", + "--needs-service-vms=false", "--enable-nat-gateways", "--enable-lbaas=false", "--enable-autoscaling", + "--internal-ipv4-prefix", "p-in", "--external-ipv4-prefix", "p-out", "--external-ipv6-prefix", "p-v6", + "--api-server-allowlist", "10.0.0.0/8 192.0.2.0/24", + }, + wantFields: map[string]any{ + "name": "demo", + "location": "l-1", + "version": "1.29", + "needs_service_vms": false, + "enable_nat_gateways": true, + "enable_lbaas": false, + "autoscaling": true, + "internal_ipv4_prefix": "p-in", + "manage_internal_ipv4_prefix": false, + "external_ipv4_prefix": "p-out", + "manage_external_ipv4_prefix": false, + "external_ipv6_prefix": "p-v6", + "manage_external_ipv6_prefix": false, + "apiserver_allowlist": "10.0.0.0/8 192.0.2.0/24", + }, + }, + { + name: "engine defaults for omitted booleans", + flags: []string{"--name", "demo", "--location", "l-1"}, + absentField: "needs_service_vms", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isolate(t) + srv, last := server(t, http.StatusOK, `{"identifier":"c-1","name":"demo"}`) + args := append([]string{"kubernetes", "cluster", "create"}, tt.flags...) + args = append(args, "--token", "tok", "--api-base-url", srv.URL) + _, _, err := run(t, args...) + if tt.wantError != "" { + require.Error(t, err) + require.Contains(t, errmap.Message(err), tt.wantError) + require.Empty(t, last.method) + return + } + + require.NoError(t, err) + var body map[string]any + require.NoError(t, json.Unmarshal([]byte(last.body), &body)) + for field, want := range tt.wantFields { + require.Equal(t, want, body[field], "field %s: got %v, want %v", field, body[field], want) + } + if tt.absentField != "" { + _, present := body[tt.absentField] + require.False(t, present, "field %s: got present, want absent", tt.absentField) + } + }) + } +} + +func TestKubernetesNodePoolCreateFlags(t *testing.T) { + base := []string{"--name", "workers", "--cluster", "c-1", "--cpus", "4", "--memory", "4", "--disk", "20"} + tests := []struct { + name string + flags []string + wantError string + want map[string]any + }{ + {name: "missing name", flags: withoutFlag(base, "--name"), wantError: "--name is required"}, + {name: "missing cluster", flags: withoutFlag(base, "--cluster"), wantError: "--cluster is required"}, + {name: "cpus below range", flags: replaceFlag(base, "--cpus", "0"), wantError: "--cpus must be between 1 and 16"}, + {name: "cpus above range", flags: replaceFlag(base, "--cpus", "17"), wantError: "--cpus must be between 1 and 16"}, + {name: "memory below range", flags: replaceFlag(base, "--memory", "1"), wantError: "--memory must be between 2 and 64"}, + {name: "memory above range", flags: replaceFlag(base, "--memory", "65"), wantError: "--memory must be between 2 and 64"}, + {name: "disk below range", flags: replaceFlag(base, "--disk", "19"), wantError: "--disk must be between 20 and 1600"}, + {name: "disk above range", flags: replaceFlag(base, "--disk", "1601"), wantError: "--disk must be between 20 and 1600"}, + { + name: "payload", + flags: append(append([]string{}, base...), "--replicas", "3"), + want: map[string]any{ + "name": "workers", + "cluster": "c-1", + "cpus": float64(4), + "memory": float64(4 << 30), + "disk_size": float64(20 << 30), + "replicas": float64(3), + }, + }, + { + name: "replicas omitted for engine default", + flags: base, + want: map[string]any{}, + }, + { + name: "zero replicas is sent", + flags: append(append([]string{}, base...), "--replicas", "0"), + want: map[string]any{"replicas": float64(0)}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isolate(t) + srv, last := server(t, http.StatusOK, `{"identifier":"np-1","name":"workers"}`) + args := append([]string{"kubernetes", "node-pool", "create"}, tt.flags...) + args = append(args, "--token", "tok", "--api-base-url", srv.URL) + _, _, err := run(t, args...) + if tt.wantError != "" { + require.Error(t, err) + require.Contains(t, errmap.Message(err), tt.wantError) + require.Empty(t, last.method) + return + } + + require.NoError(t, err) + var body map[string]any + require.NoError(t, json.Unmarshal([]byte(last.body), &body)) + for field, want := range tt.want { + require.Equal(t, want, body[field], "field %s: got %v, want %v", field, body[field], want) + } + if tt.name == "replicas omitted for engine default" { + _, present := body["replicas"] + require.False(t, present, "field replicas: got present, want absent") + } + }) + } +} + +func TestKubernetesListColumns(t *testing.T) { + tests := []struct { + name string + args []string + body string + want string + }{ + { + name: "cluster partial objects", + args: []string{"kubernetes", "cluster", "list"}, + body: `{"data":{"data":[{"identifier":"c-1","name":"demo"},{"identifier":"c-2","name":"other"}]}}`, + want: "c-1\tdemo\nc-2\tother\n", + }, + { + name: "node pool partial objects", + args: []string{"kubernetes", "node-pool", "list"}, + body: `{"data":{"data":[{"identifier":"np-1","name":"workers"},{"identifier":"np-2","name":"other"}]}}`, + want: "np-1\tworkers\nnp-2\tother\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isolate(t) + srv, _ := server(t, http.StatusOK, tt.body) + args := append(append([]string{}, tt.args...), "-o", "tsv", "--no-headers", "--token", "tok", "--api-base-url", srv.URL) + stdout, _, err := run(t, args...) + require.NoError(t, err) + require.Equal(t, tt.want, stdout, "got %q, want %q", stdout, tt.want) + }) + } +} + +func TestKubernetesNodePoolClusterFilter(t *testing.T) { + isolate(t) + srv, last := server(t, http.StatusOK, `{"data":{"data":[]}}`) + + _, _, err := run(t, "kubernetes", "node-pools", "list", "--cluster", "c-1", "--token", "tok", "--api-base-url", srv.URL) + require.NoError(t, err) + query, err := url.ParseQuery(last.query) + require.NoError(t, err) + require.Equal(t, "cluster=c-1", query.Get("filters")) +} + +func TestKubernetesKubeconfigOperations(t *testing.T) { + tests := []struct { + name string + responses []string + args []string + wantOutput string + wantPath string + wantMethod string + }{ + { + name: "existing config", + responses: []string{`{"identifier":"c-1","kubeconfig":"apiVersion: v1\n"}`}, + args: []string{"kubernetes", "cluster", "kubeconfig", "get", "c-1"}, + wantOutput: "apiVersion: v1\n", + }, + { + name: "request rule then poll", + responses: []string{`{"identifier":"c-1"}`, `{}`, `{"identifier":"c-1","kubeconfig":"config"}`}, + args: []string{"kubernetes", "cluster", "kubeconfig", "get", "c-1", "--timeout", "7s"}, + wantOutput: "config", + wantPath: "/api/kubernetes/v1/cluster.json/c-1/rule/12277a581e1c47cba72338425a008aa3", + wantMethod: http.MethodPost, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isolate(t) + var mu sync.Mutex + var seen []request + index := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + var body []byte + if r.Body != nil { + body, _ = io.ReadAll(r.Body) + } + seen = append(seen, request{method: r.Method, path: r.URL.Path, query: r.URL.RawQuery, body: string(body)}) + response := tt.responses[index] + index++ + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, response) + })) + t.Cleanup(srv.Close) + + args := append(append([]string{}, tt.args...), "--token", "tok", "--api-base-url", srv.URL) + stdout, _, err := run(t, args...) + require.NoError(t, err) + require.Equal(t, tt.wantOutput, stdout, "got %q, want %q", stdout, tt.wantOutput) + if tt.wantPath != "" { + require.Len(t, seen, len(tt.responses)) + found := false + for _, request := range seen { + if request.path == tt.wantPath && request.method == tt.wantMethod { + found = true + break + } + } + require.True(t, found, "got requests %v, want %s %s", seen, tt.wantMethod, tt.wantPath) + } + }) + } +} + +func TestKubernetesKubeconfigDeleteCallsRule(t *testing.T) { + isolate(t) + srv, last := server(t, http.StatusOK, `{}`) + + _, stderr, err := runWithInput(t, "y\n", "kubernetes", "cluster", "kubeconfig", "delete", "c-1", "--token", "tok", "--api-base-url", srv.URL) + require.NoError(t, err) + require.Equal(t, http.MethodPost, last.method) + require.Equal(t, "/api/kubernetes/v1/cluster.json/c-1/rule/eec87131729e44fa91a4b7ee8c365a26", last.path) + require.Equal(t, "delete kubeconfig of cluster \"c-1\" [y/N]: deleted kubeconfig of cluster c-1\n", stderr) +} + +func TestKubernetesKubeconfigRejectsEscapedIdentifiers(t *testing.T) { + tests := []struct { + name string + id string + verb string + }{ + {name: "question mark get", id: "c?1", verb: "get"}, + {name: "fragment get", id: "c#1", verb: "get"}, + {name: "encoded slash get", id: "c%2F1", verb: "get"}, + {name: "space delete", id: "c 1", verb: "delete"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isolate(t) + srv, last := server(t, http.StatusOK, `{}`) + args := []string{"kubernetes", "cluster", "kubeconfig", tt.verb, tt.id, "--timeout", "1ms", "--token", "tok", "--api-base-url", srv.URL} + if tt.verb == "delete" { + args = append(args, "--yes") + } + _, _, err := run(t, args...) + require.Error(t, err) + require.Contains(t, errmap.Message(err), "invalid cluster identifier") + require.Empty(t, last.method, "got request %s, want no request", last.method) + }) + } +} + +func TestKubernetesKubeconfigDeleteRespectsConfirmation(t *testing.T) { + isolate(t) + srv, last := server(t, http.StatusOK, `{}`) + + _, _, err := runWithInput(t, "n\n", "kubernetes", "cluster", "kubeconfig", "delete", "c-1", "--token", "tok", "--api-base-url", srv.URL) + require.Error(t, err) + require.Empty(t, last.method, "got request %s, want no request", last.method) +} + +func withoutFlag(flags []string, name string) []string { + result := make([]string, 0, len(flags)) + for i := 0; i < len(flags); i++ { + if flags[i] == name { + i++ + continue + } + result = append(result, flags[i]) + } + return result +} + +func replaceFlag(flags []string, name, value string) []string { + result := append([]string{}, flags...) + for i := range result { + if result[i] == name && i+1 < len(result) { + result[i+1] = value + } + } + return result +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 849eb89..93a9e81 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -201,6 +201,7 @@ func NewRootCommand(d Deps) *cobra.Command { newCoreCommand(opts), newNetworkCommand(opts), newDNSCommand(opts), + newKubernetesCommand(opts), newConfigCommand(opts), newVersionCommand(), newMovedCommand("location", "anexia core location list"),