diff --git a/.changes/next-release/feature-vks-tidlj2nc.json b/.changes/next-release/feature-vks-tidlj2nc.json new file mode 100644 index 0000000..4354bb7 --- /dev/null +++ b/.changes/next-release/feature-vks-tidlj2nc.json @@ -0,0 +1,5 @@ +{ + "type": "feature", + "category": "vks", + "description": "create-cluster can optionally attach a node group at creation via the new spec 'nodeGroup' field (singular object; the deprecated 'nodeGroups' array is not used): set --node-group-name (+ --flavor-id/--disk-type/--ssh-key-id, and optional --os/--disk-size/--num-nodes/--private-nodes/--security-groups/--labels/--taints); omit them to create the control plane only" +} diff --git a/docs/commands/vks/create-cluster.md b/docs/commands/vks/create-cluster.md index 78c370d..a8f1135 100644 --- a/docs/commands/vks/create-cluster.md +++ b/docs/commands/vks/create-cluster.md @@ -2,9 +2,9 @@ ## Description -Create a new VKS cluster (control plane only). This command provisions the cluster itself; add worker nodes afterwards with [create-nodegroup](create-nodegroup.md). +Create a new VKS cluster. By default only the control plane is provisioned. Provide `--node-group-name` (together with `--flavor-id`, `--disk-type`, `--ssh-key-id`) to also attach a node group at creation, or add one later with [create-nodegroup](create-nodegroup.md). -Cluster names must be 5–20 characters, lowercase alphanumeric and hyphens, starting and ending with an alphanumeric character. +Cluster names must be 5–20 characters, lowercase alphanumeric and hyphens, starting and ending with an alphanumeric character. Node group names follow the same pattern with a length of 5–15 characters. When `--network-type` is `TIGERA` or `CILIUM_OVERLAY`, the `--cidr` option is required. By default, both the load balancer plugin and the block store CSI plugin are enabled; use `--load-balancer-plugin disabled` or `--block-store-csi-plugin disabled` to turn them off. @@ -32,6 +32,17 @@ grn vks create-cluster [--node-netmask-size ] [--auto-upgrade-config ] [--auto-healing-config ] + [--node-group-name ] + [--flavor-id ] + [--os ] + [--disk-type ] + [--ssh-key-id ] + [--disk-size ] + [--num-nodes ] + [--private-nodes ] + [--security-groups ] + [--labels ] + [--taints ] [--dry-run] ``` @@ -91,12 +102,49 @@ grn vks create-cluster `--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. +### Node group settings (optional) + +Provide `--node-group-name` to attach a node group at creation (sent as the API's `nodeGroup` object; the deprecated `nodeGroups` array is not used). When set, `--flavor-id`, `--disk-type`, and `--ssh-key-id` are also required. The other node-group flags apply only when a node group is attached. + +`--node-group-name` (optional) +: Node group name. Setting this attaches a node group. Must be 5–15 characters, lowercase alphanumeric and hyphens, starting and ending with an alphanumeric character. + +`--flavor-id` (required with a node group) +: Flavor (instance type) ID for the nodes. + +`--os` (optional, default `ubuntu`) +: Node group OS image. Supported values: `ubuntu`, `linux`, `rocky`. + +`--disk-type` (required with a node group) +: Disk type ID for the node boot volumes. + +`--ssh-key-id` (required with a node group) +: SSH key pair ID to inject into each node. + +`--disk-size` (optional, default `100`) +: Boot disk size in GiB. Accepted range: 20–5000. + +`--num-nodes` (optional, default `1`) +: Number of nodes to create. Accepted range: 0–10. + +`--private-nodes` (optional, default `disabled`) +: Private nodes state. Accepted values: `enabled`, `disabled`. + +`--security-groups` (optional) +: Comma-separated list of security group IDs to attach to the nodes. + +`--labels` (optional) +: Comma-separated `key=value` pairs to add as Kubernetes node labels. + +`--taints` (optional) +: Comma-separated node taints in `key=value:effect` format. + `--dry-run` (optional) : Validate all parameters and print a report without sending the create request. ## Examples -Create a cluster with CILIUM_NATIVE_ROUTING: +Create a cluster (control plane only) with CILIUM_NATIVE_ROUTING: ```bash grn vks create-cluster \ @@ -107,6 +155,22 @@ grn vks create-cluster \ --subnet-id sub-abc12345-0000-0000-0000-000000000001 ``` +Create a cluster and attach a node group in one call: + +```bash +grn vks create-cluster \ + --name my-cluster \ + --k8s-version v1.29.1 \ + --network-type CILIUM_NATIVE_ROUTING \ + --vpc-id net-abc12345-0000-0000-0000-000000000001 \ + --subnet-id sub-abc12345-0000-0000-0000-000000000001 \ + --node-group-name default-ng \ + --flavor-id flv-2c4g \ + --disk-type SSD \ + --ssh-key-id key-abc12345-0000-0000-0000-000000000001 \ + --num-nodes 3 +``` + Create a cluster with TIGERA network type (CIDR required): ```bash diff --git a/go/cmd/vks/create_cluster.go b/go/cmd/vks/create_cluster.go index 3546c9f..c2c0178 100644 --- a/go/cmd/vks/create_cluster.go +++ b/go/cmd/vks/create_cluster.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "regexp" + "strings" "github.com/spf13/cobra" "github.com/vngcloud/greennode-cli/internal/cli" @@ -12,8 +13,9 @@ import ( var createClusterCmd = &cobra.Command{ Use: "create-cluster", Short: "Create a new VKS cluster", - Long: "Create a new VKS cluster (control plane only). " + - "Add worker nodes afterwards with 'grn vks create-nodegroup'.", + Long: "Create a new VKS cluster. By default only the control plane is provisioned; " + + "pass --node-group-name (with --flavor-id, --disk-type, --ssh-key-id) to also attach a " + + "node group at creation, or add one later with 'grn vks create-nodegroup'.", RunE: runCreateCluster, } @@ -44,6 +46,22 @@ func init() { 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)") + + // Node group settings (optional; a node group is attached only when + // --node-group-name is set, and then --flavor-id/--disk-type/--ssh-key-id + // are required too). Sent as the singular `nodeGroup` object. + f.String("node-group-name", "", "Node group name (attaches a node group at creation when set)") + f.String("flavor-id", "", "Node group flavor ID (required when attaching a node group)") + f.String("os", "ubuntu", "Node group OS image (ubuntu, linux, rocky)") + f.String("disk-type", "", "Node group disk type ID (required when attaching a node group)") + f.String("ssh-key-id", "", "Node group SSH key ID (required when attaching a node group)") + f.Int("disk-size", 100, "Node group disk size in GiB (20-5000)") + f.Int("num-nodes", 1, "Node group number of nodes (0-10)") + f.String("private-nodes", "disabled", "Node group private nodes (enabled, disabled)") + f.String("security-groups", "", "Node group security group IDs (comma-separated)") + f.String("labels", "", "Node group labels as key=value pairs (comma-separated)") + f.String("taints", "", "Node group taints as key=value:effect (comma-separated)") + f.Bool("dry-run", false, "Validate parameters without creating the cluster") } @@ -134,8 +152,88 @@ func runCreateCluster(cmd *cobra.Command, args []string) error { body["autoHealingConfig"] = hc } + // Optionally attach a node group. Triggered when any node-group flag is set; + // sent as the singular `nodeGroup` object (the API's `nodeGroups` field is + // deprecated). + ngFlags := []string{"node-group-name", "flavor-id", "os", "disk-type", "ssh-key-id", + "disk-size", "num-nodes", "private-nodes", "security-groups", "labels", "taints"} + wantsNodeGroup := false + for _, fl := range ngFlags { + if cmd.Flags().Changed(fl) { + wantsNodeGroup = true + break + } + } + var ngName string + var ngDiskSize, ngNumNodes int + if wantsNodeGroup { + ngName, _ = cmd.Flags().GetString("node-group-name") + flavorID, _ := cmd.Flags().GetString("flavor-id") + diskType, _ := cmd.Flags().GetString("disk-type") + sshKeyID, _ := cmd.Flags().GetString("ssh-key-id") + + var missing []string + if ngName == "" { + missing = append(missing, "--node-group-name") + } + if flavorID == "" { + missing = append(missing, "--flavor-id") + } + if diskType == "" { + missing = append(missing, "--disk-type") + } + if sshKeyID == "" { + missing = append(missing, "--ssh-key-id") + } + if len(missing) > 0 { + return fmt.Errorf("attaching a node group requires: %s", strings.Join(missing, ", ")) + } + + osImage, _ := cmd.Flags().GetString("os") + ngDiskSize, _ = cmd.Flags().GetInt("disk-size") + ngNumNodes, _ = cmd.Flags().GetInt("num-nodes") + privateNodesVal, _ := cmd.Flags().GetString("private-nodes") + nodeSecurityGroups, _ := cmd.Flags().GetString("security-groups") + labels, _ := cmd.Flags().GetString("labels") + taints, _ := cmd.Flags().GetString("taints") + enablePrivateNodes, err := parseToggle("private-nodes", privateNodesVal) + if err != nil { + return err + } + + nodeGroup := map[string]interface{}{ + "name": ngName, + "flavorId": flavorID, + "os": osImage, + "diskSize": ngDiskSize, + "diskType": diskType, + "numNodes": ngNumNodes, + "enablePrivateNodes": enablePrivateNodes, + "sshKeyId": sshKeyID, + "upgradeConfig": map[string]interface{}{ + "maxSurge": 1, + "maxUnavailable": 0, + "strategy": "SURGE", + }, + "securityGroups": []string{}, + } + if subnetID != "" { + nodeGroup["subnetId"] = subnetID + } + if nodeSecurityGroups != "" { + nodeGroup["securityGroups"] = parseCommaSeparated(nodeSecurityGroups) + } + if labels != "" { + nodeGroup["labels"] = parseLabels(labels) + } + if taints != "" { + nodeGroup["taints"] = parseTaints(taints) + } + body["nodeGroup"] = nodeGroup + } + if dryRun { - return validateCreateCluster(name, networkType, cidr) + return validateCreateCluster(name, networkType, cidr, wantsNodeGroup, ngName, ngDiskSize, ngNumNodes) } apiClient, err := createClient(cmd) @@ -152,7 +250,7 @@ func runCreateCluster(cmd *cobra.Command, args []string) error { return outputResult(cmd, result) } -func validateCreateCluster(name, networkType, cidr string) error { +func validateCreateCluster(name, networkType, cidr string, wantsNodeGroup bool, ngName string, diskSize, numNodes int) error { clusterNameRE := regexp.MustCompile(`^[a-z0-9][a-z0-9\-]{3,18}[a-z0-9]$`) var errors []string @@ -166,6 +264,20 @@ func validateCreateCluster(name, networkType, cidr string) error { errors = append(errors, fmt.Sprintf("--cidr is required when network-type is %s", networkType)) } + if wantsNodeGroup { + ngNameRE := regexp.MustCompile(`^[a-z0-9][a-z0-9-]{3,13}[a-z0-9]$`) + if !ngNameRE.MatchString(ngName) { + errors = append(errors, fmt.Sprintf( + "Node group name '%s' is invalid. Must be 5-15 chars, lowercase alphanumeric and hyphens, start/end with alphanumeric.", ngName)) + } + if diskSize < 20 || diskSize > 5000 { + errors = append(errors, fmt.Sprintf("Disk size %d out of range (20-5000 GiB)", diskSize)) + } + if numNodes < 0 || numNodes > 10 { + errors = append(errors, fmt.Sprintf("Number of nodes %d out of range (0-10)", numNodes)) + } + } + fmt.Println("=== DRY RUN: Validation results ===") fmt.Println() if len(errors) > 0 {