Skip to content

Commit b703456

Browse files
S96EAowensweatybridge
authored
feat: support create/delete project (#6)
* feat: implement project settings * feat: add some tests * feat: remove plan from create project params * fix: exclude non-errors from project create and delete * chore: regenerate docs --------- Co-authored-by: owen <[email protected]> Co-authored-by: Qiao Han <[email protected]>
1 parent 769b139 commit b703456

File tree

5 files changed

+349
-0
lines changed

5 files changed

+349
-0
lines changed

examples/examples.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import _ "embed"
55
var (
66
//go:embed resources/supabase_settings/resource.tf
77
SettingsResourceConfig string
8+
//go:embed resources/supabase_project/resource.tf
9+
ProjectResourceConfig string
810
//go:embed data-sources/supabase_branch/data-source.tf
911
BranchDataSourceConfig string
1012
)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
resource "supabase_project" "test" {
2+
organization_id = "continued-brown-smelt"
3+
name = "foo"
4+
database_password = "bar"
5+
region = "us-east-1"
6+
}
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package provider
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"net/http"
10+
11+
"github.com/hashicorp/terraform-plugin-framework/diag"
12+
"github.com/hashicorp/terraform-plugin-framework/path"
13+
"github.com/hashicorp/terraform-plugin-framework/resource"
14+
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
15+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
16+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
17+
"github.com/hashicorp/terraform-plugin-framework/types"
18+
"github.com/hashicorp/terraform-plugin-log/tflog"
19+
"github.com/supabase/cli/pkg/api"
20+
)
21+
22+
// Ensure provider defined types fully satisfy framework interfaces.
23+
var _ resource.Resource = &ProjectResource{}
24+
var _ resource.ResourceWithImportState = &ProjectResource{}
25+
26+
func NewProjectResource() resource.Resource {
27+
return &ProjectResource{}
28+
}
29+
30+
// ProjectResource defines the resource implementation.
31+
type ProjectResource struct {
32+
client *api.ClientWithResponses
33+
}
34+
35+
// ProjectResourceModel describes the resource data model.
36+
type ProjectResourceModel struct {
37+
OrganizationId types.String `tfsdk:"organization_id"`
38+
Name types.String `tfsdk:"name"`
39+
DatabasePassword types.String `tfsdk:"database_password"`
40+
Region types.String `tfsdk:"region"`
41+
Id types.String `tfsdk:"id"`
42+
}
43+
44+
func (r *ProjectResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
45+
resp.TypeName = req.ProviderTypeName + "_project"
46+
}
47+
48+
func (r *ProjectResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
49+
resp.Schema = schema.Schema{
50+
MarkdownDescription: "Project resource",
51+
52+
Attributes: map[string]schema.Attribute{
53+
"organization_id": schema.StringAttribute{
54+
MarkdownDescription: "Reference to the organization",
55+
Required: true,
56+
},
57+
"name": schema.StringAttribute{
58+
MarkdownDescription: "Name of the project",
59+
Required: true,
60+
},
61+
"database_password": schema.StringAttribute{
62+
MarkdownDescription: "Password for the project database",
63+
Required: true,
64+
Sensitive: true,
65+
},
66+
"region": schema.StringAttribute{
67+
MarkdownDescription: "Region where the project is located",
68+
Required: true,
69+
},
70+
"id": schema.StringAttribute{
71+
MarkdownDescription: "Project identifier",
72+
Computed: true,
73+
PlanModifiers: []planmodifier.String{
74+
stringplanmodifier.UseStateForUnknown(),
75+
},
76+
},
77+
},
78+
}
79+
}
80+
81+
func (r *ProjectResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
82+
// Prevent panic if the provider has not been configured.
83+
if req.ProviderData == nil {
84+
return
85+
}
86+
87+
client, ok := req.ProviderData.(*api.ClientWithResponses)
88+
89+
if !ok {
90+
resp.Diagnostics.AddError(
91+
"Unexpected Resource Configure Type",
92+
fmt.Sprintf("Expected *http.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData),
93+
)
94+
95+
return
96+
}
97+
98+
r.client = client
99+
}
100+
101+
func (r *ProjectResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
102+
var data ProjectResourceModel
103+
104+
// Read Terraform plan data into the model
105+
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
106+
107+
if resp.Diagnostics.HasError() {
108+
return
109+
}
110+
111+
resp.Diagnostics.Append(createProject(ctx, &data, r.client)...)
112+
113+
if resp.Diagnostics.HasError() {
114+
return
115+
}
116+
117+
tflog.Trace(ctx, "create project")
118+
119+
// Save data into Terraform state
120+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
121+
}
122+
123+
func (r *ProjectResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
124+
var data ProjectResourceModel
125+
126+
// Read Terraform prior state data into the model
127+
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
128+
129+
if resp.Diagnostics.HasError() {
130+
return
131+
}
132+
133+
tflog.Trace(ctx, "read project")
134+
135+
resp.Diagnostics.Append(readProject(ctx, &data, r.client)...)
136+
137+
if resp.Diagnostics.HasError() {
138+
return
139+
}
140+
141+
// Save updated data into Terraform state
142+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
143+
}
144+
145+
func (r *ProjectResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
146+
var data ProjectResourceModel
147+
148+
// Read Terraform plan data into the model
149+
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
150+
151+
if resp.Diagnostics.HasError() {
152+
return
153+
}
154+
155+
msg := fmt.Sprintf("Update is not supported for project resource: %s", data.Id.ValueString())
156+
resp.Diagnostics.Append(diag.NewErrorDiagnostic("Client Error", msg))
157+
}
158+
159+
func (r *ProjectResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
160+
var data ProjectResourceModel
161+
162+
// Read Terraform prior state data into the model
163+
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
164+
165+
if resp.Diagnostics.HasError() {
166+
return
167+
}
168+
169+
resp.Diagnostics.Append(deleteProject(ctx, &data, r.client)...)
170+
171+
if resp.Diagnostics.HasError() {
172+
return
173+
}
174+
175+
tflog.Trace(ctx, "delete project")
176+
177+
// Save data into Terraform state
178+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
179+
}
180+
181+
func (r *ProjectResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
182+
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
183+
}
184+
185+
func createProject(ctx context.Context, data *ProjectResourceModel, client *api.ClientWithResponses) diag.Diagnostics {
186+
httpResp, err := client.CreateProjectWithResponse(ctx, api.CreateProjectJSONRequestBody{
187+
OrganizationId: data.OrganizationId.ValueString(),
188+
Name: data.Name.ValueString(),
189+
DbPass: data.DatabasePassword.ValueString(),
190+
Region: api.CreateProjectBodyRegion(data.Region.ValueString()),
191+
// TODO: the plan field is deprecated, remove after API fix is deployed
192+
Plan: api.CreateProjectBodyPlanFree,
193+
})
194+
195+
if err != nil {
196+
msg := fmt.Sprintf("Unable to create project, got error: %s", err)
197+
return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)}
198+
}
199+
200+
if httpResp.JSON201 == nil {
201+
msg := fmt.Sprintf("Unable to create project, got status %d: %s", httpResp.StatusCode(), httpResp.Body)
202+
return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)}
203+
}
204+
205+
data.Id = types.StringValue(httpResp.JSON201.Id)
206+
return nil
207+
}
208+
209+
func readProject(ctx context.Context, data *ProjectResourceModel, client *api.ClientWithResponses) diag.Diagnostics {
210+
httpResp, err := client.GetProjectsWithResponse(ctx)
211+
212+
if err != nil {
213+
msg := fmt.Sprintf("Unable to read project, got error: %s", err)
214+
return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)}
215+
}
216+
217+
if httpResp.JSON200 == nil {
218+
msg := fmt.Sprintf("Unable to read project, got status %d: %s", httpResp.StatusCode(), httpResp.Body)
219+
return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)}
220+
}
221+
222+
for _, project := range *httpResp.JSON200 {
223+
if project.Id == data.Id.ValueString() {
224+
data.OrganizationId = types.StringValue(project.OrganizationId)
225+
data.Name = types.StringValue(project.Name)
226+
data.Region = types.StringValue(project.Region)
227+
return nil
228+
}
229+
}
230+
231+
// Not finding a project means our local state is stale. Return no error to allow TF to refresh its state.
232+
tflog.Trace(ctx, fmt.Sprintf("project not found: %s", data.Id.ValueString()))
233+
return nil
234+
}
235+
236+
func deleteProject(ctx context.Context, data *ProjectResourceModel, client *api.ClientWithResponses) diag.Diagnostics {
237+
httpResp, err := client.DeleteProjectWithResponse(ctx, data.Id.ValueString())
238+
239+
if err != nil {
240+
msg := fmt.Sprintf("Unable to delete project, got error: %s", err)
241+
return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)}
242+
}
243+
244+
if httpResp.StatusCode() == http.StatusNotFound {
245+
tflog.Trace(ctx, fmt.Sprintf("project not found: %s", data.Id.ValueString()))
246+
return nil
247+
}
248+
249+
if httpResp.JSON200 == nil {
250+
msg := fmt.Sprintf("Unable to delete project, got status %d: %s", httpResp.StatusCode(), httpResp.Body)
251+
return diag.Diagnostics{diag.NewErrorDiagnostic("Client Error", msg)}
252+
}
253+
254+
return nil
255+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package provider
5+
6+
import (
7+
"net/http"
8+
"testing"
9+
10+
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
11+
"github.com/supabase/cli/pkg/api"
12+
"github.com/supabase/terraform-provider-supabase/examples"
13+
"gopkg.in/h2non/gock.v1"
14+
)
15+
16+
func TestAccProjectResource(t *testing.T) {
17+
// Setup mock api
18+
defer gock.OffAll()
19+
// Step 1: create
20+
gock.New("https://api.supabase.com").
21+
Post("/v1/projects").
22+
Reply(http.StatusCreated).
23+
JSON(api.ProjectResponse{
24+
Id: "mayuaycdtijbctgqbycg",
25+
Name: "foo",
26+
})
27+
gock.New("https://api.supabase.com").
28+
Get("/v1/projects").
29+
Reply(http.StatusOK).
30+
JSON(
31+
[]api.ProjectResponse{
32+
{
33+
Id: "mayuaycdtijbctgqbycg",
34+
Name: "foo",
35+
OrganizationId: "continued-brown-smelt",
36+
Region: "us-east-1",
37+
},
38+
},
39+
)
40+
// Step 2: read
41+
gock.New("https://api.supabase.com").
42+
Get("/v1/projects").
43+
Reply(http.StatusOK).
44+
JSON(
45+
[]api.ProjectResponse{
46+
{
47+
Id: "mayuaycdtijbctgqbycg",
48+
Name: "foo",
49+
OrganizationId: "continued-brown-smelt",
50+
Region: "us-east-1",
51+
},
52+
},
53+
)
54+
// Step 3: delete
55+
gock.New("https://api.supabase.com").
56+
Delete("/v1/projects/mayuaycdtijbctgqbycg").
57+
Reply(http.StatusOK).
58+
JSON(api.PostgrestConfigResponse{
59+
DbExtraSearchPath: "public,extensions",
60+
DbSchema: "public,storage,graphql_public",
61+
MaxRows: 1000,
62+
})
63+
// Run test
64+
resource.Test(t, resource.TestCase{
65+
PreCheck: func() { testAccPreCheck(t) },
66+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
67+
Steps: []resource.TestStep{
68+
// Create and Read testing
69+
{
70+
Config: examples.ProjectResourceConfig,
71+
Check: resource.ComposeAggregateTestCheckFunc(
72+
resource.TestCheckResourceAttr("supabase_project.test", "id", "mayuaycdtijbctgqbycg"),
73+
),
74+
},
75+
// ImportState testing
76+
{
77+
ResourceName: "supabase_project.test",
78+
ImportState: true,
79+
ImportStateVerify: true,
80+
ImportStateVerifyIgnore: []string{"database_password"},
81+
},
82+
// Delete testing automatically occurs in TestCase
83+
},
84+
})
85+
}

internal/provider/provider.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ func (p *SupabaseProvider) Configure(ctx context.Context, req provider.Configure
8686

8787
func (p *SupabaseProvider) Resources(ctx context.Context) []func() resource.Resource {
8888
return []func() resource.Resource{
89+
NewProjectResource,
8990
NewSettingsResource,
9091
}
9192
}

0 commit comments

Comments
 (0)