Skip to content

Commit ba511ed

Browse files
authored
feat(kubernetes): support storage customisation (#510)
Add support for customising storage tier and size for Kubernetes node groups utilising GPU and Cloud Native plans. Signed-off-by: Ville Vesilehto <ville.vesilehto@upcloud.com>
1 parent cd2d76e commit ba511ed

4 files changed

Lines changed: 209 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818
- Expose GPU limits in `account show` command
1919
- Expose GPU model and amount in `server plans` command
2020
- Add `audit-log export` command.
21+
- Add support for customising storage tier and size for Kubernetes node groups utilising GPU and Cloud Native plans.
2122

2223
## [3.21.0] - 2025-07-15
2324

internal/commands/kubernetes/create.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ func CreateCommand() commands.Command {
3535
--network 03e5ca07-f36c-4957-a676-e001e40441eb \
3636
--node-group count=4,kubelet-arg="log-flush-frequency=5s",label="owner=devteam",label="env=dev",name=my-node-group,plan=4xCPU-8GB,ssh-key="ssh-ed25519 AAAAo admin@user.com",ssh-key="/path/to/your/public/ssh/key.pub",storage=01000000-0000-4000-8000-000160010100,taint="env=dev:NoSchedule",taint="env=dev2:NoSchedule" \
3737
--zone de-fra1`,
38+
`upctl kubernetes create \
39+
--name gpu-cluster \
40+
--network 03e5ca07-f36c-4957-a676-e001e40441eb \
41+
--node-group count=2,name=gpu-workers,plan=GPU-8xCPU-64GB-1xL40S,storage-size=1024,storage-tier=maxiops,label="gpu=NVIDIA-L40S" \
42+
--node-group count=3,name=cloud-native-workers,plan=CLOUDNATIVE-4xCPU-8GB,storage-size=100,storage-tier=standard \
43+
--zone fi-hel2`,
3844
),
3945
}
4046
}
@@ -134,8 +140,11 @@ func (c *createCommand) InitCommand() {
134140
"ssh-key=\"ssh-ed25519 AAAAo admin@user.com\","+
135141
"ssh-key=\"/path/to/your/public/ssh/key.pub\","+
136142
"storage=01000000-0000-4000-8000-000160010100,"+
143+
"storage-size=100,"+
144+
"storage-tier=maxiops,"+
137145
"taint=\"env=dev:NoSchedule\","+
138-
"taint=\"env=dev2:NoSchedule\"`",
146+
"taint=\"env=dev2:NoSchedule\"`\n"+
147+
"Note: storage-size and storage-tier are only supported for Cloud Native (CLOUDNATIVE-*) and GPU (GPU-*) plans. Valid storage tiers: maxiops, standard, hdd.",
139148
)
140149
fs.StringArrayVar(
141150
&c.params.ControlPlaneIPFilter,

internal/commands/kubernetes/nodegroup/create.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,21 @@ import (
1818
"github.com/spf13/pflag"
1919
)
2020

21+
const (
22+
CloudNativePlanPrefix = "CLOUDNATIVE-"
23+
GPUPlanPrefix = "GPU-"
24+
)
25+
26+
var validStorageTiers = []string{upcloud.StorageTierMaxIOPS, upcloud.StorageTierStandard, upcloud.StorageTierHDD}
27+
2128
type CreateNodeGroupParams struct {
2229
Count int
2330
Name string
2431
Plan string
2532
SSHKeys []string
2633
Storage string
34+
StorageSize int
35+
StorageTier string
2736
KubeletArgs []string
2837
Labels []string
2938
Taints []string
@@ -40,6 +49,8 @@ func GetCreateNodeGroupFlagSet(p *CreateNodeGroupParams) *pflag.FlagSet {
4049
fs.StringVar(&p.Plan, "plan", "", "Server plan to use for nodes in the node group. Run `upctl server plans` to list all available plans.")
4150
fs.StringArrayVar(&p.SSHKeys, "ssh-key", []string{}, "SSH keys to be configured as authorized keys to the nodes.")
4251
fs.StringVar(&p.Storage, "storage", "", "Storage template to use when creating the nodes. Defaults to `UpCloud K8s` public template.")
52+
fs.IntVar(&p.StorageSize, "storage-size", 0, fmt.Sprintf("Custom storage size in GiB. Only applicable for Cloud Native (%s*) and GPU (%s*) plans. If not specified, uses plan default.", CloudNativePlanPrefix, GPUPlanPrefix))
53+
fs.StringVar(&p.StorageTier, "storage-tier", "", fmt.Sprintf("Storage tier (maxiops, standard, hdd). Only applicable for Cloud Native (%s*) and GPU (%s*) plans. If not specified, uses plan default.", CloudNativePlanPrefix, GPUPlanPrefix))
4354
fs.StringArrayVar(&p.Taints, "taint", []string{}, "Taints to be configured to the nodes in `key=value:effect` format")
4455
config.AddEnableOrDisableFlag(fs, &p.UtilityNetworkAccess, true, "utility-network-access", "utility network access. If disabled, nodes in this group will not have access to utility network")
4556

@@ -49,11 +60,51 @@ func GetCreateNodeGroupFlagSet(p *CreateNodeGroupParams) *pflag.FlagSet {
4960
commands.Must(fs.SetAnnotation("name", commands.FlagAnnotationNoFileCompletions, nil))
5061
commands.Must(fs.SetAnnotation("ssh-key", commands.FlagAnnotationNoFileCompletions, nil))
5162
commands.Must(fs.SetAnnotation("storage", commands.FlagAnnotationNoFileCompletions, nil))
63+
commands.Must(fs.SetAnnotation("storage-size", commands.FlagAnnotationNoFileCompletions, nil))
64+
commands.Must(fs.SetAnnotation("storage-tier", commands.FlagAnnotationFixedCompletions, []string{upcloud.StorageTierMaxIOPS, upcloud.StorageTierStandard, upcloud.StorageTierHDD}))
5265
commands.Must(fs.SetAnnotation("taint", commands.FlagAnnotationNoFileCompletions, nil))
5366

5467
return fs
5568
}
5669

70+
// supportStorageCustomization checks if a plan supports storage customization
71+
func supportStorageCustomization(planName string) bool {
72+
return strings.HasPrefix(planName, CloudNativePlanPrefix) ||
73+
strings.HasPrefix(planName, GPUPlanPrefix)
74+
}
75+
76+
// validateStorageTier checks if the storage tier is valid
77+
func validateStorageTier(tier string) error {
78+
if tier == "" {
79+
return nil // Empty is valid (uses plan default)
80+
}
81+
82+
for _, validTier := range validStorageTiers {
83+
if tier == validTier {
84+
return nil
85+
}
86+
}
87+
88+
return fmt.Errorf("invalid storage tier %q, must be one of: %s", tier, strings.Join(validStorageTiers, ", "))
89+
}
90+
91+
// validateStorageSize checks if the storage size is valid
92+
func validateStorageSize(size int) error {
93+
if size == 0 {
94+
return nil // Zero is valid (uses plan default)
95+
}
96+
97+
if size < 25 {
98+
return fmt.Errorf("storage size must be at least 25 GiB, got %d", size)
99+
}
100+
101+
if size > 4096 {
102+
return fmt.Errorf("storage size cannot exceed 4096 GiB, got %d", size)
103+
}
104+
105+
return nil
106+
}
107+
57108
func processKubeletArg(in string) (upcloud.KubernetesKubeletArg, error) {
58109
split := strings.SplitN(in, "=", 2)
59110
if len(split) < 2 {
@@ -83,6 +134,19 @@ func processTaint(in string) (upcloud.KubernetesTaint, error) {
83134
func ProcessNodeGroupParams(p CreateNodeGroupParams) (request.KubernetesNodeGroup, error) {
84135
ng := request.KubernetesNodeGroup{}
85136

137+
hasStorageCustomization := p.StorageSize > 0 || p.StorageTier != ""
138+
if hasStorageCustomization && !supportStorageCustomization(p.Plan) {
139+
return ng, fmt.Errorf("storage customization (--storage-size, --storage-tier) is only supported for Cloud Native (%s*) and GPU (%s*) plans, got plan: %s", CloudNativePlanPrefix, GPUPlanPrefix, p.Plan)
140+
}
141+
142+
if err := validateStorageTier(p.StorageTier); err != nil {
143+
return ng, err
144+
}
145+
146+
if err := validateStorageSize(p.StorageSize); err != nil {
147+
return ng, err
148+
}
149+
86150
kubeletArgs := make([]upcloud.KubernetesKubeletArg, 0)
87151
for _, v := range p.KubeletArgs {
88152
ka, err := processKubeletArg(v)
@@ -125,6 +189,27 @@ func ProcessNodeGroupParams(p CreateNodeGroupParams) (request.KubernetesNodeGrou
125189
UtilityNetworkAccess: upcloud.BoolPtr(p.UtilityNetworkAccess.Value()),
126190
}
127191

192+
// Set storage customization for supported plans
193+
if hasStorageCustomization {
194+
if strings.HasPrefix(p.Plan, CloudNativePlanPrefix) {
195+
ng.CloudNativePlan = &upcloud.KubernetesNodeGroupCloudNativePlan{}
196+
if p.StorageSize > 0 {
197+
ng.CloudNativePlan.StorageSize = p.StorageSize
198+
}
199+
if p.StorageTier != "" {
200+
ng.CloudNativePlan.StorageTier = upcloud.StorageTier(p.StorageTier)
201+
}
202+
} else if strings.HasPrefix(p.Plan, GPUPlanPrefix) {
203+
ng.GPUPlan = &upcloud.KubernetesNodeGroupGPUPlan{}
204+
if p.StorageSize > 0 {
205+
ng.GPUPlan.StorageSize = p.StorageSize
206+
}
207+
if p.StorageTier != "" {
208+
ng.GPUPlan.StorageTier = upcloud.StorageTier(p.StorageTier)
209+
}
210+
}
211+
}
212+
128213
return ng, nil
129214
}
130215

@@ -142,6 +227,8 @@ func CreateCommand() commands.Command {
142227
"create",
143228
"Create a new node group into the specified cluster.",
144229
"upctl kubernetes nodegroup create 55199a44-4751-4e27-9394-7c7661910be3 --name secondary-node-group --count 3 --plan 2xCPU-4GB",
230+
"upctl kubernetes nodegroup create 55199a44-4751-4e27-9394-7c7661910be3 --name gpu-nodes --count 2 --plan GPU-8xCPU-64GB-1xL40S --storage-size 1024 --storage-tier maxiops --label gpu=NVIDIA-L40S",
231+
"upctl kubernetes nodegroup create 55199a44-4751-4e27-9394-7c7661910be3 --name cloud-native-nodes --count 4 --plan CLOUDNATIVE-4xCPU-8GB --storage-size 50 --storage-tier standard",
145232
),
146233
}
147234
}

internal/commands/kubernetes/nodegroup/create_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,66 @@ func TestCreateKubernetesNodeGroup(t *testing.T) {
127127
},
128128
},
129129
},
130+
{
131+
name: "GPU plan with storage customization",
132+
args: []string{clusterUUID, "--count", "2", "--name", "gpu-nodes", "--plan", "GPU-8xCPU-64GB-1xL40S", "--storage-size", "1024", "--storage-tier", "maxiops"},
133+
expected: request.CreateKubernetesNodeGroupRequest{
134+
ClusterUUID: clusterUUID,
135+
NodeGroup: request.KubernetesNodeGroup{
136+
Count: 2,
137+
Name: "gpu-nodes",
138+
Plan: "GPU-8xCPU-64GB-1xL40S",
139+
Labels: []upcloud.Label{},
140+
KubeletArgs: []upcloud.KubernetesKubeletArg{},
141+
Taints: []upcloud.KubernetesTaint{},
142+
UtilityNetworkAccess: upcloud.BoolPtr(true),
143+
GPUPlan: &upcloud.KubernetesNodeGroupGPUPlan{
144+
StorageSize: 1024,
145+
StorageTier: upcloud.StorageTierMaxIOPS,
146+
},
147+
},
148+
},
149+
},
150+
{
151+
name: "Cloud Native plan with storage customization",
152+
args: []string{clusterUUID, "--count", "3", "--name", "cloud-native-nodes", "--plan", "CLOUDNATIVE-4xCPU-8GB", "--storage-size", "50", "--storage-tier", "standard"},
153+
expected: request.CreateKubernetesNodeGroupRequest{
154+
ClusterUUID: clusterUUID,
155+
NodeGroup: request.KubernetesNodeGroup{
156+
Count: 3,
157+
Name: "cloud-native-nodes",
158+
Plan: "CLOUDNATIVE-4xCPU-8GB",
159+
Labels: []upcloud.Label{},
160+
KubeletArgs: []upcloud.KubernetesKubeletArg{},
161+
Taints: []upcloud.KubernetesTaint{},
162+
UtilityNetworkAccess: upcloud.BoolPtr(true),
163+
CloudNativePlan: &upcloud.KubernetesNodeGroupCloudNativePlan{
164+
StorageSize: 50,
165+
StorageTier: upcloud.StorageTierStandard,
166+
},
167+
},
168+
},
169+
},
170+
{
171+
name: "storage customization with unsupported plan",
172+
args: []string{clusterUUID, "--count", "2", "--name", "regular-nodes", "--plan", "2xCPU-4GB", "--storage-size", "100"},
173+
errorMsg: "storage customization (--storage-size, --storage-tier) is only supported for Cloud Native (CLOUDNATIVE-*) and GPU (GPU-*) plans, got plan: 2xCPU-4GB",
174+
},
175+
{
176+
name: "invalid storage tier",
177+
args: []string{clusterUUID, "--count", "2", "--name", "gpu-nodes", "--plan", "GPU-8xCPU-64GB-1xL40S", "--storage-tier", "invalid"},
178+
errorMsg: "invalid storage tier \"invalid\", must be one of: maxiops, standard, hdd",
179+
},
180+
{
181+
name: "invalid storage size too small",
182+
args: []string{clusterUUID, "--count", "2", "--name", "gpu-nodes", "--plan", "GPU-8xCPU-64GB-1xL40S", "--storage-size", "20"},
183+
errorMsg: "storage size must be at least 25 GiB, got 20",
184+
},
185+
{
186+
name: "invalid storage size too large",
187+
args: []string{clusterUUID, "--count", "2", "--name", "gpu-nodes", "--plan", "GPU-8xCPU-64GB-1xL40S", "--storage-size", "5000"},
188+
errorMsg: "storage size cannot exceed 4096 GiB, got 5000",
189+
},
130190
} {
131191
t.Run(test.name, func(t *testing.T) {
132192
conf := config.New()
@@ -151,3 +211,54 @@ func TestCreateKubernetesNodeGroup(t *testing.T) {
151211
})
152212
}
153213
}
214+
215+
func TestSupportStorageCustomization(t *testing.T) {
216+
tests := []struct {
217+
planName string
218+
expected bool
219+
}{
220+
{"2xCPU-4GB", false},
221+
{"4xCPU-8GB", false},
222+
{"HICPU-8xCPU-16GB", false},
223+
{"HIMEM-4xCPU-32GB", false},
224+
{"DEV-1xCPU-1GB", false},
225+
{"CLOUDNATIVE-2xCPU-4GB", true},
226+
{"CLOUDNATIVE-4xCPU-8GB", true},
227+
{"GPU-8xCPU-64GB-1xL40S", true},
228+
{"", false},
229+
}
230+
231+
for _, test := range tests {
232+
t.Run(test.planName, func(t *testing.T) {
233+
result := supportStorageCustomization(test.planName)
234+
assert.Equal(t, test.expected, result)
235+
})
236+
}
237+
}
238+
239+
func TestValidateStorageTier(t *testing.T) {
240+
tests := []struct {
241+
tier string
242+
hasError bool
243+
}{
244+
{"", false},
245+
{"maxiops", false},
246+
{"standard", false},
247+
{"hdd", false},
248+
{"archive", true},
249+
{"invalid", true},
250+
{"MAXIOPS", true},
251+
{"Standard", true},
252+
}
253+
254+
for _, test := range tests {
255+
t.Run(test.tier, func(t *testing.T) {
256+
err := validateStorageTier(test.tier)
257+
if test.hasError {
258+
assert.Error(t, err)
259+
} else {
260+
assert.NoError(t, err)
261+
}
262+
})
263+
}
264+
}

0 commit comments

Comments
 (0)