From 7bf25c0c600f58575cd0c4469b767115903ccb42 Mon Sep 17 00:00:00 2001 From: ProbstenHias Date: Fri, 11 Sep 2026 17:03:46 +0200 Subject: [PATCH 1/2] feat(kubernetes): add cluster, node-pool and kubeconfig commands Implement the kubernetes group with cluster and node-pool list, get, create and delete, a --cluster filter on node pools, and kubeconfig get/delete on clusters. Update verbs are omitted because go-anxcloud declares both resources non-updatable. --- README.md | 21 +- internal/cli/conformance_test.go | 97 +++++--- internal/cli/kubernetes.go | 195 +++++++++++++++ internal/cli/kubernetes_node_pool.go | 108 ++++++++ internal/cli/kubernetes_test.go | 358 +++++++++++++++++++++++++++ internal/cli/root.go | 1 + 6 files changed, 736 insertions(+), 44 deletions(-) create mode 100644 internal/cli/kubernetes.go create mode 100644 internal/cli/kubernetes_node_pool.go create mode 100644 internal/cli/kubernetes_test.go diff --git a/README.md b/README.md index c1370d1..b29a5b1 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,10 +208,10 @@ 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 +Everything after those four groups is a roadmap of what the library can reach, 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/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..f24a217 --- /dev/null +++ b/internal/cli/kubernetes.go @@ -0,0 +1,195 @@ +package cli + +import ( + "fmt" + "io" + + "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 }}, + {Name: "version", Value: func(cluster *kubernetesv1.Cluster) string { return cluster.Version }}, + {Name: "location", Value: clusterLocation}, + {Name: "state", Value: func(cluster *kubernetesv1.Cluster) string { + if cluster.State.Text != "" { + return cluster.State.Text + } + return cluster.State.ID + }}, + }, + }) + 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 + } +} + +// clusterLocation chooses the most useful location value returned by the +// Engine, falling back to the identifier for sparse responses. +func clusterLocation(cluster *kubernetesv1.Cluster) string { + if cluster.Location.Code != "" { + return cluster.Location.Code + } + if cluster.Location.Name != "" { + return cluster.Location.Name + } + return cluster.Location.Identifier +} + +// 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 := resource.ValidateIdentifier("cluster", args[0]); err != nil { + return err + } + if _, err := opts.Writer(cmd.OutOrStdout()); 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)) + } + _, err = io.WriteString(cmd.OutOrStdout(), config) + return err + } + return cmd +} + +// 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 := resource.ValidateIdentifier("cluster", 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)) + } + _, err = fmt.Fprintf(cmd.ErrOrStderr(), "deleted kubeconfig of cluster %s\n", args[0]) + return err + } + return cmd +} diff --git a/internal/cli/kubernetes_node_pool.go b/internal/cli/kubernetes_node_pool.go new file mode 100644 index 0000000..f72d4d8 --- /dev/null +++ b/internal/cli/kubernetes_node_pool.go @@ -0,0 +1,108 @@ +package cli + +import ( + "strconv" + + "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" +) + +// 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 }}, + {Name: "cluster", Value: func(nodePool *kubernetesv1.NodePool) string { + if nodePool.Cluster.Name != "" { + return nodePool.Cluster.Name + } + return nodePool.Cluster.Identifier + }}, + {Name: "replicas", Value: func(nodePool *kubernetesv1.NodePool) string { + if nodePool.Replicas == nil { + return "" + } + return strconv.Itoa(*nodePool.Replicas) + }}, + {Name: "cpus", Value: func(nodePool *kubernetesv1.NodePool) string { return strconv.Itoa(nodePool.CPUs) }}, + {Name: "memory", Value: func(nodePool *kubernetesv1.NodePool) string { return gibibytes(nodePool.Memory) }}, + {Name: "disk", Value: func(nodePool *kubernetesv1.NodePool) string { return gibibytes(nodePool.DiskSize) }}, + {Name: "state", Value: func(nodePool *kubernetesv1.NodePool) string { + if nodePool.State.Text != "" { + return nodePool.State.Text + } + return nodePool.State.ID + }}, + }, + }) +} + +// 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") + operatingSystem := flags.String("operating-system", string(kubernetesv1.FlatcarLinux), "operating system for nodes") + + return func(nodePool *kubernetesv1.NodePool) error { + if *name == "" { + return errmap.Usagef("--name is required") + } + if *cluster == "" { + return errmap.Usagef("--cluster is required") + } + if *cpus <= 0 { + return errmap.Usagef("--cpus must be greater than zero") + } + if *memory <= 0 { + return errmap.Usagef("--memory must be greater than zero") + } + if *disk <= 0 { + return errmap.Usagef("--disk must be greater than zero") + } + + nodePool.Name = *name + nodePool.Cluster.Identifier = *cluster + nodePool.CPUs = *cpus + nodePool.Memory = *memory * (1 << 30) + nodePool.DiskSize = *disk * (1 << 30) + nodePool.OperatingSystem = kubernetesv1.OperatingSystem(*operatingSystem) + if flags.Changed("replicas") { + nodePool.Replicas = pointer.Int(*replicas) + } + return nil + } +} + +// gibibytes renders a byte value using the CLI's GiB display unit. +func gibibytes(bytes int) string { + return strconv.Itoa(bytes/(1<<30)) + "Gi" +} diff --git a/internal/cli/kubernetes_test.go b/internal/cli/kubernetes_test.go new file mode 100644 index 0000000..e61601b --- /dev/null +++ b/internal/cli/kubernetes_test.go @@ -0,0 +1,358 @@ +package cli_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "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: "zero cpus", flags: replaceFlag(base, "--cpus", "0"), wantError: "--cpus must be greater than zero"}, + {name: "zero memory", flags: replaceFlag(base, "--memory", "0"), wantError: "--memory must be greater than zero"}, + {name: "zero disk", flags: replaceFlag(base, "--disk", "0"), wantError: "--disk must be greater than zero"}, + { + name: "payload and default operating system", + 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), + "operating_system": "Flatcar Linux", + }, + }, + { + name: "replicas omitted for engine default", + flags: base, + want: map[string]any{"operating_system": "Flatcar Linux"}, + }, + } + + 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 location and state fallbacks", + args: []string{"kubernetes", "cluster", "list"}, + body: `{"data":{"data":[{"identifier":"c-1","name":"demo","version":"1.29","location":{"code":"ANX04"},"state":{"title":"Running"}},{"identifier":"c-2","name":"other","location":{"name":"Vienna"},"state":{"id":"pending"}},{"identifier":"c-3","location":{"identifier":"l-3"}}]}}`, + want: "c-1\tdemo\t1.29\tANX04\tRunning\nc-2\tother\t\tVienna\tpending\nc-3\t\t\tl-3\t\n", + }, + { + name: "node pool values", + args: []string{"kubernetes", "node-pool", "list"}, + body: `{"data":{"data":[{"identifier":"np-1","name":"workers","cluster":{"name":"demo"},"replicas":3,"cpus":4,"memory":4294967296,"disk_size":21474836480,"state":{"title":"Running"}},{"identifier":"np-2","cluster":{"identifier":"c-2"},"memory":2147483648,"disk_size":21474836480,"state":{"id":"pending"}}]}}`, + want: "np-1\tworkers\tdemo\t3\t4\t4Gi\t20Gi\tRunning\nnp-2\t\tc-2\t\t0\t2Gi\t20Gi\tpending\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) + require.Contains(t, last.query, "filters=") + require.Contains(t, last.query, "cluster") + require.Contains(t, last.query, "c-1") +} + +func TestKubernetesKubeconfigOperations(t *testing.T) { + tests := []struct { + name string + responses []string + input 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, + }, + { + name: "delete rule", + responses: []string{`{}`}, + input: "y\n", + args: []string{"kubernetes", "cluster", "kubeconfig", "delete", "c-1"}, + wantPath: "/api/kubernetes/v1/cluster.json/c-1/rule/eec87131729e44fa91a4b7ee8c365a26", + 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) + var stdout, stderr string + var err error + if tt.input != "" { + stdout, stderr, err = runWithInput(t, tt.input, args...) + } else { + stdout, stderr, 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) + } + _ = stderr + }) + } +} + +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 TestKubernetesEngineErrorPrefixes(t *testing.T) { + tests := []struct { + name string + args []string + prefix string + }{ + {name: "cluster list", args: []string{"kubernetes", "cluster", "list"}, prefix: "listing clusters"}, + {name: "cluster get", args: []string{"kubernetes", "cluster", "get", "placeholder"}, prefix: `reading cluster "placeholder"`}, + {name: "cluster create", args: []string{"kubernetes", "cluster", "create", "--name", "placeholder", "--location", "placeholder"}, prefix: `creating cluster "placeholder"`}, + {name: "cluster delete", args: []string{"kubernetes", "cluster", "delete", "placeholder", "--yes"}, prefix: `deleting cluster "placeholder"`}, + {name: "kubeconfig get", args: []string{"kubernetes", "cluster", "kubeconfig", "get", "placeholder"}, prefix: `reading kubeconfig of cluster "placeholder"`}, + {name: "kubeconfig delete", args: []string{"kubernetes", "cluster", "kubeconfig", "delete", "placeholder", "--yes"}, prefix: `deleting kubeconfig of cluster "placeholder"`}, + {name: "node pool list", args: []string{"kubernetes", "node-pool", "list"}, prefix: "listing node-pools"}, + {name: "node pool get", args: []string{"kubernetes", "node-pool", "get", "placeholder"}, prefix: `reading node-pool "placeholder"`}, + {name: "node pool create", args: []string{"kubernetes", "node-pool", "create", "--name", "placeholder", "--cluster", "placeholder", "--cpus", "1", "--memory", "2", "--disk", "20"}, prefix: `creating node-pool "placeholder"`}, + {name: "node pool delete", args: []string{"kubernetes", "node-pool", "delete", "placeholder", "--yes"}, prefix: `deleting node-pool "placeholder"`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isolate(t) + srv, _ := server(t, http.StatusInternalServerError, `{"error":{"code":500,"message":"boom"}}`) + args := append(append([]string{}, tt.args...), "--token", "tok", "--api-base-url", srv.URL) + _, _, err := run(t, args...) + require.Error(t, err) + require.Contains(t, errmap.Message(err), tt.prefix, "got %q, want prefix %q", errmap.Message(err), tt.prefix) + }) + } +} + +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"), From 7fea0639840638229e3b905da64015e1048c9462 Mon Sep 17 00:00:00 2001 From: ProbstenHias Date: Fri, 11 Sep 2026 19:23:11 +0200 Subject: [PATCH 2/2] fix(kubernetes): harden kubeconfig ids and trim list columns Reject cluster identifiers that would not survive URL path escaping before go-anxcloud interpolates them into the kubeconfig rule URL. List responses for clusters and node pools only carry identifier and name, so drop the columns that always rendered empty. Validate node-pool CPU, memory and disk against the Engine ranges, remove the --operating-system flag since only Flatcar exists, and wrap output write errors. Update README and design doc wording that still described kubernetes as future work. --- README.md | 4 +- docs/cli-design.md | 3 +- internal/cli/kubernetes.go | 52 ++++++------ internal/cli/kubernetes_node_pool.go | 56 ++++--------- internal/cli/kubernetes_test.go | 119 ++++++++++++++------------- 5 files changed, 105 insertions(+), 129 deletions(-) diff --git a/README.md b/README.md index b29a5b1..795b91a 100644 --- a/README.md +++ b/README.md @@ -211,8 +211,8 @@ when the library says which, but a `-` is never evidence about the Engine on its 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 four 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 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/kubernetes.go b/internal/cli/kubernetes.go index f24a217..40e27eb 100644 --- a/internal/cli/kubernetes.go +++ b/internal/cli/kubernetes.go @@ -3,6 +3,7 @@ package cli import ( "fmt" "io" + "net/url" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -42,14 +43,6 @@ func newKubernetesClusterCommand(opts *globalOptions) *cobra.Command { 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 }}, - {Name: "version", Value: func(cluster *kubernetesv1.Cluster) string { return cluster.Version }}, - {Name: "location", Value: clusterLocation}, - {Name: "state", Value: func(cluster *kubernetesv1.Cluster) string { - if cluster.State.Text != "" { - return cluster.State.Text - } - return cluster.State.ID - }}, }, }) cmd.AddCommand(newKubernetesKubeconfigCommand(opts)) @@ -111,18 +104,6 @@ func clusterCreateFlags(flags *pflag.FlagSet) func(*kubernetesv1.Cluster) error } } -// clusterLocation chooses the most useful location value returned by the -// Engine, falling back to the identifier for sparse responses. -func clusterLocation(cluster *kubernetesv1.Cluster) string { - if cluster.Location.Code != "" { - return cluster.Location.Code - } - if cluster.Location.Name != "" { - return cluster.Location.Name - } - return cluster.Location.Identifier -} - // 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", @@ -141,10 +122,7 @@ func newKubernetesKubeconfigGetCommand(opts *globalOptions) *cobra.Command { Args: cobra.ExactArgs(1), } cmd.RunE = func(cmd *cobra.Command, args []string) error { - if err := resource.ValidateIdentifier("cluster", args[0]); err != nil { - return err - } - if _, err := opts.Writer(cmd.OutOrStdout()); err != nil { + if err := validateKubeconfigCluster(args[0]); err != nil { return err } apiClient, err := opts.API(cmd.Flags()) @@ -157,12 +135,26 @@ func newKubernetesKubeconfigGetCommand(opts *globalOptions) *cobra.Command { if err != nil { return opts.Fail(fmt.Errorf("reading kubeconfig of cluster %q: %w", args[0], err)) } - _, err = io.WriteString(cmd.OutOrStdout(), config) - return 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{ @@ -172,7 +164,7 @@ func newKubernetesKubeconfigDeleteCommand(opts *globalOptions) *cobra.Command { Args: cobra.ExactArgs(1), } cmd.RunE = func(cmd *cobra.Command, args []string) error { - if err := resource.ValidateIdentifier("cluster", args[0]); err != nil { + if err := validateKubeconfigCluster(args[0]); err != nil { return err } question := fmt.Sprintf("delete kubeconfig of cluster %q", args[0]) @@ -188,8 +180,10 @@ func newKubernetesKubeconfigDeleteCommand(opts *globalOptions) *cobra.Command { if err := kubernetesv1.RemoveKubeConfig(ctx, apiClient, args[0]); err != nil { return opts.Fail(fmt.Errorf("deleting kubeconfig of cluster %q: %w", args[0], err)) } - _, err = fmt.Fprintf(cmd.ErrOrStderr(), "deleted kubeconfig of cluster %s\n", args[0]) - return 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 index f72d4d8..2759f5f 100644 --- a/internal/cli/kubernetes_node_pool.go +++ b/internal/cli/kubernetes_node_pool.go @@ -1,8 +1,6 @@ package cli import ( - "strconv" - "github.com/spf13/cobra" "github.com/spf13/pflag" kubernetesv1 "go.anx.io/go-anxcloud/pkg/apis/kubernetes/v1" @@ -12,6 +10,16 @@ import ( "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 { @@ -36,27 +44,6 @@ func newKubernetesNodePoolCommand(opts *globalOptions) *cobra.Command { 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 }}, - {Name: "cluster", Value: func(nodePool *kubernetesv1.NodePool) string { - if nodePool.Cluster.Name != "" { - return nodePool.Cluster.Name - } - return nodePool.Cluster.Identifier - }}, - {Name: "replicas", Value: func(nodePool *kubernetesv1.NodePool) string { - if nodePool.Replicas == nil { - return "" - } - return strconv.Itoa(*nodePool.Replicas) - }}, - {Name: "cpus", Value: func(nodePool *kubernetesv1.NodePool) string { return strconv.Itoa(nodePool.CPUs) }}, - {Name: "memory", Value: func(nodePool *kubernetesv1.NodePool) string { return gibibytes(nodePool.Memory) }}, - {Name: "disk", Value: func(nodePool *kubernetesv1.NodePool) string { return gibibytes(nodePool.DiskSize) }}, - {Name: "state", Value: func(nodePool *kubernetesv1.NodePool) string { - if nodePool.State.Text != "" { - return nodePool.State.Text - } - return nodePool.State.ID - }}, }, }) } @@ -70,7 +57,6 @@ func nodePoolCreateFlags(flags *pflag.FlagSet) func(*kubernetesv1.NodePool) erro 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") - operatingSystem := flags.String("operating-system", string(kubernetesv1.FlatcarLinux), "operating system for nodes") return func(nodePool *kubernetesv1.NodePool) error { if *name == "" { @@ -79,30 +65,24 @@ func nodePoolCreateFlags(flags *pflag.FlagSet) func(*kubernetesv1.NodePool) erro if *cluster == "" { return errmap.Usagef("--cluster is required") } - if *cpus <= 0 { - return errmap.Usagef("--cpus must be greater than zero") + if *cpus < minCPUs || *cpus > maxCPUs { + return errmap.Usagef("--cpus must be between %d and %d", minCPUs, maxCPUs) } - if *memory <= 0 { - return errmap.Usagef("--memory must be greater than zero") + if *memory < minMemoryGiB || *memory > maxMemoryGiB { + return errmap.Usagef("--memory must be between %d and %d", minMemoryGiB, maxMemoryGiB) } - if *disk <= 0 { - return errmap.Usagef("--disk must be greater than zero") + 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 * (1 << 30) - nodePool.DiskSize = *disk * (1 << 30) - nodePool.OperatingSystem = kubernetesv1.OperatingSystem(*operatingSystem) + nodePool.Memory = *memory * gibibyte + nodePool.DiskSize = *disk * gibibyte if flags.Changed("replicas") { nodePool.Replicas = pointer.Int(*replicas) } return nil } } - -// gibibytes renders a byte value using the CLI's GiB display unit. -func gibibytes(bytes int) string { - return strconv.Itoa(bytes/(1<<30)) + "Gi" -} diff --git a/internal/cli/kubernetes_test.go b/internal/cli/kubernetes_test.go index e61601b..c2c9935 100644 --- a/internal/cli/kubernetes_test.go +++ b/internal/cli/kubernetes_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "sync" "testing" @@ -120,26 +121,33 @@ func TestKubernetesNodePoolCreateFlags(t *testing.T) { }{ {name: "missing name", flags: withoutFlag(base, "--name"), wantError: "--name is required"}, {name: "missing cluster", flags: withoutFlag(base, "--cluster"), wantError: "--cluster is required"}, - {name: "zero cpus", flags: replaceFlag(base, "--cpus", "0"), wantError: "--cpus must be greater than zero"}, - {name: "zero memory", flags: replaceFlag(base, "--memory", "0"), wantError: "--memory must be greater than zero"}, - {name: "zero disk", flags: replaceFlag(base, "--disk", "0"), wantError: "--disk must be greater than zero"}, + {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 and default operating system", + 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), - "operating_system": "Flatcar Linux", + "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{"operating_system": "Flatcar Linux"}, + want: map[string]any{}, + }, + { + name: "zero replicas is sent", + flags: append(append([]string{}, base...), "--replicas", "0"), + want: map[string]any{"replicas": float64(0)}, }, } @@ -179,16 +187,16 @@ func TestKubernetesListColumns(t *testing.T) { want string }{ { - name: "cluster location and state fallbacks", + name: "cluster partial objects", args: []string{"kubernetes", "cluster", "list"}, - body: `{"data":{"data":[{"identifier":"c-1","name":"demo","version":"1.29","location":{"code":"ANX04"},"state":{"title":"Running"}},{"identifier":"c-2","name":"other","location":{"name":"Vienna"},"state":{"id":"pending"}},{"identifier":"c-3","location":{"identifier":"l-3"}}]}}`, - want: "c-1\tdemo\t1.29\tANX04\tRunning\nc-2\tother\t\tVienna\tpending\nc-3\t\t\tl-3\t\n", + body: `{"data":{"data":[{"identifier":"c-1","name":"demo"},{"identifier":"c-2","name":"other"}]}}`, + want: "c-1\tdemo\nc-2\tother\n", }, { - name: "node pool values", + name: "node pool partial objects", args: []string{"kubernetes", "node-pool", "list"}, - body: `{"data":{"data":[{"identifier":"np-1","name":"workers","cluster":{"name":"demo"},"replicas":3,"cpus":4,"memory":4294967296,"disk_size":21474836480,"state":{"title":"Running"}},{"identifier":"np-2","cluster":{"identifier":"c-2"},"memory":2147483648,"disk_size":21474836480,"state":{"id":"pending"}}]}}`, - want: "np-1\tworkers\tdemo\t3\t4\t4Gi\t20Gi\tRunning\nnp-2\t\tc-2\t\t0\t2Gi\t20Gi\tpending\n", + body: `{"data":{"data":[{"identifier":"np-1","name":"workers"},{"identifier":"np-2","name":"other"}]}}`, + want: "np-1\tworkers\nnp-2\tother\n", }, } @@ -210,16 +218,15 @@ func TestKubernetesNodePoolClusterFilter(t *testing.T) { _, _, err := run(t, "kubernetes", "node-pools", "list", "--cluster", "c-1", "--token", "tok", "--api-base-url", srv.URL) require.NoError(t, err) - require.Contains(t, last.query, "filters=") - require.Contains(t, last.query, "cluster") - require.Contains(t, last.query, "c-1") + 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 - input string args []string wantOutput string wantPath string @@ -239,14 +246,6 @@ func TestKubernetesKubeconfigOperations(t *testing.T) { wantPath: "/api/kubernetes/v1/cluster.json/c-1/rule/12277a581e1c47cba72338425a008aa3", wantMethod: http.MethodPost, }, - { - name: "delete rule", - responses: []string{`{}`}, - input: "y\n", - args: []string{"kubernetes", "cluster", "kubeconfig", "delete", "c-1"}, - wantPath: "/api/kubernetes/v1/cluster.json/c-1/rule/eec87131729e44fa91a4b7ee8c365a26", - wantMethod: http.MethodPost, - }, } for _, tt := range tests { @@ -271,13 +270,7 @@ func TestKubernetesKubeconfigOperations(t *testing.T) { t.Cleanup(srv.Close) args := append(append([]string{}, tt.args...), "--token", "tok", "--api-base-url", srv.URL) - var stdout, stderr string - var err error - if tt.input != "" { - stdout, stderr, err = runWithInput(t, tt.input, args...) - } else { - stdout, stderr, err = run(t, args...) - } + 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 != "" { @@ -291,50 +284,58 @@ func TestKubernetesKubeconfigOperations(t *testing.T) { } require.True(t, found, "got requests %v, want %s %s", seen, tt.wantMethod, tt.wantPath) } - _ = stderr }) } } -func TestKubernetesKubeconfigDeleteRespectsConfirmation(t *testing.T) { +func TestKubernetesKubeconfigDeleteCallsRule(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) + _, 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 TestKubernetesEngineErrorPrefixes(t *testing.T) { +func TestKubernetesKubeconfigRejectsEscapedIdentifiers(t *testing.T) { tests := []struct { - name string - args []string - prefix string + name string + id string + verb string }{ - {name: "cluster list", args: []string{"kubernetes", "cluster", "list"}, prefix: "listing clusters"}, - {name: "cluster get", args: []string{"kubernetes", "cluster", "get", "placeholder"}, prefix: `reading cluster "placeholder"`}, - {name: "cluster create", args: []string{"kubernetes", "cluster", "create", "--name", "placeholder", "--location", "placeholder"}, prefix: `creating cluster "placeholder"`}, - {name: "cluster delete", args: []string{"kubernetes", "cluster", "delete", "placeholder", "--yes"}, prefix: `deleting cluster "placeholder"`}, - {name: "kubeconfig get", args: []string{"kubernetes", "cluster", "kubeconfig", "get", "placeholder"}, prefix: `reading kubeconfig of cluster "placeholder"`}, - {name: "kubeconfig delete", args: []string{"kubernetes", "cluster", "kubeconfig", "delete", "placeholder", "--yes"}, prefix: `deleting kubeconfig of cluster "placeholder"`}, - {name: "node pool list", args: []string{"kubernetes", "node-pool", "list"}, prefix: "listing node-pools"}, - {name: "node pool get", args: []string{"kubernetes", "node-pool", "get", "placeholder"}, prefix: `reading node-pool "placeholder"`}, - {name: "node pool create", args: []string{"kubernetes", "node-pool", "create", "--name", "placeholder", "--cluster", "placeholder", "--cpus", "1", "--memory", "2", "--disk", "20"}, prefix: `creating node-pool "placeholder"`}, - {name: "node pool delete", args: []string{"kubernetes", "node-pool", "delete", "placeholder", "--yes"}, prefix: `deleting node-pool "placeholder"`}, + {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, _ := server(t, http.StatusInternalServerError, `{"error":{"code":500,"message":"boom"}}`) - args := append(append([]string{}, tt.args...), "--token", "tok", "--api-base-url", srv.URL) + 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), tt.prefix, "got %q, want prefix %q", errmap.Message(err), tt.prefix) + 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++ {