From 3f02de4b0377429bb74cdda11fcf8a028f653563 Mon Sep 17 00:00:00 2001 From: tytv2 Date: Tue, 30 Jun 2026 13:06:39 +0700 Subject: [PATCH] feat(vks): struct-valued flags for create/update node group Add AWS-style struct flags (shorthand key=val,key2=val2 or JSON) backed by a shared cli.ParseStructFlag helper. create-nodegroup: add --tags, --secondary-subnets, --auto-scale, --placement-group, and --upgrade-config (replaces the hardcoded SURGE 1/0 default, still applied when the flag is omitted). update-nodegroup: replace --auto-scale-min/max and --upgrade-strategy/max-surge/max-unavailable with struct --auto-scale and --upgrade-config. Drop the API-deprecated --labels/--taints (use update-nodegroup-metadata instead). BREAKING CHANGE: update-nodegroup removes --labels, --taints, --auto-scale-min, --auto-scale-max, --upgrade-strategy, --upgrade-max-surge, and --upgrade-max-unavailable. Co-Authored-By: Claude Opus 4.8 --- .../next-release/api-change-vks-ckjydblt.json | 5 ++ .../next-release/feature-vks-bgen7jri.json | 5 ++ docs/commands/vks/create-nodegroup.md | 35 +++++++++++ docs/commands/vks/update-nodegroup.md | 52 +++++++---------- go/cmd/vks/create_nodegroup.go | 38 ++++++++++++ go/cmd/vks/update_nodegroup.go | 58 +++++-------------- go/internal/cli/parse.go | 50 ++++++++++++++++ go/internal/cli/parse_test.go | 49 ++++++++++++++++ 8 files changed, 219 insertions(+), 73 deletions(-) create mode 100644 .changes/next-release/api-change-vks-ckjydblt.json create mode 100644 .changes/next-release/feature-vks-bgen7jri.json diff --git a/.changes/next-release/api-change-vks-ckjydblt.json b/.changes/next-release/api-change-vks-ckjydblt.json new file mode 100644 index 0000000..548f98a --- /dev/null +++ b/.changes/next-release/api-change-vks-ckjydblt.json @@ -0,0 +1,5 @@ +{ + "type": "api-change", + "category": "vks", + "description": "update-nodegroup: drop deprecated --labels/--taints (use update-nodegroup-metadata); replace --auto-scale-min/max and --upgrade-strategy/max-surge/max-unavailable with struct flags --auto-scale and --upgrade-config" +} diff --git a/.changes/next-release/feature-vks-bgen7jri.json b/.changes/next-release/feature-vks-bgen7jri.json new file mode 100644 index 0000000..e183961 --- /dev/null +++ b/.changes/next-release/feature-vks-bgen7jri.json @@ -0,0 +1,5 @@ +{ + "type": "feature", + "category": "vks", + "description": "Add struct-valued flags (shorthand or JSON) to create-nodegroup: --tags, --secondary-subnets, --auto-scale, --placement-group, --upgrade-config" +} diff --git a/docs/commands/vks/create-nodegroup.md b/docs/commands/vks/create-nodegroup.md index ae6d4e1..fb45f4d 100644 --- a/docs/commands/vks/create-nodegroup.md +++ b/docs/commands/vks/create-nodegroup.md @@ -23,6 +23,11 @@ grn vks create-nodegroup [--subnet-id ] [--labels ] [--taints ] + [--tags ] + [--secondary-subnets ] + [--auto-scale ] + [--placement-group ] + [--upgrade-config ] [--enable-encryption-volume] [--dry-run] ``` @@ -68,6 +73,21 @@ grn vks create-nodegroup `--taints` (optional) : Comma-separated node taints in `key=value:effect` format (e.g. `dedicated=gpu:NoSchedule`). +`--tags` (optional) +: Comma-separated `key=value` pairs to attach as node group tags (e.g. `team=platform,cost-center=42`). + +`--secondary-subnets` (optional) +: Comma-separated list of secondary subnet IDs for the node group. + +`--auto-scale` (optional) +: Auto-scale configuration. Shorthand `minSize=2,maxSize=10` or JSON `{"minSize":2,"maxSize":10}`. + +`--placement-group` (optional) +: Placement group configuration. Shorthand `type=NEW,placementGroupName=pg-1` or JSON. `type` is `NEW` or `EXISTING`; use `placementGroupName` (with `NEW`) or `placementGroupId` (with `EXISTING`). + +`--upgrade-config` (optional, default `maxSurge=1,maxUnavailable=0,strategy=SURGE`) +: Upgrade configuration. Shorthand `maxSurge=1,maxUnavailable=0,strategy=SURGE` or JSON `{"maxSurge":1,"maxUnavailable":0,"strategy":"SURGE"}`. + `--enable-encryption-volume` (optional) : Enable encryption for the node boot volumes. @@ -105,6 +125,21 @@ grn vks create-nodegroup \ --enable-encryption-volume ``` +Create an auto-scaling node group with a custom upgrade config (shorthand and JSON are both accepted): + +```bash +grn vks create-nodegroup \ + --cluster-id cls-abc12345-6789-def0-1234-abcdef012345 \ + --name auto-ng \ + --os ubuntu \ + --flavor-id flv-4c8g \ + --disk-type SSD \ + --ssh-key-id key-abc12345-0000-0000-0000-000000000001 \ + --auto-scale minSize=2,maxSize=10 \ + --upgrade-config '{"maxSurge":2,"maxUnavailable":1,"strategy":"SURGE"}' \ + --tags team=platform,env=prod +``` + Validate parameters without creating: ```bash diff --git a/docs/commands/vks/update-nodegroup.md b/docs/commands/vks/update-nodegroup.md index d98a0ec..b9db248 100644 --- a/docs/commands/vks/update-nodegroup.md +++ b/docs/commands/vks/update-nodegroup.md @@ -2,7 +2,9 @@ ## Description -Update a node group's node count, security groups, labels, taints, auto-scaling configuration, and upgrade strategy. +Update a node group's node count, security groups, auto-scaling configuration, and upgrade configuration. + +To update labels, tags, or taints, use `grn vks update-nodegroup-metadata` — those fields are deprecated on `update-nodegroup`. Use `--dry-run` to preview the update payload without executing it. @@ -14,13 +16,8 @@ grn vks update-nodegroup --nodegroup-id [--num-nodes ] [--security-groups ] - [--labels ] - [--taints ] - [--auto-scale-min ] - [--auto-scale-max ] - [--upgrade-strategy ] - [--upgrade-max-surge ] - [--upgrade-max-unavailable ] + [--auto-scale ] + [--upgrade-config ] [--dry-run] ``` @@ -38,26 +35,11 @@ grn vks update-nodegroup `--security-groups` (optional) : Comma-separated list of security group IDs to replace the current set. -`--labels` (optional) -: Comma-separated `key=value` pairs to set as Kubernetes node labels (replaces existing labels). - -`--taints` (optional) -: Comma-separated node taints in `key=value:effect` format (replaces existing taints). - -`--auto-scale-min` (optional) -: Minimum number of nodes for the auto-scaler. - -`--auto-scale-max` (optional) -: Maximum number of nodes for the auto-scaler. - -`--upgrade-strategy` (optional) -: Node upgrade strategy. Accepted value: `SURGE`. - -`--upgrade-max-surge` (optional) -: Maximum number of extra nodes to create during a surge upgrade. +`--auto-scale` (optional) +: Auto-scale configuration. Shorthand `minSize=2,maxSize=10` or JSON `{"minSize":2,"maxSize":10}`. -`--upgrade-max-unavailable` (optional) -: Maximum number of nodes that may be unavailable during an upgrade. +`--upgrade-config` (optional) +: Upgrade configuration. Shorthand `maxSurge=1,maxUnavailable=0,strategy=SURGE` or JSON `{"maxSurge":1,"maxUnavailable":0,"strategy":"SURGE"}`. `--dry-run` (optional) : Print the update payload without sending the request. @@ -73,20 +55,28 @@ grn vks update-nodegroup \ --num-nodes 5 ``` -Set auto-scaling limits: +Set auto-scaling limits (shorthand or JSON): ```bash grn vks update-nodegroup \ --cluster-id cls-abc12345-6789-def0-1234-abcdef012345 \ --nodegroup-id ng-abc12345-6789-def0-1234-abcdef012345 \ - --auto-scale-min 2 \ - --auto-scale-max 10 + --auto-scale minSize=2,maxSize=10 ``` -Update labels and taints: +Set the upgrade configuration: ```bash grn vks update-nodegroup \ + --cluster-id cls-abc12345-6789-def0-1234-abcdef012345 \ + --nodegroup-id ng-abc12345-6789-def0-1234-abcdef012345 \ + --upgrade-config '{"maxSurge":2,"maxUnavailable":1,"strategy":"SURGE"}' +``` + +To update labels, tags, or taints, use `update-nodegroup-metadata` (those fields are deprecated on `update-nodegroup`): + +```bash +grn vks update-nodegroup-metadata \ --cluster-id cls-abc12345-6789-def0-1234-abcdef012345 \ --nodegroup-id ng-abc12345-6789-def0-1234-abcdef012345 \ --labels env=prod,tier=app \ diff --git a/go/cmd/vks/create_nodegroup.go b/go/cmd/vks/create_nodegroup.go index 8e74efe..93ea813 100644 --- a/go/cmd/vks/create_nodegroup.go +++ b/go/cmd/vks/create_nodegroup.go @@ -6,6 +6,7 @@ import ( "regexp" "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/cli" "github.com/vngcloud/greennode-cli/internal/validator" ) @@ -31,6 +32,11 @@ func init() { f.String("labels", "", "Node labels as key=value pairs (comma-separated)") f.String("taints", "", "Node taints as key=value:effect (comma-separated)") f.Bool("enable-encryption-volume", false, "Enable volume encryption") + f.String("tags", "", "Node group tags as key=value pairs (comma-separated)") + f.String("secondary-subnets", "", "Secondary subnet IDs (comma-separated)") + f.String("auto-scale", "", "Auto-scale config (shorthand minSize=2,maxSize=10 or JSON)") + f.String("placement-group", "", "Placement group config (shorthand type=NEW,placementGroupName=pg or JSON)") + f.String("upgrade-config", "", "Upgrade config (shorthand maxSurge=1,maxUnavailable=0,strategy=SURGE or JSON); default SURGE 1/0") f.Bool("dry-run", false, "Validate parameters without creating") for _, name := range []string{"cluster-id", "name", "flavor-id", "disk-type", "ssh-key-id"} { @@ -54,6 +60,11 @@ func runCreateNodegroup(cmd *cobra.Command, args []string) error { taintsStr, _ := cmd.Flags().GetString("taints") enableEncryption, _ := cmd.Flags().GetBool("enable-encryption-volume") dryRun, _ := cmd.Flags().GetBool("dry-run") + tagsStr, _ := cmd.Flags().GetString("tags") + secondarySubnets, _ := cmd.Flags().GetString("secondary-subnets") + autoScaleStr, _ := cmd.Flags().GetString("auto-scale") + placementGroupStr, _ := cmd.Flags().GetString("placement-group") + upgradeConfigStr, _ := cmd.Flags().GetString("upgrade-config") if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { return err @@ -94,6 +105,33 @@ func runCreateNodegroup(cmd *cobra.Command, args []string) error { if taintsStr != "" { body["taints"] = parseTaints(taintsStr) } + if tagsStr != "" { + body["tags"] = parseLabels(tagsStr) + } + if secondarySubnets != "" { + body["secondarySubnets"] = parseCommaSeparated(secondarySubnets) + } + if autoScaleStr != "" { + asc, err := cli.ParseStructFlag(autoScaleStr, "minSize", "maxSize") + if err != nil { + return fmt.Errorf("--auto-scale: %w", err) + } + body["autoScaleConfig"] = asc + } + if placementGroupStr != "" { + pg, err := cli.ParseStructFlag(placementGroupStr) + if err != nil { + return fmt.Errorf("--placement-group: %w", err) + } + body["placementGroupConfigDto"] = pg + } + if upgradeConfigStr != "" { + uc, err := cli.ParseStructFlag(upgradeConfigStr, "maxSurge", "maxUnavailable") + if err != nil { + return fmt.Errorf("--upgrade-config: %w", err) + } + body["upgradeConfig"] = uc + } if dryRun { return validateCreateNodegroup(name, diskSize, numNodes) diff --git a/go/cmd/vks/update_nodegroup.go b/go/cmd/vks/update_nodegroup.go index 427d947..e0cd3c4 100644 --- a/go/cmd/vks/update_nodegroup.go +++ b/go/cmd/vks/update_nodegroup.go @@ -5,6 +5,7 @@ import ( "os" "github.com/spf13/cobra" + "github.com/vngcloud/greennode-cli/internal/cli" "github.com/vngcloud/greennode-cli/internal/validator" ) @@ -20,13 +21,8 @@ func init() { f.String("nodegroup-id", "", "Node group ID (required)") f.String("num-nodes", "", "New number of nodes") f.String("security-groups", "", "Security group IDs (comma-separated)") - f.String("labels", "", "Node labels as key=value pairs (comma-separated)") - f.String("taints", "", "Node taints as key=value:effect (comma-separated)") - f.String("auto-scale-min", "", "Auto-scale minimum nodes") - f.String("auto-scale-max", "", "Auto-scale maximum nodes") - f.String("upgrade-strategy", "", "Upgrade strategy (SURGE)") - f.String("upgrade-max-surge", "", "Max surge during upgrade") - f.String("upgrade-max-unavailable", "", "Max unavailable during upgrade") + f.String("auto-scale", "", "Auto-scale config (shorthand minSize=2,maxSize=10 or JSON)") + f.String("upgrade-config", "", "Upgrade config (shorthand maxSurge=1,maxUnavailable=0,strategy=SURGE or JSON)") f.Bool("dry-run", false, "Preview update without executing") updateNodegroupCmd.MarkFlagRequired("cluster-id") @@ -38,13 +34,8 @@ func runUpdateNodegroup(cmd *cobra.Command, args []string) error { nodegroupID, _ := cmd.Flags().GetString("nodegroup-id") numNodes, _ := cmd.Flags().GetString("num-nodes") securityGroups, _ := cmd.Flags().GetString("security-groups") - labelsStr, _ := cmd.Flags().GetString("labels") - taintsStr, _ := cmd.Flags().GetString("taints") - autoScaleMin, _ := cmd.Flags().GetString("auto-scale-min") - autoScaleMax, _ := cmd.Flags().GetString("auto-scale-max") - upgradeStrategy, _ := cmd.Flags().GetString("upgrade-strategy") - upgradeMaxSurge, _ := cmd.Flags().GetString("upgrade-max-surge") - upgradeMaxUnavail, _ := cmd.Flags().GetString("upgrade-max-unavailable") + autoScaleStr, _ := cmd.Flags().GetString("auto-scale") + upgradeConfigStr, _ := cmd.Flags().GetString("upgrade-config") dryRun, _ := cmd.Flags().GetBool("dry-run") if err := validator.ValidateID(clusterID, "cluster-id"); err != nil { @@ -62,40 +53,23 @@ func runUpdateNodegroup(cmd *cobra.Command, args []string) error { if securityGroups != "" { body["securityGroups"] = parseCommaSeparated(securityGroups) } - if labelsStr != "" { - body["labels"] = parseLabels(labelsStr) - } - if taintsStr != "" { - body["taints"] = parseTaints(taintsStr) - } - - if autoScaleMin != "" || autoScaleMax != "" { - autoScaleConfig := map[string]interface{}{} - if autoScaleMin != "" { - autoScaleConfig["minSize"] = toInt(autoScaleMin) - } - if autoScaleMax != "" { - autoScaleConfig["maxSize"] = toInt(autoScaleMax) + if autoScaleStr != "" { + asc, err := cli.ParseStructFlag(autoScaleStr, "minSize", "maxSize") + if err != nil { + return fmt.Errorf("--auto-scale: %w", err) } - body["autoScaleConfig"] = autoScaleConfig + body["autoScaleConfig"] = asc } - - if upgradeStrategy != "" || upgradeMaxSurge != "" || upgradeMaxUnavail != "" { - upgradeConfig := map[string]interface{}{} - if upgradeStrategy != "" { - upgradeConfig["strategy"] = upgradeStrategy - } - if upgradeMaxSurge != "" { - upgradeConfig["maxSurge"] = toInt(upgradeMaxSurge) - } - if upgradeMaxUnavail != "" { - upgradeConfig["maxUnavailable"] = toInt(upgradeMaxUnavail) + if upgradeConfigStr != "" { + uc, err := cli.ParseStructFlag(upgradeConfigStr, "maxSurge", "maxUnavailable") + if err != nil { + return fmt.Errorf("--upgrade-config: %w", err) } - body["upgradeConfig"] = upgradeConfig + body["upgradeConfig"] = uc } if len(body) == 0 { - return fmt.Errorf("nothing to update: provide at least one of --num-nodes, --security-groups, --labels, --taints, --auto-scale-min/max, or --upgrade-strategy/max-surge/max-unavailable") + return fmt.Errorf("nothing to update: provide at least one of --num-nodes, --security-groups, --auto-scale, or --upgrade-config (use 'update-nodegroup-metadata' for labels/tags/taints)") } if dryRun { diff --git a/go/internal/cli/parse.go b/go/internal/cli/parse.go index 7476a3b..12b656e 100644 --- a/go/internal/cli/parse.go +++ b/go/internal/cli/parse.go @@ -1,7 +1,9 @@ package cli import ( + "encoding/json" "fmt" + "strconv" "strings" ) @@ -22,6 +24,54 @@ func ParseCommaSeparated(s string) []string { return result } +// ParseStructFlag parses a struct-valued CLI flag accepting either JSON +// ({"minSize":2,"maxSize":10}) or AWS-style shorthand (minSize=2,maxSize=10). +// Keys listed in intFields are coerced to int in the shorthand form; all other +// values stay strings. JSON is passed through as decoded. An empty/blank value +// 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) { + v := strings.TrimSpace(value) + if v == "" { + return nil, nil + } + if strings.HasPrefix(v, "{") { + var m map[string]interface{} + if err := json.Unmarshal([]byte(v), &m); err != nil { + return nil, fmt.Errorf("invalid JSON: %w", err) + } + return m, nil + } + + ints := map[string]bool{} + for _, f := range intFields { + ints[f] = true + } + out := map[string]interface{}{} + for _, pair := range strings.Split(v, ",") { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + idx := strings.Index(pair, "=") + if idx < 0 { + return nil, fmt.Errorf("invalid shorthand %q: expected key=value", pair) + } + key := strings.TrimSpace(pair[:idx]) + val := strings.TrimSpace(pair[idx+1:]) + if 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 { + out[key] = val + } + } + return out, nil +} + // BuildEventsQuery builds query params for events endpoints, including only // flags the user explicitly set. `changed` maps flag name -> was it set. // VKS pagination is 0-based, so page is passed through verbatim. diff --git a/go/internal/cli/parse_test.go b/go/internal/cli/parse_test.go index 0e269bc..d7004b9 100644 --- a/go/internal/cli/parse_test.go +++ b/go/internal/cli/parse_test.go @@ -36,3 +36,52 @@ func TestParseCommaSeparated(t *testing.T) { t.Errorf("ParseCommaSeparated(\"\") should be nil") } } + +func TestParseStructFlagShorthand(t *testing.T) { + got, err := ParseStructFlag("minSize=2,maxSize=10", "minSize", "maxSize") + if err != nil { + t.Fatalf("err: %v", err) + } + if got["minSize"] != 2 || got["maxSize"] != 10 { + t.Errorf("got %#v, want minSize=2 maxSize=10 (ints)", got) + } +} + +func TestParseStructFlagShorthandStringsStay(t *testing.T) { + got, err := ParseStructFlag("type=NEW,placementGroupName=pg-1") + if err != nil { + t.Fatalf("err: %v", err) + } + if got["type"] != "NEW" || got["placementGroupName"] != "pg-1" { + t.Errorf("got %#v", got) + } +} + +func TestParseStructFlagJSON(t *testing.T) { + got, err := ParseStructFlag(`{"minSize":2,"maxSize":10}`, "minSize", "maxSize") + if err != nil { + t.Fatalf("err: %v", err) + } + if got["minSize"].(float64) != 2 || got["maxSize"].(float64) != 10 { + t.Errorf("got %#v", got) + } +} + +func TestParseStructFlagEmpty(t *testing.T) { + got, err := ParseStructFlag(" ") + if err != nil || got != nil { + t.Errorf("empty should be (nil,nil), got %#v err %v", got, err) + } +} + +func TestParseStructFlagErrors(t *testing.T) { + if _, err := ParseStructFlag("{bad json"); err == nil { + t.Error("malformed JSON should error") + } + if _, err := ParseStructFlag("minSize"); err == nil { + t.Error("shorthand pair without '=' should error") + } + if _, err := ParseStructFlag("minSize=abc", "minSize"); err == nil { + t.Error("non-integer int field should error") + } +}