diff --git a/castai/resource_node_template.go b/castai/resource_node_template.go index e00b430b..4f3dc980 100644 --- a/castai/resource_node_template.go +++ b/castai/resource_node_template.go @@ -33,6 +33,7 @@ const ( FieldNodeTemplateIncludeNames = "include_names" FieldNodeTemplateInstanceFamilies = "instance_families" FieldNodeTemplateIsDefault = "is_default" + FieldNodeTemplateIsEnabled = "is_enabled" FieldNodeTemplateIsGpuOnly = "is_gpu_only" FieldNodeTemplateManufacturers = "manufacturers" FieldNodeTemplateMaxCount = "max_count" @@ -92,6 +93,12 @@ func resourceNodeTemplate() *schema.Resource { ValidateDiagFunc: validation.ToDiagFunc(validation.StringIsNotWhiteSpace), Description: "Name of the node template.", }, + FieldNodeTemplateIsEnabled: { + Type: schema.TypeBool, + Optional: true, + Computed: true, + Description: "Flag whether the node template is enabled and considered for autoscaling.", + }, FieldNodeTemplateIsDefault: { Type: schema.TypeBool, Optional: true, @@ -390,6 +397,9 @@ func resourceNodeTemplateRead(ctx context.Context, d *schema.ResourceData, meta if err := d.Set(FieldNodeTemplateName, nodeTemplate.Name); err != nil { return diag.FromErr(fmt.Errorf("setting name: %w", err)) } + if err := d.Set(FieldNodeTemplateIsEnabled, nodeTemplate.IsEnabled); err != nil { + return diag.FromErr(fmt.Errorf("setting is enabled: %w", err)) + } if err := d.Set(FieldNodeTemplateIsDefault, nodeTemplate.IsDefault); err != nil { return diag.FromErr(fmt.Errorf("setting is default: %w", err)) } @@ -535,6 +545,18 @@ func resourceNodeTemplateDelete(ctx context.Context, d *schema.ResourceData, met clusterID := d.Get(FieldClusterID).(string) name := d.Get(FieldNodeTemplateName).(string) + if isDefault, ok := d.Get(FieldNodeTemplateIsDefault).(bool); ok && isDefault { + return diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: fmt.Sprintf("Skipping delete of \"%s\" node template", name), + Detail: "Default node templates cannot be deleted from CAST.ai. If you want to autoscaler to stop " + + "considering this node template, you can disable it (either from UI or by setting `is_enabled` " + + "flag to false).", + }, + } + } + resp, err := client.NodeTemplatesAPIDeleteNodeTemplateWithResponse(ctx, clusterID, name) if checkErr := sdk.CheckOKResponse(resp, err); checkErr != nil { return diag.FromErr(checkErr) @@ -554,6 +576,7 @@ func resourceNodeTemplateUpdate(ctx context.Context, d *schema.ResourceData, met FieldNodeTemplateCustomTaints, FieldNodeTemplateCustomInstancesEnabled, FieldNodeTemplateConstraints, + FieldNodeTemplateIsEnabled, ) { log.Printf("[INFO] Nothing to update in node configuration") return nil @@ -568,6 +591,10 @@ func resourceNodeTemplateUpdate(ctx context.Context, d *schema.ResourceData, met req.IsDefault = toPtr(v.(bool)) } + if v, ok := d.GetOk(FieldNodeTemplateIsEnabled); ok { + req.IsEnabled = toPtr(v.(bool)) + } + if v, ok := d.GetOk(FieldNodeTemplateConfigurationId); ok { req.ConfigurationId = toPtr(v.(string)) } @@ -639,6 +666,10 @@ func resourceNodeTemplateCreate(ctx context.Context, d *schema.ResourceData, met ShouldTaint: lo.ToPtr(d.Get(FieldNodeTemplateShouldTaint).(bool)), } + if v, ok := d.GetOk(FieldNodeTemplateIsEnabled); ok { + req.IsEnabled = lo.ToPtr(v.(bool)) + } + if v, ok := d.Get(FieldNodeTemplateRebalancingConfigMinNodes).(int32); ok { req.RebalancingConfig = &sdk.NodetemplatesV1RebalancingConfiguration{ MinNodes: lo.ToPtr(v), diff --git a/castai/resource_node_template_test.go b/castai/resource_node_template_test.go index 7b0dc8ec..6d931359 100644 --- a/castai/resource_node_template_test.go +++ b/castai/resource_node_template_test.go @@ -12,6 +12,7 @@ import ( "github.com/golang/mock/gomock" "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" @@ -41,6 +42,7 @@ func TestNodeTemplateResourceReadContext(t *testing.T) { "template": { "configurationId": "7dc4f922-29c9-4377-889c-0c8c5fb8d497", "configurationName": "default", + "isEnabled": true, "name": "gpu", "constraints": { "spot": false, @@ -171,6 +173,7 @@ custom_taints.1.effect = NoSchedule custom_taints.1.key = some-key-2 custom_taints.1.value = some-value-2 is_default = false +is_enabled = true name = gpu rebalancing_config_min_nodes = 0 should_taint = true @@ -211,6 +214,77 @@ func TestNodeTemplateResourceReadContextEmptyList(t *testing.T) { r.Equal(result[0].Summary, "failed to find node template with name: gpu") } +func TestNodeTemplateResourceDelete_defaultNodeTemplate(t *testing.T) { + r := require.New(t) + mockctrl := gomock.NewController(t) + mockClient := mock_sdk.NewMockClientInterface(mockctrl) + + ctx := context.Background() + provider := &ProviderConfig{ + api: &sdk.ClientWithResponses{ + ClientInterface: mockClient, + }, + } + + clusterId := "b6bfc074-a267-400f-b8f1-db0850c369b1" + body := io.NopCloser(bytes.NewReader([]byte(` + { + "items": [ + { + "template": { + "configurationId": "7dc4f922-29c9-4377-889c-0c8c5fb8d497", + "configurationName": "default", + "name": "default-by-castai", + "isEnabled": true, + "isDefault": true, + "constraints": { + "spot": false, + "onDemand": true, + "minCpu": 10, + "maxCpu": 10000, + "architectures": ["amd64", "arm64"] + }, + "version": "3", + "shouldTaint": true, + "customLabels": {}, + "customTaints": [], + "rebalancingConfig": { + "minNodes": 0 + }, + "customInstancesEnabled": true + } + } + ] + } + `))) + mockClient.EXPECT(). + NodeTemplatesAPIListNodeTemplates(gomock.Any(), clusterId, &sdk.NodeTemplatesAPIListNodeTemplatesParams{IncludeDefault: lo.ToPtr(true)}). + Return(&http.Response{StatusCode: 200, Body: body, Header: map[string][]string{"Content-Type": {"json"}}}, nil) + + resource := resourceNodeTemplate() + val := cty.ObjectVal(map[string]cty.Value{ + FieldClusterId: cty.StringVal(clusterId), + FieldNodeTemplateName: cty.StringVal("default-by-castai"), + }) + state := terraform.NewInstanceStateShimmedFromValue(val, 0) + state.ID = "default-by-castai" + + data := resource.Data(state) + result := resource.ReadContext(ctx, data, provider) + r.Nil(result) + r.False(result.HasError()) + + result = resource.DeleteContext(ctx, data, provider) + r.NotNil(result) + r.Len(result, 1) + r.False(result.HasError()) + r.Equal(diag.Warning, result[0].Severity) + r.Equal("Skipping delete of \"default-by-castai\" node template", result[0].Summary) + r.Equal("Default node templates cannot be deleted from CAST.ai. If you want to autoscaler to stop"+ + " considering this node template, you can disable it (either from UI or by setting `is_enabled` flag to"+ + " false).", result[0].Detail) +} + func TestAccResourceNodeTemplate_basic(t *testing.T) { rName := fmt.Sprintf("%v-node-template-%v", ResourcePrefix, acctest.RandString(8)) resourceName := "castai_node_template.test" @@ -225,6 +299,7 @@ func TestAccResourceNodeTemplate_basic(t *testing.T) { Config: testAccNodeTemplateConfig(rName, clusterName), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr(resourceName, "name", rName), + resource.TestCheckResourceAttr(resourceName, "is_enabled", "true"), resource.TestCheckResourceAttr(resourceName, "should_taint", "true"), resource.TestCheckResourceAttr(resourceName, "custom_instances_enabled", "false"), resource.TestCheckResourceAttr(resourceName, "custom_label.#", "0"), @@ -267,6 +342,7 @@ func TestAccResourceNodeTemplate_basic(t *testing.T) { Config: testNodeTemplateUpdated(rName, clusterName), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr(resourceName, "name", rName), + resource.TestCheckResourceAttr(resourceName, "is_enabled", "true"), resource.TestCheckResourceAttr(resourceName, "should_taint", "true"), resource.TestCheckResourceAttr(resourceName, "custom_instances_enabled", "false"), resource.TestCheckResourceAttr(resourceName, "custom_label.#", "0"), diff --git a/docs/resources/node_template.md b/docs/resources/node_template.md index c4c71058..ddcb1883 100644 --- a/docs/resources/node_template.md +++ b/docs/resources/node_template.md @@ -29,6 +29,7 @@ CAST AI node template resource to manage node templates - `custom_labels` (Map of String) Custom labels to be added to nodes created from this template. If the field `custom_label` is present, the value of `custom_labels` will be ignored. - `custom_taints` (Block List) Custom taints to be added to the nodes created from this template. `shouldTaint` has to be `true` in order to create/update the node template with custom taints. If `shouldTaint` is `true`, but no custom taints are provided, the nodes will be tainted with the default node template taint. (see [below for nested schema](#nestedblock--custom_taints)) - `is_default` (Boolean) Flag whether the node template is default. +- `is_enabled` (Boolean) Flag whether the node template is enabled and considered for autoscaling. - `rebalancing_config_min_nodes` (Number) Minimum nodes that will be kept when rebalancing nodes using this node template. - `should_taint` (Boolean) Marks whether the templated nodes will have a taint. - `timeouts` (Block, Optional) (see [below for nested schema](#nestedblock--timeouts))