diff --git a/.changes/next-release/enhancement-core-9wovvlf2.json b/.changes/next-release/enhancement-core-9wovvlf2.json new file mode 100644 index 0000000..4887f13 --- /dev/null +++ b/.changes/next-release/enhancement-core-9wovvlf2.json @@ -0,0 +1,5 @@ +{ + "type": "enhancement", + "category": "core", + "description": "cli.ParseStructFlag gains bool-field coercion (ParseStructFlagTyped) so struct flags can carry boolean values in shorthand" +} diff --git a/.changes/next-release/feature-vks-twemaoo1.json b/.changes/next-release/feature-vks-twemaoo1.json new file mode 100644 index 0000000..cc79bb7 --- /dev/null +++ b/.changes/next-release/feature-vks-twemaoo1.json @@ -0,0 +1,5 @@ +{ + "type": "feature", + "category": "vks", + "description": "create-cluster gains the remaining optional CreateClusterDto fields: --secondary-subnets, --list-subnet-ids, --node-netmask-size, --service-endpoint, --az-strategy, and struct-valued --auto-upgrade-config/--auto-healing-config (shorthand or JSON, matching create-nodegroup). --subnet-id is now optional (per spec; pass --subnet-id or --list-subnet-ids or neither, the server validates)" +} diff --git a/docs/commands/vks/create-cluster.md b/docs/commands/vks/create-cluster.md index c2d0828..78c370d 100644 --- a/docs/commands/vks/create-cluster.md +++ b/docs/commands/vks/create-cluster.md @@ -18,13 +18,20 @@ grn vks create-cluster --k8s-version --network-type --vpc-id - --subnet-id + [--subnet-id ] [--cidr ] [--description ] [--private-cluster ] [--release-channel ] [--load-balancer-plugin ] [--block-store-csi-plugin ] + [--service-endpoint ] + [--az-strategy ] + [--secondary-subnets ] + [--list-subnet-ids ] + [--node-netmask-size ] + [--auto-upgrade-config ] + [--auto-healing-config ] [--dry-run] ``` @@ -42,8 +49,8 @@ grn vks create-cluster `--vpc-id` (required) : VPC ID where the cluster will be provisioned. -`--subnet-id` (required) -: Subnet ID for the cluster control plane. +`--subnet-id` (optional) +: Subnet ID for the cluster control plane. Optional per the API — provide either `--subnet-id` or `--list-subnet-ids` (or neither); the server validates. `--cidr` (optional) : Pod CIDR block. Required when `--network-type` is `TIGERA` or `CILIUM_OVERLAY` (e.g. `10.96.0.0/12`). @@ -63,6 +70,27 @@ grn vks create-cluster `--block-store-csi-plugin` (optional, default `enabled`) : Block store CSI plugin state. Accepted values: `enabled`, `disabled`. +`--service-endpoint` (optional, default `disabled`) +: Service endpoint state. Accepted values: `enabled`, `disabled`. + +`--az-strategy` (optional, default `SINGLE`) +: Availability-zone strategy for the cluster. + +`--secondary-subnets` (optional) +: Comma-separated list of secondary subnet IDs. + +`--list-subnet-ids` (optional) +: Comma-separated list of subnet IDs for the cluster. + +`--node-netmask-size` (optional) +: Node netmask size (integer). Only sent when explicitly provided. + +`--auto-upgrade-config` (optional) +: Auto-upgrade schedule. Shorthand `time=03:00,weekdays=Mon` or JSON `{"weekdays":"Mon,Wed,Fri","time":"03:00"}`. Use JSON when `weekdays` has multiple days (shorthand splits on commas). + +`--auto-healing-config` (optional) +: Auto-healing config. Shorthand `enableAutoHealing=true,maxUnhealthy=20%,unhealthyRange=[2-5],timeoutUnhealthy=10` or JSON. `enableAutoHealing` is a boolean, `timeoutUnhealthy` an integer. + `--dry-run` (optional) : Validate all parameters and print a report without sending the create request. diff --git a/go/cmd/vks/create_cluster.go b/go/cmd/vks/create_cluster.go index 29b8de5..3546c9f 100644 --- a/go/cmd/vks/create_cluster.go +++ b/go/cmd/vks/create_cluster.go @@ -6,6 +6,7 @@ import ( "regexp" "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/cli" ) var createClusterCmd = &cobra.Command{ @@ -23,9 +24,9 @@ func init() { f.String("k8s-version", "", "Kubernetes version (required)") f.String("network-type", "", "Network type: TIGERA, CILIUM_OVERLAY, CILIUM_NATIVE_ROUTING (required)") f.String("vpc-id", "", "VPC ID (required)") - f.String("subnet-id", "", "Subnet ID (required)") + f.String("subnet-id", "", "Subnet ID") - for _, name := range []string{"name", "k8s-version", "network-type", "vpc-id", "subnet-id"} { + for _, name := range []string{"name", "k8s-version", "network-type", "vpc-id"} { createClusterCmd.MarkFlagRequired(name) } @@ -36,6 +37,13 @@ func init() { f.String("release-channel", "STABLE", "Release channel (RAPID, STABLE)") f.String("load-balancer-plugin", "enabled", "Load balancer plugin (enabled, disabled)") f.String("block-store-csi-plugin", "enabled", "Block store CSI plugin (enabled, disabled)") + f.String("service-endpoint", "disabled", "Service endpoint (enabled, disabled)") + f.String("az-strategy", "SINGLE", "Availability zone strategy") + f.String("secondary-subnets", "", "Secondary subnet IDs (comma-separated)") + f.String("list-subnet-ids", "", "Subnet IDs for the cluster (comma-separated)") + f.Int("node-netmask-size", 0, "Node netmask size") + f.String("auto-upgrade-config", "", "Auto-upgrade config (shorthand time=03:00,weekdays=Mon or JSON; use JSON for multiple weekdays)") + f.String("auto-healing-config", "", "Auto-healing config (shorthand enableAutoHealing=true,maxUnhealthy=20%,unhealthyRange=[2-5],timeoutUnhealthy=10 or JSON)") f.Bool("dry-run", false, "Validate parameters without creating the cluster") } @@ -48,12 +56,18 @@ func runCreateCluster(cmd *cobra.Command, args []string) error { cidr, _ := cmd.Flags().GetString("cidr") description, _ := cmd.Flags().GetString("description") releaseChannel, _ := cmd.Flags().GetString("release-channel") + azStrategy, _ := cmd.Flags().GetString("az-strategy") + secondarySubnets, _ := cmd.Flags().GetString("secondary-subnets") + listSubnetIDs, _ := cmd.Flags().GetString("list-subnet-ids") + autoUpgradeStr, _ := cmd.Flags().GetString("auto-upgrade-config") + autoHealingStr, _ := cmd.Flags().GetString("auto-healing-config") dryRun, _ := cmd.Flags().GetBool("dry-run") // Parse enabled/disabled toggle flags. privateClusterVal, _ := cmd.Flags().GetString("private-cluster") lbPluginVal, _ := cmd.Flags().GetString("load-balancer-plugin") csiPluginVal, _ := cmd.Flags().GetString("block-store-csi-plugin") + serviceEndpointVal, _ := cmd.Flags().GetString("service-endpoint") enablePrivateCluster, err := parseToggle("private-cluster", privateClusterVal) if err != nil { return err @@ -66,6 +80,10 @@ func runCreateCluster(cmd *cobra.Command, args []string) error { if err != nil { return err } + enabledServiceEndpoint, err := parseToggle("service-endpoint", serviceEndpointVal) + if err != nil { + return err + } // Build cluster body. Node groups are created separately via // 'grn vks create-nodegroup'. @@ -74,21 +92,47 @@ func runCreateCluster(cmd *cobra.Command, args []string) error { "version": k8sVersion, "networkType": networkType, "vpcId": vpcID, - "subnetId": subnetID, "enablePrivateCluster": enablePrivateCluster, "releaseChannel": releaseChannel, "enabledBlockStoreCsiPlugin": enabledCSIPlugin, "enabledLoadBalancerPlugin": enabledLBPlugin, - "enabledServiceEndpoint": false, - "azStrategy": "SINGLE", + "enabledServiceEndpoint": enabledServiceEndpoint, + "azStrategy": azStrategy, } + if subnetID != "" { + body["subnetId"] = subnetID + } if cidr != "" { body["cidr"] = cidr } if description != "" { body["description"] = description } + if secondarySubnets != "" { + body["secondarySubnets"] = parseCommaSeparated(secondarySubnets) + } + if listSubnetIDs != "" { + body["listSubnetIds"] = parseCommaSeparated(listSubnetIDs) + } + if cmd.Flags().Changed("node-netmask-size") { + nodeNetmaskSize, _ := cmd.Flags().GetInt("node-netmask-size") + body["nodeNetmaskSize"] = nodeNetmaskSize + } + if autoUpgradeStr != "" { + uc, err := cli.ParseStructFlag(autoUpgradeStr) + if err != nil { + return fmt.Errorf("--auto-upgrade-config: %w", err) + } + body["autoUpgradeConfig"] = uc + } + if autoHealingStr != "" { + hc, err := cli.ParseStructFlagTyped(autoHealingStr, []string{"timeoutUnhealthy"}, []string{"enableAutoHealing"}) + if err != nil { + return fmt.Errorf("--auto-healing-config: %w", err) + } + body["autoHealingConfig"] = hc + } if dryRun { return validateCreateCluster(name, networkType, cidr) diff --git a/go/internal/cli/parse.go b/go/internal/cli/parse.go index 12b656e..20a190e 100644 --- a/go/internal/cli/parse.go +++ b/go/internal/cli/parse.go @@ -31,6 +31,14 @@ func ParseCommaSeparated(s string) []string { // returns (nil, nil). Returns an error on malformed JSON, a shorthand entry // without '=', or a non-integer value for an int field. func ParseStructFlag(value string, intFields ...string) (map[string]interface{}, error) { + return ParseStructFlagTyped(value, intFields, nil) +} + +// ParseStructFlagTyped is ParseStructFlag with explicit int and bool field +// lists. In shorthand form, keys in intFields are coerced to int and keys in +// boolFields to bool ("true"/"false"); all other values stay strings. JSON is +// passed through as decoded. +func ParseStructFlagTyped(value string, intFields, boolFields []string) (map[string]interface{}, error) { v := strings.TrimSpace(value) if v == "" { return nil, nil @@ -47,6 +55,10 @@ func ParseStructFlag(value string, intFields ...string) (map[string]interface{}, for _, f := range intFields { ints[f] = true } + bools := map[string]bool{} + for _, f := range boolFields { + bools[f] = true + } out := map[string]interface{}{} for _, pair := range strings.Split(v, ",") { pair = strings.TrimSpace(pair) @@ -59,13 +71,20 @@ func ParseStructFlag(value string, intFields ...string) (map[string]interface{}, } key := strings.TrimSpace(pair[:idx]) val := strings.TrimSpace(pair[idx+1:]) - if ints[key] { + switch { + case ints[key]: n, err := strconv.Atoi(val) if err != nil { return nil, fmt.Errorf("%s must be an integer, got %q", key, val) } out[key] = n - } else { + case bools[key]: + b, err := strconv.ParseBool(val) + if err != nil { + return nil, fmt.Errorf("%s must be a boolean (true/false), got %q", key, val) + } + out[key] = b + default: out[key] = val } } diff --git a/go/internal/cli/parse_test.go b/go/internal/cli/parse_test.go index d7004b9..5c393f3 100644 --- a/go/internal/cli/parse_test.go +++ b/go/internal/cli/parse_test.go @@ -85,3 +85,26 @@ func TestParseStructFlagErrors(t *testing.T) { t.Error("non-integer int field should error") } } + +func TestParseStructFlagTypedBool(t *testing.T) { + got, err := ParseStructFlagTyped("enableAutoHealing=true,timeoutUnhealthy=10,maxUnhealthy=20%", + []string{"timeoutUnhealthy"}, []string{"enableAutoHealing"}) + if err != nil { + t.Fatalf("err: %v", err) + } + if got["enableAutoHealing"] != true { + t.Errorf("enableAutoHealing = %#v, want bool true", got["enableAutoHealing"]) + } + if got["timeoutUnhealthy"] != 10 { + t.Errorf("timeoutUnhealthy = %#v, want int 10", got["timeoutUnhealthy"]) + } + if got["maxUnhealthy"] != "20%" { + t.Errorf("maxUnhealthy = %#v, want string 20%%", got["maxUnhealthy"]) + } +} + +func TestParseStructFlagTypedBadBool(t *testing.T) { + if _, err := ParseStructFlagTyped("enableAutoHealing=yes", nil, []string{"enableAutoHealing"}); err == nil { + t.Error("invalid bool should error") + } +}