From c09caccd3f85215e7b69bb649000bb5a9f1d7216 Mon Sep 17 00:00:00 2001 From: tytv2 Date: Fri, 3 Jul 2026 20:56:53 +0700 Subject: [PATCH 1/2] feat(vks): add remaining optional fields to create-cluster Mirror the create-nodegroup treatment: expose the optional CreateClusterDto fields create-cluster was missing. - simple flags: --secondary-subnets, --list-subnet-ids, --node-netmask-size, --service-endpoint (was hardcoded false), --az-strategy (was hardcoded SINGLE) - struct flags (shorthand or JSON): --auto-upgrade-config, --auto-healing-config Extend cli.ParseStructFlag with bool-field coercion (ParseStructFlagTyped) so --auto-healing-config's enableAutoHealing works in shorthand. --fleet-config was intentionally left out. Note: autoUpgradeConfig.weekdays with multiple days must use JSON (shorthand splits on commas). Co-Authored-By: Claude Opus 4.8 --- .../enhancement-core-9wovvlf2.json | 5 ++ .../next-release/feature-vks-twemaoo1.json | 5 ++ docs/commands/vks/create-cluster.md | 28 +++++++++++ go/cmd/vks/create_cluster.go | 46 ++++++++++++++++++- go/internal/cli/parse.go | 23 +++++++++- go/internal/cli/parse_test.go | 23 ++++++++++ 6 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 .changes/next-release/enhancement-core-9wovvlf2.json create mode 100644 .changes/next-release/feature-vks-twemaoo1.json 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..4f3e46e --- /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)" +} diff --git a/docs/commands/vks/create-cluster.md b/docs/commands/vks/create-cluster.md index c2d0828..f8a6b16 100644 --- a/docs/commands/vks/create-cluster.md +++ b/docs/commands/vks/create-cluster.md @@ -25,6 +25,13 @@ grn vks create-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] ``` @@ -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..c057b29 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{ @@ -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'. @@ -79,8 +97,8 @@ func runCreateCluster(cmd *cobra.Command, args []string) error { "releaseChannel": releaseChannel, "enabledBlockStoreCsiPlugin": enabledCSIPlugin, "enabledLoadBalancerPlugin": enabledLBPlugin, - "enabledServiceEndpoint": false, - "azStrategy": "SINGLE", + "enabledServiceEndpoint": enabledServiceEndpoint, + "azStrategy": azStrategy, } if cidr != "" { @@ -89,6 +107,30 @@ func runCreateCluster(cmd *cobra.Command, args []string) error { 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") + } +} From 350620c318da0ab45056aaf7cc0c76a91ce793bb Mon Sep 17 00:00:00 2001 From: tytv2 Date: Fri, 3 Jul 2026 21:58:57 +0700 Subject: [PATCH 2/2] feat(vks): make create-cluster --subnet-id optional Per the CreateClusterDto spec neither subnetId nor listSubnetIds is required, so drop --subnet-id from the required set and send subnetId only when provided. Users can pass --subnet-id, --list-subnet-ids, or neither; the server validates. Co-Authored-By: Claude Opus 4.8 --- .changes/next-release/feature-vks-twemaoo1.json | 2 +- docs/commands/vks/create-cluster.md | 6 +++--- go/cmd/vks/create_cluster.go | 8 +++++--- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.changes/next-release/feature-vks-twemaoo1.json b/.changes/next-release/feature-vks-twemaoo1.json index 4f3e46e..cc79bb7 100644 --- a/.changes/next-release/feature-vks-twemaoo1.json +++ b/.changes/next-release/feature-vks-twemaoo1.json @@ -1,5 +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)" + "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 f8a6b16..78c370d 100644 --- a/docs/commands/vks/create-cluster.md +++ b/docs/commands/vks/create-cluster.md @@ -18,7 +18,7 @@ grn vks create-cluster --k8s-version --network-type --vpc-id - --subnet-id + [--subnet-id ] [--cidr ] [--description ] [--private-cluster ] @@ -49,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`). diff --git a/go/cmd/vks/create_cluster.go b/go/cmd/vks/create_cluster.go index c057b29..3546c9f 100644 --- a/go/cmd/vks/create_cluster.go +++ b/go/cmd/vks/create_cluster.go @@ -24,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) } @@ -92,7 +92,6 @@ func runCreateCluster(cmd *cobra.Command, args []string) error { "version": k8sVersion, "networkType": networkType, "vpcId": vpcID, - "subnetId": subnetID, "enablePrivateCluster": enablePrivateCluster, "releaseChannel": releaseChannel, "enabledBlockStoreCsiPlugin": enabledCSIPlugin, @@ -101,6 +100,9 @@ func runCreateCluster(cmd *cobra.Command, args []string) error { "azStrategy": azStrategy, } + if subnetID != "" { + body["subnetId"] = subnetID + } if cidr != "" { body["cidr"] = cidr }