Skip to content

Commit bf5fa3b

Browse files
committed
Add alpha resource to manage databases
1 parent 14d910c commit bf5fa3b

File tree

7 files changed

+2364
-1410
lines changed

7 files changed

+2364
-1410
lines changed

pkg/internal/api/client_mock.go

+2,019-1,410
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/internal/api/database.go

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
8+
"github.com/huandu/go-sqlbuilder"
9+
10+
sqlutil "github.com/ClickHouse/terraform-provider-clickhouse/pkg/internal/sql"
11+
)
12+
13+
type Database struct {
14+
Name string `json:"name"`
15+
Comment string `json:"comment"`
16+
}
17+
18+
func (c *ClientImpl) CreateDatabase(ctx context.Context, serviceID string, db Database) error {
19+
format := "CREATE DATABASE `$?`"
20+
args := []interface{}{
21+
sqlbuilder.Raw(sqlutil.EscapeBacktick(db.Name)),
22+
}
23+
24+
if db.Comment != "" {
25+
format = fmt.Sprintf("%s COMMENT ${comment}", format)
26+
args = append(args, sqlbuilder.Named("comment", db.Comment))
27+
}
28+
sb := sqlbuilder.Build(format, args...)
29+
30+
sql, args := sb.Build()
31+
32+
_, err := c.runQuery(ctx, serviceID, sql, args...)
33+
if err != nil {
34+
return err
35+
}
36+
37+
return nil
38+
}
39+
40+
func (c *ClientImpl) GetDatabase(ctx context.Context, serviceID string, name string) (*Database, error) {
41+
sb := sqlbuilder.Build("SELECT name, comment FROM system.databases WHERE name=$?;", name)
42+
sql, args := sb.Build()
43+
44+
body, err := c.runQuery(ctx, serviceID, sql, args...)
45+
if err != nil {
46+
return nil, err
47+
}
48+
49+
database := Database{}
50+
err = json.Unmarshal(body, &database)
51+
if err != nil {
52+
return nil, err
53+
}
54+
55+
return &database, nil
56+
}
57+
58+
func (c *ClientImpl) DeleteDatabase(ctx context.Context, serviceID string, name string) error {
59+
sb := sqlbuilder.Build("DROP DATABASE `$?`;", sqlbuilder.Raw(sqlutil.EscapeBacktick(name)))
60+
sql, args := sb.Build()
61+
_, err := c.runQuery(ctx, serviceID, sql, args...)
62+
if err != nil {
63+
return err
64+
}
65+
66+
return nil
67+
}
68+
69+
func (c *ClientImpl) SyncDatabase(ctx context.Context, serviceID string, db Database) error {
70+
// There is no field in the Database spec that allows changing on the fly, so this function is a no-op.
71+
return nil
72+
}

pkg/internal/api/interface.go

+5
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,9 @@ type Client interface {
4747
GrantPrivilege(ctx context.Context, serviceId string, grantPrivilege GrantPrivilege) (*GrantPrivilege, error)
4848
GetGrantPrivilege(ctx context.Context, serviceID string, accessType string, database string, table *string, column *string, granteeUserName *string, granteeRoleName *string) (*GrantPrivilege, error)
4949
RevokeGrantPrivilege(ctx context.Context, serviceID string, accessType string, database string, table *string, column *string, granteeUserName *string, granteeRoleName *string) error
50+
51+
CreateDatabase(ctx context.Context, serviceID string, db Database) error
52+
GetDatabase(ctx context.Context, serviceID string, name string) (*Database, error)
53+
DeleteDatabase(ctx context.Context, serviceID string, name string) error
54+
SyncDatabase(ctx context.Context, serviceID string, db Database) error
5055
}

pkg/resource/database.go

+248
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
//go:build alpha
2+
3+
package resource
4+
5+
import (
6+
"context"
7+
_ "embed"
8+
"fmt"
9+
"strings"
10+
11+
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
12+
"github.com/hashicorp/terraform-plugin-framework/diag"
13+
"github.com/hashicorp/terraform-plugin-framework/path"
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/schema/validator"
18+
"github.com/hashicorp/terraform-plugin-framework/types"
19+
20+
"github.com/ClickHouse/terraform-provider-clickhouse/pkg/internal/api"
21+
"github.com/ClickHouse/terraform-provider-clickhouse/pkg/resource/models"
22+
23+
"github.com/hashicorp/terraform-plugin-framework/resource"
24+
)
25+
26+
//go:embed descriptions/database.md
27+
var databaseResourceDescription string
28+
29+
// Ensure the implementation satisfies the expected interfaces.
30+
var (
31+
_ resource.Resource = &DatabaseResource{}
32+
_ resource.ResourceWithConfigure = &DatabaseResource{}
33+
_ resource.ResourceWithImportState = &DatabaseResource{}
34+
)
35+
36+
// NewDatabaseResource is a helper function to simplify the provider implementation.
37+
func NewDatabaseResource() resource.Resource {
38+
return &DatabaseResource{}
39+
}
40+
41+
// DatabaseResource is the resource implementation.
42+
type DatabaseResource struct {
43+
client api.Client
44+
}
45+
46+
// Metadata returns the resource type name.
47+
func (r *DatabaseResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
48+
resp.TypeName = req.ProviderTypeName + "_database"
49+
}
50+
51+
// Schema defines the schema for the resource.
52+
func (r *DatabaseResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
53+
resp.Schema = schema.Schema{
54+
Attributes: map[string]schema.Attribute{
55+
"service_id": schema.StringAttribute{
56+
Description: "ClickHouse Service ID",
57+
Required: true,
58+
},
59+
"name": schema.StringAttribute{
60+
Required: true,
61+
Description: "Name of the database",
62+
PlanModifiers: []planmodifier.String{
63+
stringplanmodifier.RequiresReplace(),
64+
},
65+
},
66+
"comment": schema.StringAttribute{
67+
Optional: true,
68+
Description: "Comment associated with the database",
69+
Validators: []validator.String{
70+
// If user specifies the comment field, it can't be the empty string otherwise we get an error from terraform
71+
// due to the difference between null and empty string. User can always set this field to null or leave it out completely.
72+
stringvalidator.LengthAtLeast(1),
73+
stringvalidator.LengthAtMost(255),
74+
},
75+
PlanModifiers: []planmodifier.String{
76+
// Changing comment is not implemented: https://github.com/ClickHouse/ClickHouse/issues/73351
77+
stringplanmodifier.RequiresReplace(),
78+
},
79+
},
80+
},
81+
MarkdownDescription: databaseResourceDescription,
82+
}
83+
}
84+
85+
func (r *DatabaseResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) {
86+
if req.ProviderData == nil {
87+
return
88+
}
89+
90+
r.client = req.ProviderData.(api.Client)
91+
}
92+
93+
func (r *DatabaseResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
94+
var plan models.DatabaseResourceModel
95+
diags := req.Plan.Get(ctx, &plan)
96+
resp.Diagnostics.Append(diags...)
97+
if resp.Diagnostics.HasError() {
98+
return
99+
}
100+
101+
db, diagnostics := databaseFromPlan(ctx, plan)
102+
if diagnostics.HasError() {
103+
resp.Diagnostics.Append(diagnostics...)
104+
return
105+
}
106+
107+
err := r.client.CreateDatabase(ctx, plan.ServiceID.ValueString(), *db)
108+
if err != nil {
109+
resp.Diagnostics.AddError(
110+
"Error creating database",
111+
"Could not create database, unexpected error: "+err.Error(),
112+
)
113+
return
114+
}
115+
116+
state, diagnostics := r.syncDatabaseState(ctx, plan.ServiceID.ValueString(), plan.Name.ValueString())
117+
if diagnostics.HasError() {
118+
resp.Diagnostics.Append(diagnostics...)
119+
return
120+
}
121+
122+
diags = resp.State.Set(ctx, state)
123+
resp.Diagnostics.Append(diags...)
124+
if resp.Diagnostics.HasError() {
125+
return
126+
}
127+
}
128+
129+
func (r *DatabaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
130+
var plan models.DatabaseResourceModel
131+
diags := req.State.Get(ctx, &plan)
132+
resp.Diagnostics.Append(diags...)
133+
if resp.Diagnostics.HasError() {
134+
return
135+
}
136+
137+
state, diagnostics := r.syncDatabaseState(ctx, plan.ServiceID.ValueString(), plan.Name.ValueString())
138+
if diagnostics.HasError() {
139+
resp.Diagnostics.Append(diagnostics...)
140+
return
141+
}
142+
143+
diags = resp.State.Set(ctx, state)
144+
resp.Diagnostics.Append(diags...)
145+
if resp.Diagnostics.HasError() {
146+
return
147+
}
148+
}
149+
150+
func (r *DatabaseResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
151+
var plan models.DatabaseResourceModel
152+
diags := req.Plan.Get(ctx, &plan)
153+
resp.Diagnostics.Append(diags...)
154+
if resp.Diagnostics.HasError() {
155+
return
156+
}
157+
158+
table, diagnostics := databaseFromPlan(ctx, plan)
159+
if diagnostics.HasError() {
160+
resp.Diagnostics.Append(diagnostics.Errors()...)
161+
return
162+
}
163+
164+
err := r.client.SyncDatabase(ctx, plan.ServiceID.ValueString(), *table)
165+
if err != nil {
166+
resp.Diagnostics.AddError(
167+
"Error syncing table",
168+
"Could not sync table, unexpected error: "+err.Error(),
169+
)
170+
return
171+
}
172+
173+
state, diagnostics := r.syncDatabaseState(ctx, plan.ServiceID.ValueString(), plan.Name.ValueString())
174+
if diagnostics.HasError() {
175+
resp.Diagnostics.Append(diagnostics...)
176+
return
177+
}
178+
179+
diags = resp.State.Set(ctx, state)
180+
resp.Diagnostics.Append(diags...)
181+
if resp.Diagnostics.HasError() {
182+
return
183+
}
184+
}
185+
186+
func (r *DatabaseResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
187+
var plan models.DatabaseResourceModel
188+
diags := req.State.Get(ctx, &plan)
189+
resp.Diagnostics.Append(diags...)
190+
if resp.Diagnostics.HasError() {
191+
return
192+
}
193+
194+
err := r.client.DeleteDatabase(ctx, plan.ServiceID.ValueString(), plan.Name.ValueString())
195+
if err != nil {
196+
resp.Diagnostics.AddError(
197+
"Error deleting database",
198+
"Could not delete database, unexpected error: "+err.Error(),
199+
)
200+
return
201+
}
202+
}
203+
204+
func (r *DatabaseResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
205+
idParts := strings.Split(req.ID, ",")
206+
207+
if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" {
208+
resp.Diagnostics.AddError(
209+
"Unexpected Import Identifier",
210+
fmt.Sprintf("Expected import identifier with format: service_id,database_name. Got: %q", req.ID),
211+
)
212+
return
213+
}
214+
215+
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("service_id"), idParts[0])...)
216+
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), idParts[1])...)
217+
}
218+
219+
// syncDatabaseState reads database settings from clickhouse and returns a DatabaseResourceModel
220+
func (r *DatabaseResource) syncDatabaseState(ctx context.Context, serviceID string, dbName string) (*models.DatabaseResourceModel, diag.Diagnostics) {
221+
db, err := r.client.GetDatabase(ctx, serviceID, dbName)
222+
if err != nil {
223+
return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Error reading database state", err.Error())}
224+
}
225+
226+
comment := types.StringNull()
227+
if db.Comment != "" {
228+
comment = types.StringValue(db.Comment)
229+
}
230+
231+
state := &models.DatabaseResourceModel{
232+
ServiceID: types.StringValue(serviceID),
233+
Name: types.StringValue(db.Name),
234+
Comment: comment,
235+
}
236+
237+
return state, nil
238+
}
239+
240+
// databaseFromPlan takes a terraform plan (DatabaseResourceModel) and creates a Database struct
241+
func databaseFromPlan(ctx context.Context, plan models.DatabaseResourceModel) (*api.Database, diag.Diagnostics) {
242+
db := &api.Database{
243+
Name: plan.Name.ValueString(),
244+
Comment: plan.Comment.ValueString(),
245+
}
246+
247+
return db, nil
248+
}

pkg/resource/descriptions/database.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Use the *clickhouse_database* resource to create a database in a ClickHouse cloud *service*.
2+
3+
Known limitations:
4+
5+
- Changing the comment on a `database` resource is unsupported and will cause the database to be destroyed and recreated. WARNING: you will lose any content of the database if you do so!
6+
+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//go:build alpha
2+
3+
package models
4+
5+
import (
6+
"github.com/hashicorp/terraform-plugin-framework/types"
7+
)
8+
9+
type DatabaseResourceModel struct {
10+
ServiceID types.String `tfsdk:"service_id"`
11+
Name types.String `tfsdk:"name"`
12+
Comment types.String `tfsdk:"comment"`
13+
}

pkg/resource/register_debug.go

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ func GetResourceFactories() []func() upstreamresource.Resource {
1111
NewServiceResource,
1212
NewPrivateEndpointRegistrationResource,
1313
NewServicePrivateEndpointsAttachmentResource,
14+
NewDatabaseResource,
1415
NewClickPipeResource,
1516
NewUserResource,
1617
NewRoleResource,

0 commit comments

Comments
 (0)