diff --git a/docs/resources/project.md b/docs/resources/project.md index 3f2b525..6d51f3f 100644 --- a/docs/resources/project.md +++ b/docs/resources/project.md @@ -21,10 +21,7 @@ resource "supabase_project" "test" { instance_size = "micro" lifecycle { - ignore_changes = [ - database_password, - instance_size, - ] + ignore_changes = [database_password] } } ``` diff --git a/docs/schema.json b/docs/schema.json index 9b00781..637976e 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -138,7 +138,8 @@ "type": "string", "description": "Desired instance size of the project", "description_kind": "markdown", - "optional": true + "optional": true, + "computed": true }, "name": { "type": "string", diff --git a/examples/resources/supabase_project/resource.tf b/examples/resources/supabase_project/resource.tf index 3759fe1..8e033ec 100644 --- a/examples/resources/supabase_project/resource.tf +++ b/examples/resources/supabase_project/resource.tf @@ -6,9 +6,6 @@ resource "supabase_project" "test" { instance_size = "micro" lifecycle { - ignore_changes = [ - database_password, - instance_size, - ] + ignore_changes = [database_password] } } diff --git a/go.mod b/go.mod index 299bb56..2b25b20 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/hashicorp/terraform-plugin-docs v0.24.0 github.com/hashicorp/terraform-plugin-framework v1.16.1 github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0 + github.com/hashicorp/terraform-plugin-framework-validators v0.19.0 github.com/hashicorp/terraform-plugin-go v0.29.0 github.com/hashicorp/terraform-plugin-log v0.10.0 github.com/hashicorp/terraform-plugin-testing v1.13.3 diff --git a/go.sum b/go.sum index b831f72..9475361 100644 --- a/go.sum +++ b/go.sum @@ -117,6 +117,8 @@ github.com/hashicorp/terraform-plugin-framework v1.16.1 h1:1+zwFm3MEqd/0K3YBB2v9 github.com/hashicorp/terraform-plugin-framework v1.16.1/go.mod h1:0xFOxLy5lRzDTayc4dzK/FakIgBhNf/lC4499R9cV4Y= github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0 h1:SJXL5FfJJm17554Kpt9jFXngdM6fXbnUnZ6iT2IeiYA= github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0/go.mod h1:p0phD0IYhsu9bR4+6OetVvvH59I6LwjXGnTVEr8ox6E= +github.com/hashicorp/terraform-plugin-framework-validators v0.19.0 h1:Zz3iGgzxe/1XBkooZCewS0nJAaCFPFPHdNJd8FgE4Ow= +github.com/hashicorp/terraform-plugin-framework-validators v0.19.0/go.mod h1:GBKTNGbGVJohU03dZ7U8wHqc2zYnMUawgCN+gC0itLc= github.com/hashicorp/terraform-plugin-go v0.29.0 h1:1nXKl/nSpaYIUBU1IG/EsDOX0vv+9JxAltQyDMpq5mU= github.com/hashicorp/terraform-plugin-go v0.29.0/go.mod h1:vYZbIyvxyy0FWSmDHChCqKvI40cFTDGSb3D8D70i9GM= github.com/hashicorp/terraform-plugin-log v0.10.0 h1:eu2kW6/QBVdN4P3Ju2WiB2W3ObjkAsyfBsL3Wh1fj3g= diff --git a/internal/provider/project_resource.go b/internal/provider/project_resource.go index 1d9a495..7a986f7 100644 --- a/internal/provider/project_resource.go +++ b/internal/provider/project_resource.go @@ -7,13 +7,16 @@ import ( "context" "fmt" "net/http" + "strings" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/supabase/cli/pkg/api" @@ -71,6 +74,34 @@ func (r *ProjectResource) Schema(ctx context.Context, req resource.SchemaRequest "instance_size": schema.StringAttribute{ MarkdownDescription: "Desired instance size of the project", Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Validators: []validator.String{ + stringvalidator.OneOf( + string(api.V1CreateProjectBodyDesiredInstanceSizeLarge), + string(api.V1CreateProjectBodyDesiredInstanceSizeMedium), + string(api.V1CreateProjectBodyDesiredInstanceSizeMicro), + string(api.V1CreateProjectBodyDesiredInstanceSizeN12xlarge), + string(api.V1CreateProjectBodyDesiredInstanceSizeN16xlarge), + string(api.V1CreateProjectBodyDesiredInstanceSizeN24xlarge), + string(api.V1CreateProjectBodyDesiredInstanceSizeN24xlargeHighMemory), + string(api.V1CreateProjectBodyDesiredInstanceSizeN24xlargeOptimizedCpu), + string(api.V1CreateProjectBodyDesiredInstanceSizeN24xlargeOptimizedMemory), + string(api.V1CreateProjectBodyDesiredInstanceSizeN2xlarge), + string(api.V1CreateProjectBodyDesiredInstanceSizeN48xlarge), + string(api.V1CreateProjectBodyDesiredInstanceSizeN48xlargeHighMemory), + string(api.V1CreateProjectBodyDesiredInstanceSizeN48xlargeOptimizedCpu), + string(api.V1CreateProjectBodyDesiredInstanceSizeN48xlargeOptimizedMemory), + string(api.V1CreateProjectBodyDesiredInstanceSizeN4xlarge), + string(api.V1CreateProjectBodyDesiredInstanceSizeN8xlarge), + string(api.V1CreateProjectBodyDesiredInstanceSizeNano), + string(api.V1CreateProjectBodyDesiredInstanceSizePico), + string(api.V1CreateProjectBodyDesiredInstanceSizeSmall), + string(api.V1CreateProjectBodyDesiredInstanceSizeXlarge), + ), + }, }, "id": schema.StringAttribute{ MarkdownDescription: "Project identifier", @@ -110,12 +141,17 @@ func (r *ProjectResource) Create(ctx context.Context, req resource.CreateRequest return } + tflog.Trace(ctx, "create project") resp.Diagnostics.Append(createProject(ctx, &data, r.client)...) if resp.Diagnostics.HasError() { return } - tflog.Trace(ctx, "create project") + tflog.Trace(ctx, "read up to date project") + resp.Diagnostics.Append(readProject(ctx, &data, r.client)...) + if resp.Diagnostics.HasError() { + return + } // Save data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) @@ -142,17 +178,41 @@ func (r *ProjectResource) Read(ctx context.Context, req resource.ReadRequest, re } func (r *ProjectResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var data ProjectResourceModel + var plan, state ProjectResourceModel - // Read Terraform plan data into the model - resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } - // TODO: allow api to update project resource - msg := fmt.Sprintf("Update is not supported for project resource: %s", data.Id.ValueString()) - resp.Diagnostics.Append(diag.NewErrorDiagnostic("Client Error", msg)) + // required attributes + if !plan.Name.Equal(state.Name) { + resp.Diagnostics.AddAttributeError(path.Root("name"), "Client Error", "Update is not supported for this attribute") + return + } + if !plan.DatabasePassword.Equal(state.DatabasePassword) { + resp.Diagnostics.AddAttributeError(path.Root("database_password"), "Client Error", "Update is not supported for this attribute") + return + } + if !plan.Region.Equal(state.Region) { + resp.Diagnostics.AddAttributeError(path.Root("region"), "Client Error", "Update is not supported for this attribute") + return + } + if !plan.OrganizationId.Equal(state.OrganizationId) { + resp.Diagnostics.AddAttributeError(path.Root("organization_id"), "Client Error", "Update is not supported for this attribute") + return + } + + // optional attributes + if !plan.InstanceSize.IsNull() && !plan.InstanceSize.Equal(state.InstanceSize) { + resp.Diagnostics.Append(updateInstanceSize(ctx, &plan, r.client)...) + } + + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) } func (r *ProjectResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { @@ -198,7 +258,7 @@ func createProject(ctx context.Context, data *ProjectResourceModel, client *api. DbPass: data.DatabasePassword.ValueString(), RegionSelection: ®ion, } - if !data.InstanceSize.IsNull() { + if !data.InstanceSize.IsUnknown() && !data.InstanceSize.IsNull() { body.DesiredInstanceSize = Ptr(api.V1CreateProjectBodyDesiredInstanceSize(data.InstanceSize.ValueString())) } @@ -218,28 +278,53 @@ func createProject(ctx context.Context, data *ProjectResourceModel, client *api. } func readProject(ctx context.Context, data *ProjectResourceModel, client *api.ClientWithResponses) diag.Diagnostics { - httpResp, err := client.V1ListAllProjectsWithResponse(ctx) + projectResp, err := client.V1GetProjectWithResponse(ctx, data.Id.ValueString()) if err != nil { msg := fmt.Sprintf("Unable to read project, got error: %s", err) return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)} } - if httpResp.JSON200 == nil { - msg := fmt.Sprintf("Unable to read project, got status %d: %s", httpResp.StatusCode(), httpResp.Body) + if projectResp.StatusCode() == http.StatusNotFound { + return nil + } + + if projectResp.JSON200 == nil { + msg := fmt.Sprintf("Unable to read project, got status %d: %s", projectResp.StatusCode(), projectResp.Body) + return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)} + } + + project := projectResp.JSON200 + data.OrganizationId = types.StringValue(project.OrganizationId) + data.Name = types.StringValue(project.Name) + data.Region = types.StringValue(project.Region) + data.InstanceSize = types.StringNull() + + addonsResp, err := client.V1ListProjectAddonsWithResponse(ctx, project.Id) + if err != nil { + msg := fmt.Sprintf("Unable to read project addons, got error: %s", err) return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)} } - for _, project := range *httpResp.JSON200 { - if project.Id == data.Id.ValueString() { - data.OrganizationId = types.StringValue(project.OrganizationId) - data.Name = types.StringValue(project.Name) - data.Region = types.StringValue(project.Region) - return nil + if addonsResp.JSON200 == nil { + msg := fmt.Sprintf("Unable to read project addons, got error: %s", string(addonsResp.Body)) + return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)} + } + + for _, addon := range addonsResp.JSON200.SelectedAddons { + if addon.Type != api.ComputeInstance { + continue + } + + val, err := addon.Variant.Id.AsListProjectAddonsResponseSelectedAddonsVariantId0() + if err != nil { + msg := fmt.Sprintf("Unable to read compute instance addon, got error: %s", err) + return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)} } + + data.InstanceSize = types.StringValue(strings.TrimPrefix(string(val), "ci_")) + break } - // Not finding a project means our local state is stale. Return no error to allow TF to refresh its state. - tflog.Trace(ctx, fmt.Sprintf("project not found: %s", data.Id.ValueString())) return nil } @@ -262,3 +347,31 @@ func deleteProject(ctx context.Context, data *ProjectResourceModel, client *api. return nil } + +func updateInstanceSize(ctx context.Context, plan *ProjectResourceModel, client *api.ClientWithResponses) diag.Diagnostics { + addon := api.ApplyProjectAddonBody_AddonVariant{} + variant := api.ApplyProjectAddonBodyAddonVariant0("ci_" + plan.InstanceSize.ValueString()) + if err := addon.FromApplyProjectAddonBodyAddonVariant0(variant); err != nil { + return diag.Diagnostics{diag.NewErrorDiagnostic( + "Internal Error", + fmt.Sprintf("Failed to configure instance size: %s", err), + )} + } + body := api.V1ApplyProjectAddonJSONRequestBody{ + AddonType: api.ApplyProjectAddonBodyAddonTypeComputeInstance, + AddonVariant: addon, + } + + httpResp, err := client.V1ApplyProjectAddonWithResponse(ctx, plan.Id.ValueString(), body) + if err != nil { + msg := fmt.Sprintf("Unable to update project, got error: %s", err) + return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)} + } + + if httpResp.StatusCode() != http.StatusOK { + msg := fmt.Sprintf("Unable to update project, got error: %s", string(httpResp.Body)) + return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)} + } + + return nil +} diff --git a/internal/provider/project_resource_test.go b/internal/provider/project_resource_test.go index a9df953..c5e5fe5 100644 --- a/internal/provider/project_resource_test.go +++ b/internal/provider/project_resource_test.go @@ -5,6 +5,7 @@ package provider import ( "net/http" + "strings" "testing" "github.com/hashicorp/terraform-plugin-testing/helper/resource" @@ -25,25 +26,136 @@ func TestAccProjectResource(t *testing.T) { Name: "foo", }) gock.New("https://api.supabase.com"). - Get("/v1/projects"). + Get("/v1/projects/mayuaycdtijbctgqbycg"). Reply(http.StatusOK). - JSON([]api.V1ProjectResponse{{ + JSON(api.V1ProjectResponse{ + Id: "mayuaycdtijbctgqbycg", + Name: "foo", + OrganizationId: "continued-brown-smelt", + Region: "us-east-1", + }) + gock.New("https://api.supabase.com"). + Get("/v1/projects/mayuaycdtijbctgqbycg/billing/addons"). + Reply(http.StatusOK). + JSON(map[string]any{ + "selected_addons": []map[string]any{ + { + "type": "compute_instance", + "variant": map[string]any{ + "id": api.ListProjectAddonsResponseAvailableAddonsVariantsId0CiMicro, + "name": "Micro", + "price": map[string]any{}, + }, + }, + }, + "available_addons": []map[string]any{}, + }) + gock.New("https://api.supabase.com"). + Get("/v1/projects/mayuaycdtijbctgqbycg"). + Reply(http.StatusOK). + JSON(api.V1ProjectResponse{ + Id: "mayuaycdtijbctgqbycg", + Name: "foo", + OrganizationId: "continued-brown-smelt", + Region: "us-east-1", + }) + gock.New("https://api.supabase.com"). + Get("/v1/projects/mayuaycdtijbctgqbycg/billing/addons"). + Reply(http.StatusOK). + JSON(map[string]any{ + "selected_addons": []map[string]any{ + { + "type": "compute_instance", + "variant": map[string]any{ + "id": api.ListProjectAddonsResponseAvailableAddonsVariantsId0CiMicro, + "name": "Micro", + "price": map[string]any{}, + }, + }, + }, + "available_addons": []map[string]any{}, + }) + // Step 2: update + gock.New("https://api.supabase.com"). + Get("/v1/projects/mayuaycdtijbctgqbycg"). + Reply(http.StatusOK). + JSON(api.V1ProjectResponse{ + Id: "mayuaycdtijbctgqbycg", + Name: "foo", + OrganizationId: "continued-brown-smelt", + Region: "us-east-1", + }) + gock.New("https://api.supabase.com"). + Get("/v1/projects/mayuaycdtijbctgqbycg/billing/addons"). + Reply(http.StatusOK). + JSON(map[string]any{ + "selected_addons": []map[string]any{ + { + "type": "compute_instance", + "variant": map[string]any{ + "id": api.ListProjectAddonsResponseAvailableAddonsVariantsId0Ci16xlarge, + "name": "16XL", + "price": map[string]any{}, + }, + }, + }, + "available_addons": []map[string]any{}, + }) + gock.New("https://api.supabase.com"). + Patch("/v1/projects/mayuaycdtijbctgqbycg/billing/addons"). + Reply(http.StatusOK) + gock.New("https://api.supabase.com"). + Get("/v1/projects/mayuaycdtijbctgqbycg"). + Reply(http.StatusOK). + JSON(api.V1ProjectResponse{ Id: "mayuaycdtijbctgqbycg", Name: "foo", OrganizationId: "continued-brown-smelt", Region: "us-east-1", - }}) - // Step 2: read + }) gock.New("https://api.supabase.com"). - Get("/v1/projects"). + Get("/v1/projects/mayuaycdtijbctgqbycg/billing/addons"). Reply(http.StatusOK). - JSON([]api.V1ProjectResponse{{ + JSON(map[string]any{ + "selected_addons": []map[string]any{ + { + "type": "compute_instance", + "variant": map[string]any{ + "id": api.ListProjectAddonsResponseAvailableAddonsVariantsId0Ci16xlarge, + "name": "16XL", + "price": map[string]any{}, + }, + }, + }, + "available_addons": []map[string]any{}, + }) + // Step 3: import state + gock.New("https://api.supabase.com"). + Get("/v1/projects/mayuaycdtijbctgqbycg"). + Reply(http.StatusOK). + JSON(api.V1ProjectResponse{ Id: "mayuaycdtijbctgqbycg", Name: "foo", OrganizationId: "continued-brown-smelt", Region: "us-east-1", - }}) - // Step 3: delete + }) + gock.New("https://api.supabase.com"). + Get("/v1/projects/mayuaycdtijbctgqbycg/billing/addons"). + Reply(http.StatusOK). + JSON(map[string]any{ + "selected_addons": []map[string]any{ + { + "type": "compute_instance", + "variant": map[string]any{ + "id": api.ListProjectAddonsResponseAvailableAddonsVariantsId0Ci16xlarge, + "name": "16XL", + "price": map[string]any{}, + }, + }, + }, + "available_addons": []map[string]any{}, + }) + // Step 4: delete gock.New("https://api.supabase.com"). Delete("/v1/projects/mayuaycdtijbctgqbycg"). Reply(http.StatusOK). @@ -64,12 +176,20 @@ func TestAccProjectResource(t *testing.T) { resource.TestCheckResourceAttr("supabase_project.test", "id", "mayuaycdtijbctgqbycg"), ), }, + // Update testing + { + Config: strings.ReplaceAll(examples.ProjectResourceConfig, `"micro"`, `"16xlarge"`), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("supabase_project.test", "id", "mayuaycdtijbctgqbycg"), + resource.TestCheckResourceAttr("supabase_project.test", "instance_size", "16xlarge"), + ), + }, // ImportState testing { ResourceName: "supabase_project.test", ImportState: true, ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"database_password", "instance_size"}, + ImportStateVerifyIgnore: []string{"database_password"}, }, // Delete testing automatically occurs in TestCase },