Repository: digitalocean/doctl
Pull request #1649 (2025-01-27 22:12:08)

add cluster autoscaler configuration for cluster create and update
This PR adds the `scale-down-utilization-threshold` and the `scale-down-unneeded-time` flags for users to configure their cluster autoscaler configuration.

loosla: Nice work :rocket: 
Curious if we want to return these fields on `get` :thinking: 
dhij: @loosla it looks like we make a direct godo call to retrieve the cluster so we wouldn't need to do anything on the doctl side 🙂 
bentranter: @dhij Anna brings up a good point though – there's no way to see what these fields are set to after you've created a cluster using them.

You'll need to update the displayer for the Kubernetes cluster (https://github.com/digitalocean/doctl/blob/main/commands/displayers/kubernetes.go) to display the value of these fields somehow. Since it's only two values, you can add each value as its own fields in the full Kubernetes cluster displayer. I've added comments to point out the new fields to make it more obvious:

```go
func (clusters *KubernetesClusters) Cols() []string {
	if clusters.Short {
		return []string{
			"ID",
			"Name",
			"Region",
			"Version",
			"AutoUpgrade",
			"Status",
			"NodePools",
		}
	}
	return []string{
		"ID",
		"Name",
		"Region",
		"Version",
		"AutoUpgrade",
		"HAControlPlane",
		"Status",
		"Endpoint",
		"IPv4",
		"ClusterSubnet",
		"ServiceSubnet",
		"Tags",
		"Created",
		"Updated",
		"NodePools",
		"Autoscaler.UtilizationThreshold", // New field.
		"Autoscaler.UnneededTime", // New field.
	}
}

func (clusters *KubernetesClusters) ColMap() map[string]string {
	if clusters.Short {
		return map[string]string{
			"ID":          "ID",
			"Name":        "Name",
			"Region":      "Region",
			"Version":     "Version",
			"AutoUpgrade": "Auto Upgrade",
			"Status":      "Status",
			"NodePools":   "Node Pools",
		}
	}
	return map[string]string{
		"ID":                              "ID",
		"Name":                            "Name",
		"Region":                          "Region",
		"Version":                         "Version",
		"AutoUpgrade":                     "Auto Upgrade",
		"HAControlPlane":                  "HA Control Plane",
		"ClusterSubnet":                   "Cluster Subnet",
		"ServiceSubnet":                   "Service Subnet",
		"IPv4":                            "IPv4",
		"Endpoint":                        "Endpoint",
		"Tags":                            "Tags",
		"Status":                          "Status",
		"Created":                         "Created At",
		"Updated":                         "Updated At",
		"NodePools":                       "Node Pools",
		"Autoscaler.UtilizationThreshold": "Autoscaler Scale Down Utilization", // New field.
		"Autoscaler.UnneededTime":         "Autoscaler Scale Down Unneeded Time", // New field.
	}
}

func (clusters *KubernetesClusters) KV() []map[string]any {
	out := make([]map[string]any, 0, len(clusters.KubernetesClusters))

	for _, cluster := range clusters.KubernetesClusters {
		tags := strings.Join(cluster.Tags, ",")
		nodePools := make([]string, 0, len(cluster.NodePools))
		for _, pool := range cluster.NodePools {
			nodePools = append(nodePools, pool.Name)
		}
		if cluster.Status == nil {
			cluster.Status = new(godo.KubernetesClusterStatus)
		}

		o := map[string]any{
			"ID":             cluster.ID,
			"Name":           cluster.Name,
			"Region":         cluster.RegionSlug,
			"Version":        cluster.VersionSlug,
			"AutoUpgrade":    cluster.AutoUpgrade,
			"HAControlPlane": cluster.HA,
			"ClusterSubnet":  cluster.ClusterSubnet,
			"ServiceSubnet":  cluster.ServiceSubnet,
			"IPv4":           cluster.IPv4,
			"Endpoint":       cluster.Endpoint,
			"Tags":           tags,
			"Status":         cluster.Status.State,
			"Created":        cluster.CreatedAt,
			"Updated":        cluster.UpdatedAt,
			"NodePools":      strings.Join(nodePools, " "),
		}

                // Add the new fields to the display output if they're set.
		if cfg := cluster.ClusterAutoscalerConfiguration; cfg != nil {
			o["Autoscaler.UtilizationThreshold"] = fmt.Sprintf("%d%%", int(*cfg.ScaleDownUtilizationThreshold*100))
			o["Autoscaler.UnneededTime"] = *cfg.ScaleDownUnneededTime
		}

		out = append(out, o)
	}

	return out
}
```

The output is already pretty long so this isn't great, but I think it's acceptable for now. If we end up having more autoscaler fields in the future, we'll most likely need to split it off into its own command separate from the `get` and `list` ones.

Patch:
diff --git a/args.go b/args.go
index dcffd055..219cf5d4 100644
--- a/args.go
+++ b/args.go
@@ -122,6 +122,10 @@ const (
 	ArgEnableControlPlaneFirewall = "enable-control-plane-firewall"
 	// ArgControlPlaneFirewallAllowedAddresses list of allowed addresses that can access the control plane.
 	ArgControlPlaneFirewallAllowedAddresses = "control-plane-firewall-allowed-addresses"
+	// ArgClusterAutoscalerScaleDownUtilizationThreshold is the cluster autoscaler scale down utilization threshold
+	ArgClusterAutoscalerScaleDownUtilizationThreshold = "scale-down-utilization-threshold"
+	// ArgClusterAutoscalerScaleDownUnneededTime is the cluster autoscaler scale down unneeded time
+	ArgClusterAutoscalerScaleDownUnneededTime = "scale-down-unneeded-time"
 	// ArgSurgeUpgrade is a cluster's surge-upgrade argument.
 	ArgSurgeUpgrade = "surge-upgrade"
 	// ArgCommandUpsert is an upsert for a resource to be created or updated argument.
diff --git a/commands/displayers/kubernetes.go b/commands/displayers/kubernetes.go
index dbc6d958..32f846fe 100644
--- a/commands/displayers/kubernetes.go
+++ b/commands/displayers/kubernetes.go
@@ -1,6 +1,7 @@
 package displayers
 
 import (
+	"fmt"
 	"io"
 	"strings"
 
@@ -47,6 +48,8 @@ func (clusters *KubernetesClusters) Cols() []string {
 		"Created",
 		"Updated",
 		"NodePools",
+		"Autoscaler.UtilizationThreshold",
+		"Autoscaler.UnneededTime",
 	}
 }
 
@@ -63,21 +66,23 @@ func (clusters *KubernetesClusters) ColMap() map[string]string {
 		}
 	}
 	return map[string]string{
-		"ID":             "ID",
-		"Name":           "Name",
-		"Region":         "Region",
-		"Version":        "Version",
-		"AutoUpgrade":    "Auto Upgrade",
-		"HAControlPlane": "HA Control Plane",
-		"ClusterSubnet":  "Cluster Subnet",
-		"ServiceSubnet":  "Service Subnet",
-		"IPv4":           "IPv4",
-		"Endpoint":       "Endpoint",
-		"Tags":           "Tags",
-		"Status":         "Status",
-		"Created":        "Created At",
-		"Updated":        "Updated At",
-		"NodePools":      "Node Pools",
+		"ID":                              "ID",
+		"Name":                            "Name",
+		"Region":                          "Region",
+		"Version":                         "Version",
+		"AutoUpgrade":                     "Auto Upgrade",
+		"HAControlPlane":                  "HA Control Plane",
+		"ClusterSubnet":                   "Cluster Subnet",
+		"ServiceSubnet":                   "Service Subnet",
+		"IPv4":                            "IPv4",
+		"Endpoint":                        "Endpoint",
+		"Tags":                            "Tags",
+		"Status":                          "Status",
+		"Created":                         "Created At",
+		"Updated":                         "Updated At",
+		"NodePools":                       "Node Pools",
+		"Autoscaler.UtilizationThreshold": "Autoscaler Scale Down Utilization",
+		"Autoscaler.UnneededTime":         "Autoscaler Scale Down Unneeded Time",
 	}
 }
 
@@ -111,6 +116,12 @@ func (clusters *KubernetesClusters) KV() []map[string]any {
 			"Updated":        cluster.UpdatedAt,
 			"NodePools":      strings.Join(nodePools, " "),
 		}
+
+		if cfg := cluster.ClusterAutoscalerConfiguration; cfg != nil {
+			o["Autoscaler.UtilizationThreshold"] = fmt.Sprintf("%d%%", int(*cfg.ScaleDownUtilizationThreshold*100))
+			o["Autoscaler.UnneededTime"] = *cfg.ScaleDownUnneededTime
+		}
+
 		out = append(out, o)
 	}
 
diff --git a/commands/kubernetes.go b/commands/kubernetes.go
index 611baf36..0ab9dddc 100644
--- a/commands/kubernetes.go
+++ b/commands/kubernetes.go
@@ -291,6 +291,10 @@ After creating a cluster, a configuration context is added to kubectl and made a
 		"Creates the cluster with control plane firewall enabled. Defaults to false. To enable the control plane firewall, supply --enable-control-plane-firewall=true.")
 	AddStringSliceFlag(cmdKubeClusterCreate, doctl.ArgControlPlaneFirewallAllowedAddresses, "", nil,
 		"A comma-separated list of allowed addresses that can access the control plane.")
+	AddStringFlag(cmdKubeClusterCreate, doctl.ArgClusterAutoscalerScaleDownUtilizationThreshold, "", "",
+		"The threshold value for the cluster autoscaler's scale-down-utilization-threshold. It is the maximum value between the sum of CPU requests and sum of memory requests of all pods running on the node divided by node's corresponding allocatable resource, below which a node can be considered for scale down. To set the scale-down-utilization-threshold to 50%, pass the floating point value 0.5.")
+	AddStringFlag(cmdKubeClusterCreate, doctl.ArgClusterAutoscalerScaleDownUnneededTime, "", "",
+		"The unneed time for the cluster autoscaler's scale-down-unneeded-time. It defines how long a node should be unneeded before it is eligible for scale down. To set the scale-down-unneeded-time to a minute and 30 seconds for example, pass the string '1m30s'.")
 	AddStringSliceFlag(cmdKubeClusterCreate, doctl.ArgTag, "", nil,
 		"A comma-separated list of `tags` to apply to the cluster, in addition to the default tags of `k8s` and `k8s:$K8S_CLUSTER_ID`.")
 	AddStringFlag(cmdKubeClusterCreate, doctl.ArgSizeSlug, "",
@@ -339,6 +343,10 @@ Updates the configuration values for a Kubernetes cluster. The cluster must be r
 		"Enables the highly-available control plane for the cluster")
 	AddBoolFlag(cmdKubeClusterUpdate, doctl.ArgEnableControlPlaneFirewall, "", false,
 		"Creates the cluster with control plane firewall enabled. Defaults to false. To enable the control plane firewall, supply --enable-control-plane-firewall=true.")
+	AddStringFlag(cmdKubeClusterUpdate, doctl.ArgClusterAutoscalerScaleDownUtilizationThreshold, "", "",
+		"The threshold value for the cluster autoscaler's scale-down-utilization-threshold. It is the maximum value between the sum of CPU requests and sum of memory requests of all pods running on the node divided by node's corresponding allocatable resource, below which a node can be considered for scale down. To set the scale-down-utilization-threshold to 50%, pass the floating point value 0.5.")
+	AddStringFlag(cmdKubeClusterUpdate, doctl.ArgClusterAutoscalerScaleDownUnneededTime, "", "",
+		"The unneed time for the cluster autoscaler's scale-down-unneeded-time. It defines how long a node should be unneeded before it is eligible for scale down. To set the scale-down-unneeded-time to a minute and 30 seconds for example, pass the string '1m30s'.")
 	AddStringSliceFlag(cmdKubeClusterUpdate, doctl.ArgControlPlaneFirewallAllowedAddresses, "", nil,
 		"A comma-separated list of allowed addresses that can access the control plane.")
 	AddBoolFlag(cmdKubeClusterUpdate, doctl.ArgClusterUpdateKubeconfig, "",
@@ -1690,6 +1698,31 @@ func buildClusterCreateRequestFromArgs(c *CmdConfig, r *godo.KubernetesClusterCr
 		}
 	}
 
+	var clusterAutoscalerConfiguration = &godo.KubernetesClusterAutoscalerConfiguration{}
+	thresholdStr, err := c.Doit.GetString(c.NS, doctl.ArgClusterAutoscalerScaleDownUtilizationThreshold)
+	if err != nil {
+		return err
+	}
+	if thresholdStr != "" {
+		scaleDownUtilizationThreshold, err := strconv.ParseFloat(thresholdStr, 64)
+		if err != nil {
+			return err
+		}
+		clusterAutoscalerConfiguration.ScaleDownUtilizationThreshold = &scaleDownUtilizationThreshold
+	}
+
+	unneededTime, err := c.Doit.GetString(c.NS, doctl.ArgClusterAutoscalerScaleDownUnneededTime)
+	if err != nil {
+		return err
+	}
+	if unneededTime != "" {
+		clusterAutoscalerConfiguration.ScaleDownUnneededTime = &unneededTime
+	}
+
+	if thresholdStr != "" || unneededTime != "" {
+		r.ClusterAutoscalerConfiguration = clusterAutoscalerConfiguration
+	}
+
 	controlPlaneFirewallAllowedAddresses, isSet, err := c.Doit.GetStringSliceIsFlagSet(c.NS, doctl.ArgControlPlaneFirewallAllowedAddresses)
 	if err != nil {
 		return err
@@ -1804,6 +1837,31 @@ func buildClusterUpdateRequestFromArgs(c *CmdConfig, r *godo.KubernetesClusterUp
 		}
 	}
 
+	var clusterAutoscalerConfiguration = &godo.KubernetesClusterAutoscalerConfiguration{}
+	thresholdStr, err := c.Doit.GetString(c.NS, doctl.ArgClusterAutoscalerScaleDownUtilizationThreshold)
+	if err != nil {
+		return err
+	}
+	if thresholdStr != "" {
+		scaleDownUtilizationThreshold, err := strconv.ParseFloat(thresholdStr, 64)
+		if err != nil {
+			return err
+		}
+		clusterAutoscalerConfiguration.ScaleDownUtilizationThreshold = &scaleDownUtilizationThreshold
+	}
+
+	unneededTime, err := c.Doit.GetString(c.NS, doctl.ArgClusterAutoscalerScaleDownUnneededTime)
+	if err != nil {
+		return err
+	}
+	if unneededTime != "" {
+		clusterAutoscalerConfiguration.ScaleDownUnneededTime = &unneededTime
+	}
+
+	if thresholdStr != "" || unneededTime != "" {
+		r.ClusterAutoscalerConfiguration = clusterAutoscalerConfiguration
+	}
+
 	controlPlaneFirewallAllowedAddresses, isSet, err := c.Doit.GetStringSliceIsFlagSet(c.NS, doctl.ArgControlPlaneFirewallAllowedAddresses)
 	if err != nil {
 		return err


Test patch:
diff --git a/commands/kubernetes_test.go b/commands/kubernetes_test.go
index 4c3d3528..a2ddf45b 100644
--- a/commands/kubernetes_test.go
+++ b/commands/kubernetes_test.go
@@ -16,6 +16,9 @@ import (
 )
 
 var (
+	scaleDownUtilizationThreshold = 0.5
+	scaleDownUnneededTime         = "1m30s"
+
 	testCluster = do.KubernetesCluster{
 		KubernetesCluster: &godo.KubernetesCluster{
 			ID:          "cde2c0d6-41e3-479e-ba60-ad971227232b",
@@ -38,6 +41,10 @@ var (
 					"4.3.2.1/32",
 				},
 			},
+			ClusterAutoscalerConfiguration: &godo.KubernetesClusterAutoscalerConfiguration{
+				ScaleDownUtilizationThreshold: &scaleDownUtilizationThreshold,
+				ScaleDownUnneededTime:         &scaleDownUnneededTime,
+			},
 		},
 	}
 
@@ -512,6 +519,10 @@ func TestKubernetesCreate(t *testing.T) {
 					"4.3.2.1/32",
 				},
 			},
+			ClusterAutoscalerConfiguration: &godo.KubernetesClusterAutoscalerConfiguration{
+				ScaleDownUtilizationThreshold: &scaleDownUtilizationThreshold,
+				ScaleDownUnneededTime:         &scaleDownUnneededTime,
+			},
 		}
 		tm.kubernetes.EXPECT().Create(&r).Return(&testCluster, nil)
 
@@ -535,6 +546,9 @@ func TestKubernetesCreate(t *testing.T) {
 		config.Doit.Set(config.NS, doctl.ArgEnableControlPlaneFirewall, testCluster.ControlPlaneFirewall.Enabled)
 		config.Doit.Set(config.NS, doctl.ArgControlPlaneFirewallAllowedAddresses, testCluster.ControlPlaneFirewall.AllowedAddresses)
 
+		config.Doit.Set(config.NS, doctl.ArgClusterAutoscalerScaleDownUtilizationThreshold, testCluster.ClusterAutoscalerConfiguration.ScaleDownUtilizationThreshold)
+		config.Doit.Set(config.NS, doctl.ArgClusterAutoscalerScaleDownUnneededTime, testCluster.ClusterAutoscalerConfiguration.ScaleDownUnneededTime)
+
 		// Test with no vpc-uuid specified
 		err := testK8sCmdService().RunKubernetesClusterCreate("c-8", 3)(config)
 		assert.NoError(t, err)
@@ -588,6 +602,10 @@ func TestKubernetesUpdate(t *testing.T) {
 					"4.3.2.1/32",
 				},
 			},
+			ClusterAutoscalerConfiguration: &godo.KubernetesClusterAutoscalerConfiguration{
+				ScaleDownUtilizationThreshold: &scaleDownUtilizationThreshold,
+				ScaleDownUnneededTime:         &scaleDownUnneededTime,
+			},
 		}
 		tm.kubernetes.EXPECT().Update(testCluster.ID, &r).Return(&testCluster, nil)
 
@@ -599,6 +617,8 @@ func TestKubernetesUpdate(t *testing.T) {
 		config.Doit.Set(config.NS, doctl.ArgHA, true)
 		config.Doit.Set(config.NS, doctl.ArgEnableControlPlaneFirewall, testCluster.ControlPlaneFirewall.Enabled)
 		config.Doit.Set(config.NS, doctl.ArgControlPlaneFirewallAllowedAddresses, testCluster.ControlPlaneFirewall.AllowedAddresses)
+		config.Doit.Set(config.NS, doctl.ArgClusterAutoscalerScaleDownUtilizationThreshold, testCluster.ClusterAutoscalerConfiguration.ScaleDownUtilizationThreshold)
+		config.Doit.Set(config.NS, doctl.ArgClusterAutoscalerScaleDownUnneededTime, testCluster.ClusterAutoscalerConfiguration.ScaleDownUnneededTime)
 
 		err := testK8sCmdService().RunKubernetesClusterUpdate(config)
 		assert.NoError(t, err)
@@ -621,6 +641,10 @@ func TestKubernetesUpdate(t *testing.T) {
 					"4.3.2.1/32",
 				},
 			},
+			ClusterAutoscalerConfiguration: &godo.KubernetesClusterAutoscalerConfiguration{
+				ScaleDownUtilizationThreshold: &scaleDownUtilizationThreshold,
+				ScaleDownUnneededTime:         &scaleDownUnneededTime,
+			},
 		}
 		tm.kubernetes.EXPECT().List().Return(testClusterList, nil)
 		tm.kubernetes.EXPECT().Update(testCluster.ID, &r).Return(&testCluster, nil)
@@ -632,6 +656,8 @@ func TestKubernetesUpdate(t *testing.T) {
 		config.Doit.Set(config.NS, doctl.ArgAutoUpgrade, false)
 		config.Doit.Set(config.NS, doctl.ArgEnableControlPlaneFirewall, testCluster.ControlPlaneFirewall.Enabled)
 		config.Doit.Set(config.NS, doctl.ArgControlPlaneFirewallAllowedAddresses, testCluster.ControlPlaneFirewall.AllowedAddresses)
+		config.Doit.Set(config.NS, doctl.ArgClusterAutoscalerScaleDownUtilizationThreshold, testCluster.ClusterAutoscalerConfiguration.ScaleDownUtilizationThreshold)
+		config.Doit.Set(config.NS, doctl.ArgClusterAutoscalerScaleDownUnneededTime, testCluster.ClusterAutoscalerConfiguration.ScaleDownUnneededTime)
 
 		err := testK8sCmdService().RunKubernetesClusterUpdate(config)
 		assert.NoError(t, err)