Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/next-release/feature-vks-tidlj2nc.json
Original file line number Diff line number Diff line change
@@ -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"
}
70 changes: 67 additions & 3 deletions docs/commands/vks/create-cluster.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -32,6 +32,17 @@ grn vks create-cluster
[--node-netmask-size <value>]
[--auto-upgrade-config <value>]
[--auto-healing-config <value>]
[--node-group-name <value>]
[--flavor-id <value>]
[--os <value>]
[--disk-type <value>]
[--ssh-key-id <value>]
[--disk-size <value>]
[--num-nodes <value>]
[--private-nodes <enabled|disabled>]
[--security-groups <value>]
[--labels <value>]
[--taints <value>]
[--dry-run]
```

Expand Down Expand Up @@ -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 \
Expand All @@ -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
Expand Down
120 changes: 116 additions & 4 deletions go/cmd/vks/create_cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"regexp"
"strings"

"github.com/spf13/cobra"
"github.com/vngcloud/greennode-cli/internal/cli"
Expand All @@ -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,
}

Expand Down Expand Up @@ -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")
}

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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 {
Expand Down