Skip to content

Commit 100c97c

Browse files
committed
feat(cluster)!: use min_nodes instead of node_count
node_count is a static value which does not describe the reality. ScyllaDB clusters are dynamic and they scale out automatically when the disk is running out of scape. node_count also can't be used to resize the cluster manually as any change to it results in recreation of the resource. This change introduces min_nodes and makes node_count a computed property. min_nodes can be used for manual resizing as well. Note that there is no max_nodes as the platform does not support it at the moment.
1 parent 103ec0c commit 100c97c

File tree

7 files changed

+491
-101
lines changed

7 files changed

+491
-101
lines changed

internal/provider/cluster/cluster.go

Lines changed: 139 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/scylladb/terraform-provider-scylladbcloud/internal/scylla"
1111
"github.com/scylladb/terraform-provider-scylladbcloud/internal/scylla/model"
1212

13+
"github.com/hashicorp/go-cty/cty"
1314
"github.com/hashicorp/terraform-plugin-log/tflog"
1415
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
1516
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
@@ -40,14 +41,19 @@ func ResourceCluster() *schema.Resource {
4041
Delete: schema.DefaultTimeout(clusterDeleteTimeout),
4142
},
4243

43-
SchemaVersion: 1,
44+
SchemaVersion: 2,
4445

4546
StateUpgraders: []schema.StateUpgrader{
4647
{
4748
Version: 0,
4849
Type: resourceClusterV0().CoreConfigSchema().ImpliedType(),
4950
Upgrade: resourceClusterUpgradeV0,
5051
},
52+
{
53+
Version: 1,
54+
Type: resourceClusterV1().CoreConfigSchema().ImpliedType(),
55+
Upgrade: resourceClusterUpgradeV1,
56+
},
5157
},
5258

5359
Schema: map[string]*schema.Schema{
@@ -76,10 +82,24 @@ func ResourceCluster() *schema.Resource {
7682
Type: schema.TypeString,
7783
},
7884
"node_count": {
79-
Description: "Node count",
85+
Description: "Current node count (computed)",
86+
Computed: true,
87+
Type: schema.TypeInt,
88+
},
89+
"min_nodes": {
90+
Description: "Minimum number of nodes",
8091
Required: true,
81-
ForceNew: true,
8292
Type: schema.TypeInt,
93+
ValidateDiagFunc: func(v interface{}, path cty.Path) diag.Diagnostics {
94+
value := v.(int)
95+
if value < 3 {
96+
return diag.Errorf("min_nodes must be at least 3, got %d", value)
97+
}
98+
if value%3 != 0 {
99+
return diag.Errorf("min_nodes must be divisible by 3, got %d", value)
100+
}
101+
return nil
102+
},
83103
},
84104
"byoa_id": {
85105
Description: "BYOA credential ID (only for AWS)",
@@ -187,7 +207,7 @@ func resourceClusterCreate(ctx context.Context, d *schema.ResourceData, meta int
187207
ClusterName: d.Get("name").(string),
188208
BroadcastType: "PRIVATE",
189209
ReplicationFactor: 3,
190-
NumberOfNodes: int64(d.Get("node_count").(int)),
210+
NumberOfNodes: int64(d.Get("min_nodes").(int)),
191211
UserAPIInterface: d.Get("user_api_interface").(string),
192212
EnableDNSAssociation: d.Get("enable_dns").(bool),
193213
}
@@ -275,7 +295,7 @@ func resourceClusterCreate(ctx context.Context, d *schema.ResourceData, meta int
275295
return diag.Errorf("error creating cluster: %s", err)
276296
}
277297

278-
if err := WaitForCluster(ctx, scyllaClient, cr.ID); err != nil {
298+
if err := WaitForClusterRequestID(ctx, scyllaClient, cr.ID); err != nil {
279299
return diag.Errorf("error waiting for cluster: %s", err)
280300
}
281301

@@ -303,12 +323,16 @@ func resourceClusterCreate(ctx context.Context, d *schema.ResourceData, meta int
303323
func resourceClusterRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
304324
scyllaClient := meta.(*scylla.Client)
305325

306-
clusterID, err := strconv.ParseInt(d.Id(), 10, 64)
307-
if err != nil {
308-
return diag.Errorf("error reading id=%q: %s", d.Id(), err)
326+
clusterID, diags := parseClusterID(d)
327+
if diags != nil {
328+
return diags
309329
}
310330

311-
reqs, err := scyllaClient.ListClusterRequest(ctx, clusterID, "CREATE_CLUSTER")
331+
reqs, err := scyllaClient.ListClusterRequest(
332+
ctx,
333+
clusterID,
334+
scylla.ListClusterRequestParams{Type: "CREATE_CLUSTER"},
335+
)
312336
switch {
313337
case scylla.IsDeletedErr(err):
314338
_ = d.Set("status", "DELETED")
@@ -321,7 +345,7 @@ func resourceClusterRead(ctx context.Context, d *schema.ResourceData, meta inter
321345
_ = d.Set("request_id", reqs[0].ID)
322346

323347
if reqs[0].Status != "COMPLETED" {
324-
if err := WaitForCluster(ctx, scyllaClient, reqs[0].ID); err != nil {
348+
if err := WaitForClusterRequestID(ctx, scyllaClient, reqs[0].ID); err != nil {
325349
return diag.Errorf("error waiting for cluster: %s", err)
326350
}
327351
}
@@ -371,6 +395,9 @@ func setClusterKVs(d *schema.ResourceData, cluster *model.Cluster, providerName,
371395
_ = d.Set("cloud", providerName)
372396
_ = d.Set("region", cluster.Region.ExternalID)
373397
_ = d.Set("node_count", len(model.NodesByStatus(cluster.Nodes, "ACTIVE")))
398+
if _, ok := d.GetOk("min_nodes"); !ok {
399+
_ = d.Set("min_nodes", len(model.NodesByStatus(cluster.Nodes, "ACTIVE")))
400+
}
374401
_ = d.Set("user_api_interface", cluster.UserAPIInterface)
375402
_ = d.Set("node_type", instanceExternalID)
376403
_ = d.Set("node_dns_names", model.NodesDNSNames(cluster.Nodes))
@@ -398,17 +425,91 @@ func setClusterKVs(d *schema.ResourceData, cluster *model.Cluster, providerName,
398425
}
399426

400427
func resourceClusterUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
401-
// Scylla Cloud API does not support updating a cluster,
402-
// thus the update always fails
403-
return diag.Errorf(`updating "scylla_cluster" resource is not supported`)
428+
scyllaClient := meta.(*scylla.Client)
429+
430+
// Currently, only min_nodes is updatable.
431+
if !d.HasChange("min_nodes") {
432+
return nil
433+
}
434+
435+
// There are three scenarios:
436+
// - scale-out: newMinNodes > oldMinNodes
437+
// - scale-in: newMinNodes < oldMinNodes
438+
// - no-op: newMinNodes == oldMinNodes
439+
//
440+
// The no-op case is already handled above by checking `d.HasChange()`.
441+
//
442+
// Scale-out is easy: we just request more nodes.
443+
//
444+
// Scale-in is more complicated. It may happen that it's currently
445+
// not possible, because after the scale-in there would be not enough
446+
// disk space. Such an update should fail, meaning that the old value
447+
// of min_nodes should not be changed. A user can try again later.
448+
//
449+
// Note that it's not possible to update min_nodes and defer
450+
// scale-in until later, e.g., when there is enough disk space,
451+
// because min_nodes controls the resize API behavior rather
452+
// than controlling the desired state. If such a behavior is needed,
453+
// please consider X Cloud. More:
454+
// https://www.scylladb.com/product/scylladb-xcloud/
455+
456+
oldMinNodesI, newMinNodesI := d.GetChange("min_nodes")
457+
458+
oldMinNodes, newMinNodes := oldMinNodesI.(int), newMinNodesI.(int)
459+
460+
clusterID, diags := parseClusterID(d)
461+
if diags != nil {
462+
return diags
463+
}
464+
465+
tflog.Debug(ctx, "Updating cluster min_nodes", map[string]interface{}{
466+
"cluster_id": clusterID,
467+
"old": oldMinNodes,
468+
"new": newMinNodes,
469+
})
470+
471+
cluster, err := scyllaClient.GetCluster(ctx, clusterID)
472+
if err != nil {
473+
if scylla.IsClusterDeletedErr(err) {
474+
d.SetId("")
475+
return nil // cluster was deleted
476+
}
477+
return diag.Errorf("error reading cluster: %s", err)
478+
}
479+
480+
if n := len(cluster.Datacenters); n > 1 {
481+
return diag.Errorf("multi-datacenter clusters are not currently supported: %d", n)
482+
}
483+
484+
// Resize will fail if there is any ongoing cluster request.
485+
if err := waitForNoInProgressRequests(ctx, scyllaClient, cluster.ID); err != nil {
486+
return diag.Errorf("error waiting for no in-progress cluster requests: %s", err)
487+
}
488+
489+
resizeRequest, err := scyllaClient.ResizeCluster(
490+
ctx,
491+
cluster.ID,
492+
cluster.Datacenter.ID,
493+
cluster.Datacenter.InstanceID,
494+
newMinNodes,
495+
)
496+
if err != nil {
497+
return diag.Errorf("error resizing cluster: %s", err)
498+
}
499+
500+
if err := WaitForClusterRequestID(ctx, scyllaClient, resizeRequest.ID); err != nil {
501+
return diag.Errorf("error waiting for cluster resize: %s", err)
502+
}
503+
504+
return resourceClusterRead(ctx, d, meta)
404505
}
405506

406507
func resourceClusterDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
407508
c := meta.(*scylla.Client)
408509

409-
clusterID, err := strconv.ParseInt(d.Id(), 10, 64)
410-
if err != nil {
411-
return diag.Errorf("error reading id=%q: %s", d.Id(), err)
510+
clusterID, diags := parseClusterID(d)
511+
if diags != nil {
512+
return diags
412513
}
413514

414515
name, ok := d.GetOk("name")
@@ -431,7 +532,7 @@ func resourceClusterDelete(ctx context.Context, d *schema.ResourceData, meta int
431532
return nil
432533
}
433534

434-
func WaitForCluster(ctx context.Context, c *scylla.Client, requestID int64) error {
535+
func WaitForClusterRequestID(ctx context.Context, c *scylla.Client, requestID int64) error {
435536
t := time.NewTicker(clusterPollInterval)
436537
defer t.Stop()
437538

@@ -455,46 +556,32 @@ func WaitForCluster(ctx context.Context, c *scylla.Client, requestID int64) erro
455556
return nil
456557
}
457558

458-
func resourceClusterUpgradeV0(ctx context.Context, rawState map[string]any, meta any) (map[string]any, error) {
459-
var (
460-
scyllaClient = meta.(*scylla.Client)
461-
cloud, cloudOK = rawState["cloud"].(string)
462-
nodeType, nodeTypeOK = rawState["node_type"].(string)
463-
region, regionOK = rawState["region"].(string)
464-
)
465-
466-
if !cloudOK {
467-
return nil, fmt.Errorf(`"cloud" is undefined`)
468-
}
469-
470-
if !nodeTypeOK {
471-
return nil, fmt.Errorf(`"node_type" is undefined`)
472-
}
559+
func waitForNoInProgressRequests(ctx context.Context, c *scylla.Client, clusterID int64) error {
560+
t := time.NewTicker(clusterPollInterval)
561+
defer t.Stop()
473562

474-
if !regionOK {
475-
return nil, fmt.Errorf(`"region" is undefined`)
476-
}
563+
for range t.C {
564+
reqs, err := c.ListClusterRequest(
565+
ctx,
566+
clusterID,
567+
scylla.ListClusterRequestParams{Status: "IN_PROGRESS"},
568+
)
569+
if err != nil {
570+
return fmt.Errorf("error reading cluster requests: %w", err)
571+
}
477572

478-
p := scyllaClient.Meta.ProviderByName(cloud)
479-
if p == nil {
480-
return nil, fmt.Errorf(`unrecognized value %q for "cloud"`, cloud)
573+
if len(reqs) == 0 {
574+
break
575+
}
481576
}
482577

483-
mr := p.RegionByName(region)
484-
if mr == nil {
485-
return nil, fmt.Errorf(`unrecognized value %q for "region"`, region)
486-
}
578+
return nil
579+
}
487580

488-
instances, err := scyllaClient.ListCloudProviderInstancesPerRegion(ctx, p.CloudProvider.ID, mr.ID)
581+
func parseClusterID(d *schema.ResourceData) (int64, diag.Diagnostics) {
582+
clusterID, err := strconv.ParseInt(d.Id(), 10, 64)
489583
if err != nil {
490-
return nil, fmt.Errorf("failed to list cloud provider instances for region %q: %s", region, err)
584+
return 0, diag.Errorf("error reading id=%q: %s", d.Id(), err)
491585
}
492-
493-
mi := p.InstanceByNameFromInstances(nodeType, instances)
494-
if mi == nil {
495-
return nil, fmt.Errorf(`unrecognized value %q for "node_type"`, nodeType)
496-
}
497-
498-
rawState["node_disk_size"] = int(mi.TotalStorage)
499-
return rawState, nil
586+
return clusterID, nil
500587
}

internal/provider/cluster/cluster_v0.go

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
package cluster
22

3-
import "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
8+
9+
"github.com/scylladb/terraform-provider-scylladbcloud/internal/scylla"
10+
)
411

512
func resourceClusterV0() *schema.Resource {
613
return &schema.Resource{
@@ -102,3 +109,47 @@ func resourceClusterV0() *schema.Resource {
102109
},
103110
}
104111
}
112+
113+
func resourceClusterUpgradeV0(ctx context.Context, rawState map[string]any, meta any) (map[string]any, error) {
114+
var (
115+
scyllaClient = meta.(*scylla.Client)
116+
cloud, cloudOK = rawState["cloud"].(string)
117+
nodeType, nodeTypeOK = rawState["node_type"].(string)
118+
region, regionOK = rawState["region"].(string)
119+
)
120+
121+
if !cloudOK {
122+
return nil, fmt.Errorf(`"cloud" is undefined`)
123+
}
124+
125+
if !nodeTypeOK {
126+
return nil, fmt.Errorf(`"node_type" is undefined`)
127+
}
128+
129+
if !regionOK {
130+
return nil, fmt.Errorf(`"region" is undefined`)
131+
}
132+
133+
p := scyllaClient.Meta.ProviderByName(cloud)
134+
if p == nil {
135+
return nil, fmt.Errorf(`unrecognized value %q for "cloud"`, cloud)
136+
}
137+
138+
mr := p.RegionByName(region)
139+
if mr == nil {
140+
return nil, fmt.Errorf(`unrecognized value %q for "region"`, region)
141+
}
142+
143+
instances, err := scyllaClient.ListCloudProviderInstancesPerRegion(ctx, p.CloudProvider.ID, mr.ID)
144+
if err != nil {
145+
return nil, fmt.Errorf("failed to list cloud provider instances for region %q: %s", region, err)
146+
}
147+
148+
mi := p.InstanceByNameFromInstances(nodeType, instances)
149+
if mi == nil {
150+
return nil, fmt.Errorf(`unrecognized value %q for "node_type"`, nodeType)
151+
}
152+
153+
rawState["node_disk_size"] = int(mi.TotalStorage)
154+
return rawState, nil
155+
}

0 commit comments

Comments
 (0)