@@ -3,6 +3,7 @@ package cluster
33import (
44 "context"
55 "fmt"
6+ "slices"
67 "strconv"
78 "strings"
89 "time"
@@ -39,7 +40,7 @@ func ResourceCluster() *schema.Resource {
3940 Delete : schema .DefaultTimeout (clusterDeleteTimeout ),
4041 },
4142
42- SchemaVersion : 2 ,
43+ SchemaVersion : 3 ,
4344
4445 StateUpgraders : []schema.StateUpgrader {
4546 {
@@ -52,6 +53,11 @@ func ResourceCluster() *schema.Resource {
5253 Type : resourceClusterV1 ().CoreConfigSchema ().ImpliedType (),
5354 Upgrade : resourceClusterUpgradeV1 ,
5455 },
56+ {
57+ Version : 2 ,
58+ Type : resourceClusterV2 ().CoreConfigSchema ().ImpliedType (),
59+ Upgrade : resourceClusterUpgradeV2 ,
60+ },
5561 },
5662
5763 Schema : map [string ]* schema.Schema {
@@ -189,6 +195,20 @@ func ResourceCluster() *schema.Resource {
189195 Computed : true ,
190196 Type : schema .TypeInt ,
191197 },
198+ "availability_zone_ids" : {
199+ Description : "List of Availability Zone IDs for the cluster nodes (e.g., " +
200+ "'use1-az1', 'use1-az2', 'use1-az4' for AWS or 'us-central1-a', 'us-central1-b', " +
201+ "'us-central1-c' for GCP). It is recommended to specify exactly 3 AZ IDs to " +
202+ "ensure optimal distribution of nodes across availability zones. AZ IDs are " +
203+ "consistent identifiers that map to the same physical availability zone across " +
204+ "all accounts, unlike AZ names which may differ between accounts. If not " +
205+ "specified, the server will automatically select availability zones." ,
206+ Optional : true ,
207+ Computed : true ,
208+ ForceNew : true ,
209+ Type : schema .TypeSet ,
210+ Elem : & schema.Schema {Type : schema .TypeString },
211+ },
192212 },
193213 }
194214}
@@ -269,6 +289,37 @@ func resourceClusterCreate(ctx context.Context, d *schema.ResourceData, meta int
269289
270290 clusterCreateRequest .InstanceID = mi .ID
271291
292+ // Handle availability zone IDs
293+ if azIDs , ok := d .GetOk ("availability_zone_ids" ); ok {
294+ // Figure out the cloud account ID; it's either BYOA or Scylla Account.
295+ // There is a clear mapping from cloudProviderID to cloudAccountID.
296+ cloudAccountID := clusterCreateRequest .AccountCredentialID
297+ if cloudAccountID == 0 {
298+ switch cloudProvider .CloudProvider .ID {
299+ case 1 : // AWS
300+ cloudAccountID = 1
301+ case 2 : // GCP
302+ cloudAccountID = 200
303+ default :
304+ return diag .Errorf ("unknown cloud provider ID %d" , cloudProvider .CloudProvider .ID )
305+ }
306+ }
307+
308+ azIDsSet := azIDs .(* schema.Set )
309+
310+ var azIDList []string
311+ for _ , v := range azIDsSet .List () {
312+ azIDList = append (azIDList , v .(string ))
313+ }
314+ slices .Sort (azIDList )
315+
316+ if err := validateAvailabilityZoneIDs (ctx , scyllaClient , cloudAccountID , mr .ID , azIDList ); err != nil {
317+ return diag .FromErr (err )
318+ }
319+
320+ clusterCreateRequest .AvailabilityZoneIDs = azIDList
321+ }
322+
272323 if ! versionOK {
273324 clusterCreateRequest .ScyllaVersionID = scyllaClient .Meta .ScyllaVersions .DefaultScyllaVersionID
274325 } else if mv := scyllaClient .Meta .VersionByName (version .(string )); mv != nil {
@@ -291,6 +342,10 @@ func resourceClusterCreate(ctx context.Context, d *schema.ResourceData, meta int
291342 return diag .Errorf ("failed to read cluster %d: %s" , cr .ClusterID , err )
292343 }
293344
345+ if n := len (cluster .Datacenters ); n != 1 {
346+ return diag .Errorf ("clusters without datacenter or multi-datacenter clusters are not currently supported (found %d datacenters)" , n )
347+ }
348+
294349 i := cloudProvider .InstanceByIDFromInstances (cluster .Datacenter .InstanceID , instances )
295350 if i == nil {
296351 return diag .Errorf ("unexpected instance ID for cluster %d: %d" , cluster .ID , cluster .Datacenter .InstanceID )
@@ -351,8 +406,8 @@ func resourceClusterRead(ctx context.Context, d *schema.ResourceData, meta inter
351406 return diag .Errorf ("unexpected cloud provider %d for cluster %d" , cluster .CloudProviderID , cluster .ID )
352407 }
353408
354- if n := len (cluster .Datacenters ); n > 1 {
355- return diag .Errorf ("multi-datacenter clusters are not currently supported (found %d datacenters)" , n )
409+ if n := len (cluster .Datacenters ); n != 1 {
410+ return diag .Errorf ("clusters without datacenter or multi-datacenter clusters are not currently supported (found %d datacenters)" , n )
356411 }
357412
358413 var instanceExternalID string
@@ -368,6 +423,7 @@ func resourceClusterRead(ctx context.Context, d *schema.ResourceData, meta inter
368423 }
369424 instanceExternalID = i .ExternalID
370425 }
426+
371427 err = setClusterKVs (d , cluster , p .CloudProvider .Name , instanceExternalID )
372428 if err != nil {
373429 return diag .Errorf ("failed to set cluster values for cluster %d: %s" , cluster .ID , err )
@@ -424,6 +480,13 @@ func setClusterKVs(d *schema.ResourceData, cluster *model.Cluster, providerName,
424480 _ = d .Set ("node_disk_size" , cluster .Instance .TotalStorage )
425481 }
426482
483+ azIDs := cluster .Datacenter .AvailabilityZoneIDs ()
484+ if azIDs == nil {
485+ // Prevent stale data in case the new value is empty or missing.
486+ azIDs = []string {}
487+ }
488+ _ = d .Set ("availability_zone_ids" , azIDs )
489+
427490 return nil
428491}
429492
@@ -479,8 +542,8 @@ func resourceClusterUpdate(ctx context.Context, d *schema.ResourceData, meta int
479542 return diag .Errorf ("failed to get the cluster with ID %d: %s" , clusterID , err )
480543 }
481544
482- if n := len (cluster .Datacenters ); n > 1 {
483- return diag .Errorf ("multi-datacenter clusters are not currently supported (found %d datacenters for cluster %d )" , n , clusterID )
545+ if n := len (cluster .Datacenters ); n != 1 {
546+ return diag .Errorf ("clusters without datacenter or multi-datacenter clusters are not currently supported (found %d datacenters)" , n )
484547 }
485548
486549 // Resize will fail if there is any ongoing cluster request.
@@ -653,3 +716,54 @@ func parseClusterID(d *schema.ResourceData) (int64, diag.Diagnostics) {
653716 }
654717 return clusterID , nil
655718}
719+
720+ // validateAvailabilityZoneIDs validates that the provided AZ IDs are valid for the given region.
721+ // TODO: When placement groups are supported through the API, revisit the minimum AZ requirement
722+ // as single-AZ deployments may become valid with placement group configuration.
723+ func validateAvailabilityZoneIDs (ctx context.Context , c * scylla.Client , cloudAccountID , regionID int64 , azIDs []string ) error {
724+ if l := len (azIDs ); l < 2 || l > 3 {
725+ return fmt .Errorf ("at least 2 and at most 3 availability zone IDs are required, got %d" , l )
726+ }
727+
728+ // Check for duplicate AZ IDs.
729+ seen := make (map [string ]struct {}, len (azIDs ))
730+ var duplicates []string
731+ for _ , azID := range azIDs {
732+ if _ , ok := seen [azID ]; ok {
733+ duplicates = append (duplicates , azID )
734+ } else {
735+ seen [azID ] = struct {}{}
736+ }
737+ }
738+ if len (duplicates ) > 0 {
739+ return fmt .Errorf ("duplicate availability zone IDs are not allowed: %v" , duplicates )
740+ }
741+
742+ // Validate available AZ IDs.
743+ availableAZs , err := c .ListAvailabilityZoneIDs (ctx , cloudAccountID , regionID )
744+ if err != nil {
745+ return fmt .Errorf ("failed to list availability zones for region: %w" , err )
746+ }
747+
748+ availableSet := make (map [string ]struct {}, len (availableAZs ))
749+ for _ , az := range availableAZs {
750+ availableSet [az ] = struct {}{}
751+ }
752+
753+ var invalidAZs []string
754+ for _ , azID := range azIDs {
755+ if _ , ok := availableSet [azID ]; ! ok {
756+ invalidAZs = append (invalidAZs , azID )
757+ }
758+ }
759+
760+ if len (invalidAZs ) > 0 {
761+ return fmt .Errorf (
762+ "invalid availability zone IDs %v; available AZ IDs for this region are: %v" ,
763+ invalidAZs ,
764+ availableAZs ,
765+ )
766+ }
767+
768+ return nil
769+ }
0 commit comments