Skip to content

Commit f9459b5

Browse files
committed
feat: allow updating project's instance size
1 parent 8d940ea commit f9459b5

File tree

6 files changed

+241
-34
lines changed

6 files changed

+241
-34
lines changed

docs/resources/project.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,7 @@ resource "supabase_project" "test" {
2121
instance_size = "micro"
2222
2323
lifecycle {
24-
ignore_changes = [
25-
database_password,
26-
instance_size,
27-
]
24+
ignore_changes = [database_password]
2825
}
2926
}
3027
```

examples/resources/supabase_project/resource.tf

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,6 @@ resource "supabase_project" "test" {
66
instance_size = "micro"
77

88
lifecycle {
9-
ignore_changes = [
10-
database_password,
11-
instance_size,
12-
]
9+
ignore_changes = [database_password]
1310
}
1411
}

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ require (
77
github.com/hashicorp/terraform-plugin-docs v0.24.0
88
github.com/hashicorp/terraform-plugin-framework v1.16.1
99
github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0
10+
github.com/hashicorp/terraform-plugin-framework-validators v0.19.0
1011
github.com/hashicorp/terraform-plugin-go v0.29.0
1112
github.com/hashicorp/terraform-plugin-log v0.10.0
1213
github.com/hashicorp/terraform-plugin-testing v1.13.3

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ github.com/hashicorp/terraform-plugin-framework v1.16.1 h1:1+zwFm3MEqd/0K3YBB2v9
117117
github.com/hashicorp/terraform-plugin-framework v1.16.1/go.mod h1:0xFOxLy5lRzDTayc4dzK/FakIgBhNf/lC4499R9cV4Y=
118118
github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0 h1:SJXL5FfJJm17554Kpt9jFXngdM6fXbnUnZ6iT2IeiYA=
119119
github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0/go.mod h1:p0phD0IYhsu9bR4+6OetVvvH59I6LwjXGnTVEr8ox6E=
120+
github.com/hashicorp/terraform-plugin-framework-validators v0.19.0 h1:Zz3iGgzxe/1XBkooZCewS0nJAaCFPFPHdNJd8FgE4Ow=
121+
github.com/hashicorp/terraform-plugin-framework-validators v0.19.0/go.mod h1:GBKTNGbGVJohU03dZ7U8wHqc2zYnMUawgCN+gC0itLc=
120122
github.com/hashicorp/terraform-plugin-go v0.29.0 h1:1nXKl/nSpaYIUBU1IG/EsDOX0vv+9JxAltQyDMpq5mU=
121123
github.com/hashicorp/terraform-plugin-go v0.29.0/go.mod h1:vYZbIyvxyy0FWSmDHChCqKvI40cFTDGSb3D8D70i9GM=
122124
github.com/hashicorp/terraform-plugin-log v0.10.0 h1:eu2kW6/QBVdN4P3Ju2WiB2W3ObjkAsyfBsL3Wh1fj3g=

internal/provider/project_resource.go

Lines changed: 132 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,17 @@ import (
77
"context"
88
"fmt"
99
"net/http"
10+
"slices"
11+
"strings"
1012

13+
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
1114
"github.com/hashicorp/terraform-plugin-framework/diag"
1215
"github.com/hashicorp/terraform-plugin-framework/path"
1316
"github.com/hashicorp/terraform-plugin-framework/resource"
1417
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
1518
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
1619
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
20+
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
1721
"github.com/hashicorp/terraform-plugin-framework/types"
1822
"github.com/hashicorp/terraform-plugin-log/tflog"
1923
"github.com/supabase/cli/pkg/api"
@@ -71,6 +75,30 @@ func (r *ProjectResource) Schema(ctx context.Context, req resource.SchemaRequest
7175
"instance_size": schema.StringAttribute{
7276
MarkdownDescription: "Desired instance size of the project",
7377
Optional: true,
78+
Validators: []validator.String{
79+
stringvalidator.OneOf(
80+
string(api.V1CreateProjectBodyDesiredInstanceSizeLarge),
81+
string(api.V1CreateProjectBodyDesiredInstanceSizeMedium),
82+
string(api.V1CreateProjectBodyDesiredInstanceSizeMicro),
83+
string(api.V1CreateProjectBodyDesiredInstanceSizeN12xlarge),
84+
string(api.V1CreateProjectBodyDesiredInstanceSizeN16xlarge),
85+
string(api.V1CreateProjectBodyDesiredInstanceSizeN24xlarge),
86+
string(api.V1CreateProjectBodyDesiredInstanceSizeN24xlargeHighMemory),
87+
string(api.V1CreateProjectBodyDesiredInstanceSizeN24xlargeOptimizedCpu),
88+
string(api.V1CreateProjectBodyDesiredInstanceSizeN24xlargeOptimizedMemory),
89+
string(api.V1CreateProjectBodyDesiredInstanceSizeN2xlarge),
90+
string(api.V1CreateProjectBodyDesiredInstanceSizeN48xlarge),
91+
string(api.V1CreateProjectBodyDesiredInstanceSizeN48xlargeHighMemory),
92+
string(api.V1CreateProjectBodyDesiredInstanceSizeN48xlargeOptimizedCpu),
93+
string(api.V1CreateProjectBodyDesiredInstanceSizeN48xlargeOptimizedMemory),
94+
string(api.V1CreateProjectBodyDesiredInstanceSizeN4xlarge),
95+
string(api.V1CreateProjectBodyDesiredInstanceSizeN8xlarge),
96+
string(api.V1CreateProjectBodyDesiredInstanceSizeNano),
97+
string(api.V1CreateProjectBodyDesiredInstanceSizePico),
98+
string(api.V1CreateProjectBodyDesiredInstanceSizeSmall),
99+
string(api.V1CreateProjectBodyDesiredInstanceSizeXlarge),
100+
),
101+
},
74102
},
75103
"id": schema.StringAttribute{
76104
MarkdownDescription: "Project identifier",
@@ -142,17 +170,41 @@ func (r *ProjectResource) Read(ctx context.Context, req resource.ReadRequest, re
142170
}
143171

144172
func (r *ProjectResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
145-
var data ProjectResourceModel
173+
var plan, state ProjectResourceModel
146174

147-
// Read Terraform plan data into the model
148-
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
175+
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
176+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
177+
if resp.Diagnostics.HasError() {
178+
return
179+
}
180+
181+
if !plan.Name.Equal(state.Name) {
182+
resp.Diagnostics.AddAttributeError(path.Root("name"), "Client Error", "Update is not supported for this attribute")
183+
return
184+
}
185+
if !plan.DatabasePassword.Equal(state.DatabasePassword) {
186+
resp.Diagnostics.AddAttributeError(path.Root("database_password"), "Client Error", "Update is not supported for this attribute")
187+
return
188+
}
189+
if !plan.Region.Equal(state.Region) {
190+
resp.Diagnostics.AddAttributeError(path.Root("region"), "Client Error", "Update is not supported for this attribute")
191+
return
192+
}
193+
if !plan.OrganizationId.Equal(state.OrganizationId) {
194+
resp.Diagnostics.AddAttributeError(path.Root("organization_id"), "Client Error", "Update is not supported for this attribute")
195+
return
196+
}
197+
if !plan.InstanceSize.Equal(state.InstanceSize) {
198+
resp.Diagnostics.Append(updateInstanceSize(ctx, &plan, &state, r.client)...)
199+
return
200+
}
201+
202+
resp.Diagnostics.Append(updateInstanceSize(ctx, &plan, &state, r.client)...)
149203
if resp.Diagnostics.HasError() {
150204
return
151205
}
152206

153-
// TODO: allow api to update project resource
154-
msg := fmt.Sprintf("Update is not supported for project resource: %s", data.Id.ValueString())
155-
resp.Diagnostics.Append(diag.NewErrorDiagnostic("Client Error", msg))
207+
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
156208
}
157209

158210
func (r *ProjectResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
@@ -218,28 +270,46 @@ func createProject(ctx context.Context, data *ProjectResourceModel, client *api.
218270
}
219271

220272
func readProject(ctx context.Context, data *ProjectResourceModel, client *api.ClientWithResponses) diag.Diagnostics {
221-
httpResp, err := client.V1ListAllProjectsWithResponse(ctx)
273+
projectResp, err := client.V1GetProjectWithResponse(ctx, data.Id.ValueString())
222274
if err != nil {
223275
msg := fmt.Sprintf("Unable to read project, got error: %s", err)
224276
return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)}
225277
}
226278

227-
if httpResp.JSON200 == nil {
228-
msg := fmt.Sprintf("Unable to read project, got status %d: %s", httpResp.StatusCode(), httpResp.Body)
279+
if projectResp.JSON200 == nil {
280+
msg := fmt.Sprintf("Unable to read project, got status %d: %s", projectResp.StatusCode(), projectResp.Body)
229281
return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)}
230282
}
231283

232-
for _, project := range *httpResp.JSON200 {
233-
if project.Id == data.Id.ValueString() {
234-
data.OrganizationId = types.StringValue(project.OrganizationId)
235-
data.Name = types.StringValue(project.Name)
236-
data.Region = types.StringValue(project.Region)
237-
return nil
284+
project := projectResp.JSON200
285+
data.OrganizationId = types.StringValue(project.OrganizationId)
286+
data.Name = types.StringValue(project.Name)
287+
data.Region = types.StringValue(project.Region)
288+
289+
addonsResp, err := client.V1ListProjectAddonsWithResponse(ctx, project.Id)
290+
if err != nil {
291+
msg := fmt.Sprintf("Unable to read project addons, got error: %s", err)
292+
return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)}
293+
}
294+
295+
if addonsResp.JSON200 != nil {
296+
for _, addon := range addonsResp.JSON200.SelectedAddons {
297+
if addon.Type != api.ComputeInstance {
298+
continue
299+
}
300+
301+
val, err := addon.Variant.Id.AsListProjectAddonsResponseSelectedAddonsVariantId0()
302+
if err != nil {
303+
tflog.Warn(ctx, "failed to get addon ID as compute_instance", map[string]any{
304+
"addon": addon,
305+
})
306+
continue
307+
}
308+
309+
data.InstanceSize = types.StringValue(strings.TrimPrefix(string(val), "ci_"))
238310
}
239311
}
240312

241-
// Not finding a project means our local state is stale. Return no error to allow TF to refresh its state.
242-
tflog.Trace(ctx, fmt.Sprintf("project not found: %s", data.Id.ValueString()))
243313
return nil
244314
}
245315

@@ -262,3 +332,48 @@ func deleteProject(ctx context.Context, data *ProjectResourceModel, client *api.
262332

263333
return nil
264334
}
335+
336+
var freeInstanceSizes = []string{
337+
string(api.V1CreateProjectBodyDesiredInstanceSizeNano),
338+
string(api.V1CreateProjectBodyDesiredInstanceSizePico),
339+
}
340+
341+
func updateInstanceSize(ctx context.Context, plan *ProjectResourceModel, state *ProjectResourceModel, client *api.ClientWithResponses) diag.Diagnostics {
342+
if plan.InstanceSize.Equal(state.InstanceSize) {
343+
return diag.Diagnostics{}
344+
}
345+
346+
requestedInstanceSize := plan.InstanceSize.ValueString()
347+
if requestedInstanceSize != "" &&
348+
!slices.Contains(freeInstanceSizes, state.InstanceSize.ValueString()) &&
349+
slices.Contains(freeInstanceSizes, requestedInstanceSize) {
350+
msg := fmt.Sprintf("A paid project cannot be downgraded to %s instance size", requestedInstanceSize)
351+
return diag.Diagnostics{diag.NewErrorDiagnostic("Configuration Error", msg)}
352+
}
353+
354+
addon := api.ApplyProjectAddonBody_AddonVariant{}
355+
variant := api.ApplyProjectAddonBodyAddonVariant0("ci_" + plan.InstanceSize.ValueString())
356+
if err := addon.FromApplyProjectAddonBodyAddonVariant0(variant); err != nil {
357+
return diag.Diagnostics{diag.NewErrorDiagnostic(
358+
"Internal Error",
359+
fmt.Sprintf("Failed to configure instance size: %s", err),
360+
)}
361+
}
362+
body := api.V1ApplyProjectAddonJSONRequestBody{
363+
AddonType: api.ApplyProjectAddonBodyAddonTypeComputeInstance,
364+
AddonVariant: addon,
365+
}
366+
367+
httpResp, err := client.V1ApplyProjectAddonWithResponse(ctx, plan.Id.ValueString(), body)
368+
if err != nil {
369+
msg := fmt.Sprintf("Unable to update project, got error: %s", err)
370+
return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)}
371+
}
372+
373+
if httpResp.StatusCode() != http.StatusOK {
374+
msg := fmt.Sprintf("Unable to update project, got error: %s", string(httpResp.Body))
375+
return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)}
376+
}
377+
378+
return nil
379+
}

internal/provider/project_resource_test.go

Lines changed: 104 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package provider
55

66
import (
77
"net/http"
8+
"strings"
89
"testing"
910

1011
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
@@ -25,25 +26,111 @@ func TestAccProjectResource(t *testing.T) {
2526
Name: "foo",
2627
})
2728
gock.New("https://api.supabase.com").
28-
Get("/v1/projects").
29+
Get("/v1/projects/mayuaycdtijbctgqbycg").
2930
Reply(http.StatusOK).
30-
JSON([]api.V1ProjectResponse{{
31+
JSON(api.V1ProjectResponse{
32+
Id: "mayuaycdtijbctgqbycg",
33+
Name: "foo",
34+
OrganizationId: "continued-brown-smelt",
35+
Region: "us-east-1",
36+
})
37+
gock.New("https://api.supabase.com").
38+
Get("/v1/projects/mayuaycdtijbctgqbycg/billing/addons").
39+
Reply(http.StatusOK).
40+
JSON(map[string]any{
41+
"selected_addons": []map[string]any{
42+
{
43+
"type": "compute_instance",
44+
"variant": map[string]any{
45+
"id": api.ListProjectAddonsResponseAvailableAddonsVariantsId0CiMicro,
46+
"name": "Micro",
47+
"price": map[string]any{},
48+
},
49+
},
50+
},
51+
"available_addons": []map[string]any{},
52+
})
53+
// Step 2: update
54+
gock.New("https://api.supabase.com").
55+
Get("/v1/projects/mayuaycdtijbctgqbycg").
56+
Reply(http.StatusOK).
57+
JSON(api.V1ProjectResponse{
58+
Id: "mayuaycdtijbctgqbycg",
59+
Name: "foo",
60+
OrganizationId: "continued-brown-smelt",
61+
Region: "us-east-1",
62+
})
63+
gock.New("https://api.supabase.com").
64+
Get("/v1/projects/mayuaycdtijbctgqbycg/billing/addons").
65+
Reply(http.StatusOK).
66+
JSON(map[string]any{
67+
"selected_addons": []map[string]any{
68+
{
69+
"type": "compute_instance",
70+
"variant": map[string]any{
71+
"id": api.ListProjectAddonsResponseAvailableAddonsVariantsId0Ci16xlarge,
72+
"name": "16XL",
73+
"price": map[string]any{},
74+
},
75+
},
76+
},
77+
"available_addons": []map[string]any{},
78+
})
79+
gock.New("https://api.supabase.com").
80+
Patch("/v1/projects/mayuaycdtijbctgqbycg/billing/addons").
81+
Reply(http.StatusOK)
82+
gock.New("https://api.supabase.com").
83+
Get("/v1/projects/mayuaycdtijbctgqbycg").
84+
Reply(http.StatusOK).
85+
JSON(api.V1ProjectResponse{
3186
Id: "mayuaycdtijbctgqbycg",
3287
Name: "foo",
3388
OrganizationId: "continued-brown-smelt",
3489
Region: "us-east-1",
35-
}})
36-
// Step 2: read
90+
})
3791
gock.New("https://api.supabase.com").
38-
Get("/v1/projects").
92+
Get("/v1/projects/mayuaycdtijbctgqbycg/billing/addons").
3993
Reply(http.StatusOK).
40-
JSON([]api.V1ProjectResponse{{
94+
JSON(map[string]any{
95+
"selected_addons": []map[string]any{
96+
{
97+
"type": "compute_instance",
98+
"variant": map[string]any{
99+
"id": api.ListProjectAddonsResponseAvailableAddonsVariantsId0Ci16xlarge,
100+
"name": "16XL",
101+
"price": map[string]any{},
102+
},
103+
},
104+
},
105+
"available_addons": []map[string]any{},
106+
})
107+
// Step 3: import state
108+
gock.New("https://api.supabase.com").
109+
Get("/v1/projects/mayuaycdtijbctgqbycg").
110+
Reply(http.StatusOK).
111+
JSON(api.V1ProjectResponse{
41112
Id: "mayuaycdtijbctgqbycg",
42113
Name: "foo",
43114
OrganizationId: "continued-brown-smelt",
44115
Region: "us-east-1",
45-
}})
46-
// Step 3: delete
116+
})
117+
gock.New("https://api.supabase.com").
118+
Get("/v1/projects/mayuaycdtijbctgqbycg/billing/addons").
119+
Reply(http.StatusOK).
120+
JSON(map[string]any{
121+
"selected_addons": []map[string]any{
122+
{
123+
"type": "compute_instance",
124+
"variant": map[string]any{
125+
"id": api.ListProjectAddonsResponseAvailableAddonsVariantsId0Ci16xlarge,
126+
"name": "16XL",
127+
"price": map[string]any{},
128+
},
129+
},
130+
},
131+
"available_addons": []map[string]any{},
132+
})
133+
// Step 4: delete
47134
gock.New("https://api.supabase.com").
48135
Delete("/v1/projects/mayuaycdtijbctgqbycg").
49136
Reply(http.StatusOK).
@@ -64,12 +151,20 @@ func TestAccProjectResource(t *testing.T) {
64151
resource.TestCheckResourceAttr("supabase_project.test", "id", "mayuaycdtijbctgqbycg"),
65152
),
66153
},
154+
// Update testing
155+
{
156+
Config: strings.ReplaceAll(examples.ProjectResourceConfig, `"micro"`, `"16xlarge"`),
157+
Check: resource.ComposeAggregateTestCheckFunc(
158+
resource.TestCheckResourceAttr("supabase_project.test", "id", "mayuaycdtijbctgqbycg"),
159+
resource.TestCheckResourceAttr("supabase_project.test", "instance_size", "16xlarge"),
160+
),
161+
},
67162
// ImportState testing
68163
{
69164
ResourceName: "supabase_project.test",
70165
ImportState: true,
71166
ImportStateVerify: true,
72-
ImportStateVerifyIgnore: []string{"database_password", "instance_size"},
167+
ImportStateVerifyIgnore: []string{"database_password"},
73168
},
74169
// Delete testing automatically occurs in TestCase
75170
},

0 commit comments

Comments
 (0)