Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resource database #207

Merged
merged 1 commit into from
Feb 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3,429 changes: 2,019 additions & 1,410 deletions pkg/internal/api/client_mock.go

Large diffs are not rendered by default.

72 changes: 72 additions & 0 deletions pkg/internal/api/database.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package api

import (
"context"
"encoding/json"
"fmt"

"github.com/huandu/go-sqlbuilder"

sqlutil "github.com/ClickHouse/terraform-provider-clickhouse/pkg/internal/sql"
)

type Database struct {
Name string `json:"name"`
Comment string `json:"comment"`
}

func (c *ClientImpl) CreateDatabase(ctx context.Context, serviceID string, db Database) error {
format := "CREATE DATABASE `$?`"
args := []interface{}{
sqlbuilder.Raw(sqlutil.EscapeBacktick(db.Name)),
}

if db.Comment != "" {
format = fmt.Sprintf("%s COMMENT ${comment}", format)
args = append(args, sqlbuilder.Named("comment", db.Comment))
}
sb := sqlbuilder.Build(format, args...)

sql, args := sb.Build()

_, err := c.runQuery(ctx, serviceID, sql, args...)
if err != nil {
return err
}

return nil
}

func (c *ClientImpl) GetDatabase(ctx context.Context, serviceID string, name string) (*Database, error) {
sb := sqlbuilder.Build("SELECT name, comment FROM system.databases WHERE name=$?;", name)
sql, args := sb.Build()

body, err := c.runQuery(ctx, serviceID, sql, args...)
if err != nil {
return nil, err
}

database := Database{}
err = json.Unmarshal(body, &database)
if err != nil {
return nil, err
}

return &database, nil
}

func (c *ClientImpl) DeleteDatabase(ctx context.Context, serviceID string, name string) error {
sb := sqlbuilder.Build("DROP DATABASE `$?`;", sqlbuilder.Raw(sqlutil.EscapeBacktick(name)))
sql, args := sb.Build()
_, err := c.runQuery(ctx, serviceID, sql, args...)
if err != nil {
return err
}

return nil
}

func (c *ClientImpl) SyncDatabase(ctx context.Context, serviceID string, db Database) error {
// There is no field in the Database spec that allows changing on the fly, so this function is a no-op.
return nil
}
5 changes: 5 additions & 0 deletions pkg/internal/api/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,9 @@ type Client interface {
GrantPrivilege(ctx context.Context, serviceId string, grantPrivilege GrantPrivilege) (*GrantPrivilege, error)
GetGrantPrivilege(ctx context.Context, serviceID string, accessType string, database string, table *string, column *string, granteeUserName *string, granteeRoleName *string) (*GrantPrivilege, error)
RevokeGrantPrivilege(ctx context.Context, serviceID string, accessType string, database string, table *string, column *string, granteeUserName *string, granteeRoleName *string) error

CreateDatabase(ctx context.Context, serviceID string, db Database) error
GetDatabase(ctx context.Context, serviceID string, name string) (*Database, error)
DeleteDatabase(ctx context.Context, serviceID string, name string) error
SyncDatabase(ctx context.Context, serviceID string, db Database) error
}
248 changes: 248 additions & 0 deletions pkg/resource/database.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
//go:build alpha

package resource

import (
"context"
_ "embed"
"fmt"
"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/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/ClickHouse/terraform-provider-clickhouse/pkg/internal/api"
"github.com/ClickHouse/terraform-provider-clickhouse/pkg/resource/models"

"github.com/hashicorp/terraform-plugin-framework/resource"
)

//go:embed descriptions/database.md
var databaseResourceDescription string

// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &DatabaseResource{}
_ resource.ResourceWithConfigure = &DatabaseResource{}
_ resource.ResourceWithImportState = &DatabaseResource{}
)

// NewDatabaseResource is a helper function to simplify the provider implementation.
func NewDatabaseResource() resource.Resource {
return &DatabaseResource{}
}

// DatabaseResource is the resource implementation.
type DatabaseResource struct {
client api.Client
}

// Metadata returns the resource type name.
func (r *DatabaseResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_database"
}

// Schema defines the schema for the resource.
func (r *DatabaseResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"service_id": schema.StringAttribute{
Description: "ClickHouse Service ID",
Required: true,
},
"name": schema.StringAttribute{
Required: true,
Description: "Name of the database",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"comment": schema.StringAttribute{
Optional: true,
Description: "Comment associated with the database",
Validators: []validator.String{
// If user specifies the comment field, it can't be the empty string otherwise we get an error from terraform
// due to the difference between null and empty string. User can always set this field to null or leave it out completely.
stringvalidator.LengthAtLeast(1),
stringvalidator.LengthAtMost(255),
},
PlanModifiers: []planmodifier.String{
// Changing comment is not implemented: https://github.com/ClickHouse/ClickHouse/issues/73351
stringplanmodifier.RequiresReplace(),
},
},
},
MarkdownDescription: databaseResourceDescription,
}
}

func (r *DatabaseResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}

r.client = req.ProviderData.(api.Client)
}

func (r *DatabaseResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var plan models.DatabaseResourceModel
diags := req.Plan.Get(ctx, &plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}

db, diagnostics := databaseFromPlan(ctx, plan)
if diagnostics.HasError() {
resp.Diagnostics.Append(diagnostics...)
return
}

err := r.client.CreateDatabase(ctx, plan.ServiceID.ValueString(), *db)
if err != nil {
resp.Diagnostics.AddError(
"Error creating database",
"Could not create database, unexpected error: "+err.Error(),
)
return
}

state, diagnostics := r.syncDatabaseState(ctx, plan.ServiceID.ValueString(), plan.Name.ValueString())
if diagnostics.HasError() {
resp.Diagnostics.Append(diagnostics...)
return
}

diags = resp.State.Set(ctx, state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}

func (r *DatabaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var plan models.DatabaseResourceModel
diags := req.State.Get(ctx, &plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}

state, diagnostics := r.syncDatabaseState(ctx, plan.ServiceID.ValueString(), plan.Name.ValueString())
if diagnostics.HasError() {
resp.Diagnostics.Append(diagnostics...)
return
}

diags = resp.State.Set(ctx, state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}

func (r *DatabaseResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var plan models.DatabaseResourceModel
diags := req.Plan.Get(ctx, &plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}

table, diagnostics := databaseFromPlan(ctx, plan)
if diagnostics.HasError() {
resp.Diagnostics.Append(diagnostics.Errors()...)
return
}

err := r.client.SyncDatabase(ctx, plan.ServiceID.ValueString(), *table)
if err != nil {
resp.Diagnostics.AddError(
"Error syncing table",
"Could not sync table, unexpected error: "+err.Error(),
)
return
}

state, diagnostics := r.syncDatabaseState(ctx, plan.ServiceID.ValueString(), plan.Name.ValueString())
if diagnostics.HasError() {
resp.Diagnostics.Append(diagnostics...)
return
}

diags = resp.State.Set(ctx, state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}

func (r *DatabaseResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var plan models.DatabaseResourceModel
diags := req.State.Get(ctx, &plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}

err := r.client.DeleteDatabase(ctx, plan.ServiceID.ValueString(), plan.Name.ValueString())
if err != nil {
resp.Diagnostics.AddError(
"Error deleting database",
"Could not delete database, unexpected error: "+err.Error(),
)
return
}
}

func (r *DatabaseResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
idParts := strings.Split(req.ID, ",")

if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" {
resp.Diagnostics.AddError(
"Unexpected Import Identifier",
fmt.Sprintf("Expected import identifier with format: service_id,database_name. Got: %q", req.ID),
)
return
}

resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("service_id"), idParts[0])...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), idParts[1])...)
}

// syncDatabaseState reads database settings from clickhouse and returns a DatabaseResourceModel
func (r *DatabaseResource) syncDatabaseState(ctx context.Context, serviceID string, dbName string) (*models.DatabaseResourceModel, diag.Diagnostics) {
db, err := r.client.GetDatabase(ctx, serviceID, dbName)
if err != nil {
return nil, []diag.Diagnostic{diag.NewErrorDiagnostic("Error reading database state", err.Error())}
}

comment := types.StringNull()
if db.Comment != "" {
comment = types.StringValue(db.Comment)
}

state := &models.DatabaseResourceModel{
ServiceID: types.StringValue(serviceID),
Name: types.StringValue(db.Name),
Comment: comment,
}

return state, nil
}

// databaseFromPlan takes a terraform plan (DatabaseResourceModel) and creates a Database struct
func databaseFromPlan(ctx context.Context, plan models.DatabaseResourceModel) (*api.Database, diag.Diagnostics) {
db := &api.Database{
Name: plan.Name.ValueString(),
Comment: plan.Comment.ValueString(),
}

return db, nil
}
6 changes: 6 additions & 0 deletions pkg/resource/descriptions/database.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Use the *clickhouse_database* resource to create a database in a ClickHouse cloud *service*.

Known limitations:

- 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!

13 changes: 13 additions & 0 deletions pkg/resource/models/database_resource.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//go:build alpha

package models

import (
"github.com/hashicorp/terraform-plugin-framework/types"
)

type DatabaseResourceModel struct {
ServiceID types.String `tfsdk:"service_id"`
Name types.String `tfsdk:"name"`
Comment types.String `tfsdk:"comment"`
}
1 change: 1 addition & 0 deletions pkg/resource/register_debug.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ func GetResourceFactories() []func() upstreamresource.Resource {
NewServiceResource,
NewPrivateEndpointRegistrationResource,
NewServicePrivateEndpointsAttachmentResource,
NewDatabaseResource,
NewClickPipeResource,
NewUserResource,
NewRoleResource,
Expand Down