Skip to content

Commit 15aa670

Browse files
authored
feat: hibernation schedules support (#478)
* feat: hibernation schedules support * chore: update API client generator and API client
1 parent 5e50e2f commit 15aa670

33 files changed

+6169
-3608
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,6 @@ jobs:
5959
SSO_CLIENT_ID: ${{ secrets.SSO_CLIENT_ID }}
6060
SSO_CLIENT_SECRET: ${{ secrets.SSO_CLIENT_SECRET }}
6161
SSO_DOMAIN: ${{ secrets.SSO_DOMAIN }}
62+
ACCEPTANCE_TEST_ORGANIZATION_ID: ${{ vars.TF_ACCEPTANCE_TEST_ORGANIZATION_ID }}
6263
run: make testacc
6364

Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ SHELL := /bin/bash
33
export API_TAGS ?= ExternalClusterAPI,PoliciesAPI,NodeConfigurationAPI,NodeTemplatesAPI,AuthTokenAPI,ScheduledRebalancingAPI,InventoryAPI,UsersAPI,OperationsAPI,EvictorAPI,SSOAPI,CommitmentsAPI,WorkloadOptimizationAPI,ServiceAccountsAPI,RbacServiceAPI
44
export SWAGGER_LOCATION ?= https://api.cast.ai/v1/spec/openapi.json
55

6+
export CLUSTER_AUTOSCALER_API_TAGS ?= HibernationSchedulesAPI
7+
export CLUSTER_AUTOSCALER_SWAGGER_LOCATION ?= https://api.cast.ai/spec/cluster-autoscaler/openapi.yaml
8+
69
default: build
710

811
.PHONY: init-examples
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package castai
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
8+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
9+
)
10+
11+
func dataSourceHibernationSchedule() *schema.Resource {
12+
dataSourceHibernationSchedule := &schema.Resource{
13+
Description: "Retrieve Hibernation Schedule ",
14+
ReadContext: dataSourceHibernationScheduleRead,
15+
Schema: map[string]*schema.Schema{},
16+
}
17+
18+
resourceHibernationSchedule := resourceHibernationSchedule()
19+
for key, value := range resourceHibernationSchedule.Schema {
20+
dataSourceHibernationSchedule.Schema[key] = value
21+
if key != FieldHibernationScheduleName && key != FieldHibernationScheduleOrganizationID {
22+
// only name and optionally organization id are provided in terraform configuration by user
23+
// other parameters are "computed" from existing hibernation schedule
24+
dataSourceHibernationSchedule.Schema[key].Computed = true
25+
dataSourceHibernationSchedule.Schema[key].Required = false
26+
// MaxItems is for configurable attributes, there's nothing to configure on computed-only field
27+
dataSourceHibernationSchedule.Schema[key].MaxItems = 0
28+
}
29+
}
30+
return dataSourceHibernationSchedule
31+
}
32+
33+
func dataSourceHibernationScheduleRead(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics {
34+
organizationID, err := getHibernationScheduleOrganizationID(ctx, data, meta)
35+
if err != nil {
36+
return diag.FromErr(fmt.Errorf("error retrieving hibernation schedule organization id: %w", err))
37+
}
38+
39+
scheduleName := data.Get(FieldHibernationScheduleName).(string)
40+
schedule, err := getHibernationScheduleByName(ctx, meta, organizationID, scheduleName)
41+
if err != nil {
42+
return diag.FromErr(fmt.Errorf("error retrieving hibernation schedule: %w", err))
43+
}
44+
45+
if err := hibernationScheduleToState(schedule, data); err != nil {
46+
return diag.FromErr(fmt.Errorf("error converting schdeure to terraform state: %w", err))
47+
}
48+
return nil
49+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package castai
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"io"
7+
"net/http"
8+
"testing"
9+
10+
"github.com/golang/mock/gomock"
11+
"github.com/hashicorp/go-cty/cty"
12+
"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
13+
"github.com/samber/lo"
14+
"github.com/stretchr/testify/require"
15+
16+
"github.com/castai/terraform-provider-castai/castai/sdk"
17+
"github.com/castai/terraform-provider-castai/castai/sdk/cluster_autoscaler"
18+
mock_cluster_autoscaler "github.com/castai/terraform-provider-castai/castai/sdk/cluster_autoscaler/mock"
19+
mock_sdk "github.com/castai/terraform-provider-castai/castai/sdk/mock"
20+
)
21+
22+
func TestHibernationScheduleDataSourceRead(t *testing.T) {
23+
t.Parallel()
24+
ctrl := gomock.NewController(t)
25+
defer ctrl.Finish()
26+
27+
r := require.New(t)
28+
mockClient := mock_sdk.NewMockClientWithResponsesInterface(ctrl)
29+
clusterAutoscalerClient := mock_cluster_autoscaler.NewMockClientInterface(ctrl)
30+
31+
ctx := context.Background()
32+
provider := &ProviderConfig{
33+
api: mockClient,
34+
clusterAutoscalerClient: &cluster_autoscaler.ClientWithResponses{
35+
ClientInterface: clusterAutoscalerClient,
36+
},
37+
}
38+
39+
organizationID := "0d0111f9-e5a4-4acc-85b2-4c3a8318dfc2"
40+
body := io.NopCloser(bytes.NewReader([]byte(`{
41+
"items": [
42+
{
43+
"id": "75c04e4e-f95c-4f24-a814-b8e753a5194d",
44+
"organizationId": "0d0111f9-e5a4-4acc-85b2-4c3a8318dfc2",
45+
"enabled": false,
46+
"name": "schedule",
47+
"pauseConfig": {
48+
"enabled": true,
49+
"schedule": {
50+
"cronExpression": "1 0 * * *"
51+
}
52+
},
53+
"resumeConfig": {
54+
"enabled": true,
55+
"schedule": {
56+
"cronExpression": "1 0 * * *"
57+
},
58+
"jobConfig": {
59+
"nodeConfig": {
60+
"instanceType": "e2-standard-4",
61+
"kubernetesLabels": {},
62+
"kubernetesTaints": []
63+
}
64+
}
65+
},
66+
"clusterAssignments": {
67+
"items": [
68+
{
69+
"clusterId": "38a49ce8-e900-4a10-be89-48fb2efb1025"
70+
}
71+
]
72+
},
73+
"createTime": "2025-04-10T12:52:07.732194Z",
74+
"updateTime": "2025-04-10T12:52:07.732194Z"
75+
}
76+
],
77+
"nextPageCursor": "",
78+
"totalCount": 1
79+
}`)))
80+
81+
mockClient.EXPECT().UsersAPIListOrganizationsWithResponse(gomock.Any()).Return(&sdk.UsersAPIListOrganizationsResponse{
82+
JSON200: &sdk.CastaiUsersV1beta1ListOrganizationsResponse{
83+
Organizations: []sdk.CastaiUsersV1beta1UserOrganization{
84+
{Id: lo.ToPtr(organizationID)},
85+
},
86+
},
87+
HTTPResponse: &http.Response{StatusCode: http.StatusOK},
88+
}, nil).Times(1)
89+
90+
clusterAutoscalerClient.EXPECT().
91+
HibernationSchedulesAPIListHibernationSchedules(gomock.Any(), organizationID, gomock.Any()).
92+
Return(&http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(body), Header: map[string][]string{"Content-Type": {"json"}}}, nil)
93+
94+
state := terraform.NewInstanceStateShimmedFromValue(cty.ObjectVal(map[string]cty.Value{}), 0)
95+
96+
resource := dataSourceHibernationSchedule()
97+
data := resource.Data(state)
98+
99+
r.NoError(data.Set("name", "schedule"))
100+
101+
result := resource.ReadContext(ctx, data, provider)
102+
r.Nil(result)
103+
r.False(result.HasError())
104+
105+
expectedState := `ID = 75c04e4e-f95c-4f24-a814-b8e753a5194d
106+
cluster_assignments.# = 1
107+
cluster_assignments.0.assignment.# = 1
108+
cluster_assignments.0.assignment.0.cluster_id = 38a49ce8-e900-4a10-be89-48fb2efb1025
109+
enabled = false
110+
name = schedule
111+
organization_id = 0d0111f9-e5a4-4acc-85b2-4c3a8318dfc2
112+
pause_config.# = 1
113+
pause_config.0.enabled = true
114+
pause_config.0.schedule.# = 1
115+
pause_config.0.schedule.0.cron_expression = 1 0 * * *
116+
resume_config.# = 1
117+
resume_config.0.enabled = true
118+
resume_config.0.job_config.# = 1
119+
resume_config.0.job_config.0.node_config.# = 1
120+
resume_config.0.job_config.0.node_config.0.config_id =
121+
resume_config.0.job_config.0.node_config.0.config_name =
122+
resume_config.0.job_config.0.node_config.0.gpu_config.# = 0
123+
resume_config.0.job_config.0.node_config.0.instance_type = e2-standard-4
124+
resume_config.0.job_config.0.node_config.0.kubernetes_labels.% = 0
125+
resume_config.0.job_config.0.node_config.0.kubernetes_taints.# = 0
126+
resume_config.0.job_config.0.node_config.0.node_affinity.# = 0
127+
resume_config.0.job_config.0.node_config.0.spot_config.# = 0
128+
resume_config.0.job_config.0.node_config.0.subnet_id =
129+
resume_config.0.job_config.0.node_config.0.volume.# = 0
130+
resume_config.0.job_config.0.node_config.0.zone =
131+
resume_config.0.schedule.# = 1
132+
resume_config.0.schedule.0.cron_expression = 1 0 * * *
133+
Tainted = false
134+
`
135+
r.Equal(expectedState, data.State().String())
136+
}

castai/provider.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ import (
1010
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
1111

1212
"github.com/castai/terraform-provider-castai/castai/sdk"
13+
"github.com/castai/terraform-provider-castai/castai/sdk/cluster_autoscaler"
1314
)
1415

1516
type ProviderConfig struct {
16-
api sdk.ClientWithResponsesInterface
17+
api sdk.ClientWithResponsesInterface
18+
clusterAutoscalerClient cluster_autoscaler.ClientWithResponsesInterface
1719
}
1820

1921
func Provider(version string) *schema.Provider {
@@ -57,13 +59,15 @@ func Provider(version string) *schema.Provider {
5759
"castai_workload_scaling_policy": resourceWorkloadScalingPolicy(),
5860
"castai_organization_group": resourceOrganizationGroup(),
5961
"castai_role_bindings": resourceRoleBindings(),
62+
"castai_hibernation_schedule": resourceHibernationSchedule(),
6063
},
6164

6265
DataSourcesMap: map[string]*schema.Resource{
6366
"castai_eks_settings": dataSourceEKSSettings(),
6467
"castai_gke_user_policies": dataSourceGKEPolicies(),
6568
"castai_organization": dataSourceOrganization(),
6669
"castai_rebalancing_schedule": dataSourceRebalancingSchedule(),
70+
"castai_hibernation_schedule": dataSourceHibernationSchedule(),
6771

6872
// TODO: remove in next major release
6973
"castai_eks_user_arn": dataSourceEKSClusterUserARN(),
@@ -90,6 +94,14 @@ func providerConfigure(version string) schema.ConfigureContextFunc {
9094
return nil, diag.FromErr(err)
9195
}
9296

93-
return &ProviderConfig{api: client}, nil
97+
clusterAutoscalerClient, err := cluster_autoscaler.CreateClient(apiURL, apiToken, agent)
98+
if err != nil {
99+
return nil, diag.FromErr(err)
100+
}
101+
102+
return &ProviderConfig{
103+
api: client,
104+
clusterAutoscalerClient: clusterAutoscalerClient,
105+
}, nil
94106
}
95107
}

castai/provider_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ func testAccPreCheck(t *testing.T) {
5050
t.Fatal("CASTAI_API_TOKEN must be set for acceptance tests")
5151
}
5252

53+
if v := os.Getenv("ACCEPTANCE_TEST_ORGANIZATION_ID"); v == "" {
54+
t.Fatal("ACCEPTANCE_TEST_ORGANIZATION_ID must be set for acceptance tests")
55+
}
56+
5357
if err := testAccProvider.Configure(context.Background(), terraform.NewResourceConfigRaw(nil)); err != nil {
5458
t.Fatal(err)
5559
}

castai/resource_eviction_config.go

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import (
55
"context"
66
"encoding/json"
77
"fmt"
8-
"github.com/castai/terraform-provider-castai/castai/sdk"
8+
"log"
9+
"time"
10+
911
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
1012
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
1113
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
1214
"github.com/samber/lo"
13-
"log"
14-
"time"
15+
16+
"github.com/castai/terraform-provider-castai/castai/sdk"
1517
)
1618

1719
const (
@@ -124,18 +126,18 @@ func resourceEvictionConfig() *schema.Resource {
124126
},
125127
},
126128
FieldEvictionOptionDisabled: {
127-
Type: schema.TypeBool,
128-
Optional: true,
129+
Type: schema.TypeBool,
130+
Optional: true,
129131
Description: "Mark pods as removal disabled",
130132
},
131133
FieldEvictionOptionAggressive: {
132-
Type: schema.TypeBool,
133-
Optional: true,
134+
Type: schema.TypeBool,
135+
Optional: true,
134136
Description: "Apply Aggressive mode to Evictor",
135137
},
136138
FieldEvictionOptionDisposable: {
137-
Type: schema.TypeBool,
138-
Optional: true,
139+
Type: schema.TypeBool,
140+
Optional: true,
139141
Description: "Mark node as disposable",
140142
},
141143
},
@@ -424,7 +426,7 @@ func toPodSelector(in interface{}) (*sdk.CastaiEvictorV1PodSelector, error) {
424426
return nil, err
425427
}
426428

427-
if mls == nil || len(mls.AdditionalProperties) == 0 {
429+
if mls == nil || len(*mls) == 0 {
428430
continue
429431
}
430432

@@ -474,21 +476,21 @@ func toNodeSelector(in interface{}) (*sdk.CastaiEvictorV1NodeSelector, error) {
474476
return &out, nil
475477
}
476478

477-
func toMatchLabels(in interface{}) (*sdk.CastaiEvictorV1LabelSelector_MatchLabels, error) {
479+
func toMatchLabels(in interface{}) (*map[string]string, error) {
478480
mls, ok := in.(map[string]interface{})
479481
if !ok {
480482
return nil, fmt.Errorf("mapping match_labels expecting map[string]interface, got %T %+v", in, in)
481483
}
482484
if len(mls) == 0 {
483485
return nil, nil
484486
}
485-
out := sdk.CastaiEvictorV1LabelSelector_MatchLabels{AdditionalProperties: map[string]string{}}
487+
out := map[string]string{}
486488
for k, v := range mls {
487489
value, ok := v.(string)
488490
if !ok {
489491
return nil, fmt.Errorf("mapping match_labels expecting string, got %T %+v", v, v)
490492
}
491-
out.AdditionalProperties[k] = value
493+
out[k] = value
492494
}
493495

494496
return &out, nil
@@ -507,7 +509,7 @@ func flattenPodSelector(ps *sdk.CastaiEvictorV1PodSelector) []map[string]any {
507509
}
508510
if ps.LabelSelector != nil {
509511
if ps.LabelSelector.MatchLabels != nil {
510-
out[FieldMatchLabels] = ps.LabelSelector.MatchLabels.AdditionalProperties
512+
out[FieldMatchLabels] = *ps.LabelSelector.MatchLabels
511513
}
512514
if ps.LabelSelector.MatchExpressions != nil {
513515
out[FieldMatchExpressions] = flattenMatchExpressions(*ps.LabelSelector.MatchExpressions)
@@ -522,7 +524,7 @@ func flattenNodeSelector(ns *sdk.CastaiEvictorV1NodeSelector) []map[string]any {
522524
}
523525
out := map[string]any{}
524526
if ns.LabelSelector.MatchLabels != nil {
525-
out[FieldMatchLabels] = ns.LabelSelector.MatchLabels.AdditionalProperties
527+
out[FieldMatchLabels] = *ns.LabelSelector.MatchLabels
526528
}
527529
if ns.LabelSelector.MatchExpressions != nil {
528530
out[FieldMatchExpressions] = flattenMatchExpressions(*ns.LabelSelector.MatchExpressions)

castai/resource_eviction_config_test.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,20 @@ import (
44
"bytes"
55
"context"
66
"fmt"
7-
"github.com/castai/terraform-provider-castai/castai/sdk"
8-
mock_sdk "github.com/castai/terraform-provider-castai/castai/sdk/mock"
7+
"io"
8+
"net/http"
9+
"testing"
10+
911
"github.com/golang/mock/gomock"
1012
"github.com/hashicorp/go-cty/cty"
1113
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
1214
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
1315
"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
1416
"github.com/samber/lo"
1517
"github.com/stretchr/testify/require"
16-
"io"
17-
"net/http"
18-
"testing"
18+
19+
"github.com/castai/terraform-provider-castai/castai/sdk"
20+
mock_sdk "github.com/castai/terraform-provider-castai/castai/sdk/mock"
1921
)
2022

2123
func TestEvictionConfig_ReadContext(t *testing.T) {
@@ -299,9 +301,9 @@ func TestEvictionConfig_UpdateContext(t *testing.T) {
299301
PodSelector: &sdk.CastaiEvictorV1PodSelector{
300302
Kind: lo.ToPtr("Job"),
301303
LabelSelector: &sdk.CastaiEvictorV1LabelSelector{
302-
MatchLabels: &sdk.CastaiEvictorV1LabelSelector_MatchLabels{AdditionalProperties: map[string]string{
304+
MatchLabels: &map[string]string{
303305
"key1": "value1",
304-
}}}}}
306+
}}}}
305307

306308
newConfig := sdk.CastaiEvictorV1EvictionConfig{
307309
Settings: sdk.CastaiEvictorV1EvictionSettings{Disposable: &sdk.CastaiEvictorV1EvictionSettingsSettingEnabled{Enabled: true}},

0 commit comments

Comments
 (0)