diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index bf27119d2..b0fe04ea3 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -46,7 +46,7 @@ jobs: echo "LINODE_TOKEN=${{ secrets.LINODE_TOKEN_USER_1 }}" >> $GITHUB_ENV ;; "USER_2") - echo "TEST_TAGS=firewall,firewalldevice,firewalls,image,images,instancenetworking,instancesharedips,instancetype,instancetypes,ipv6range,ipv6ranges,kernel,kernels,nb,nbconfig,nbconfigs,nbnode,nbs,sshkey,sshkeys,vlan,volume,volumes,vpc,vpcs,vpcsubnets" >> $GITHUB_ENV + echo "TEST_TAGS=databasemysqlv2,firewall,firewalldevice,firewalls,image,images,instancenetworking,instancesharedips,instancetype,instancetypes,ipv6range,ipv6ranges,kernel,kernels,nb,nbconfig,nbconfigs,nbnode,nbs,sshkey,sshkeys,vlan,volume,volumes,vpc,vpcs,vpcsubnets" >> $GITHUB_ENV echo "LINODE_TOKEN=${{ secrets.LINODE_TOKEN_USER_2 }}" >> $GITHUB_ENV ;; "USER_3") diff --git a/docs/resources/database_mysql_v2.md b/docs/resources/database_mysql_v2.md new file mode 100644 index 000000000..2004d5c27 --- /dev/null +++ b/docs/resources/database_mysql_v2.md @@ -0,0 +1,164 @@ +--- +page_title: "Linode: linode_database_mysql_v2" +description: |- + Manages a Linode MySQL Database. +--- + +# linode\_database\_mysql\_v2 + +Provides a Linode MySQL Database resource. This can be used to create, modify, and delete Linode MySQL Databases. +For more information, see the [Linode APIv4 docs](https://techdocs.akamai.com/linode-api/reference/post-databases-mysql-instances). + +Please keep in mind that Managed Databases can take up to half an hour to provision. + +## Example Usage + +Creating a simple MySQL database that does not allow connections: + +```hcl +resource "linode_database_mysql_v2" "foobar" { + label = "mydatabase" + engine_id = "mysql/8" + region = "us-mia" + type = "g6-nanode-1" +} +``` + +Creating a simple MySQL database that allows connections from all IPv4 addresses: + +```hcl +resource "linode_database_mysql_v2" "foobar" { + label = "mydatabase" + engine_id = "mysql/8" + region = "us-mia" + type = "g6-nanode-1" + + allowed_ips = ["0.0.0.0/0"] +} +``` + +Creating a complex MySQL database: + +```hcl +resource "linode_database_mysql_v2" "foobar" { + label = "mydatabase" + engine_id = "mysql/8" + region = "us-mia" + type = "g6-nanode-1" + + allow_list = ["10.0.0.3/32"] + cluster_size = 3 + + updates = { + duration = 4 + frequency = "weekly" + hour_of_day = 22 + day_of_week = 3 + } +} +``` + +Creating a forked MySQL database: + +```hcl +resource "linode_database_mysql_v2" "foobar" { + label = "mydatabase" + engine_id = "mysql/8" + region = "us-mia" + type = "g6-nanode-1" + + fork_source = 12345 +} +``` + +## Argument Reference + +The following arguments are supported: + +* `engine_id` - (Required) The Managed Database engine in engine/version format. (e.g. `mysql`) + +* `label` - (Required) A unique, user-defined string referring to the Managed Database. + +* `region` - (Required) The region to use for the Managed Database. + +* `type` - (Required) The Linode Instance type used for the nodes of the Managed Database. + +- - - + +* `allow_list` - (Optional) A list of IP addresses that can access the Managed Database. Each item can be a single IP address or a range in CIDR format. Use `linode_database_access_controls` to manage your allow list separately. + +* `cluster_size` - (Optional) The number of Linode Instance nodes deployed to the Managed Database. (default `1`) + +* `fork_restore_time` - (Optional) The database timestamp from which it was restored. + +* `fork_source` - (Optional) The ID of the database that was forked from. + +* [`updates`](#updates) - (Optional) Configuration settings for automated patch update maintenance for the Managed Database. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `id` - The ID of the Managed Database. + +* `ca_cert` - The base64-encoded SSL CA certificate for the Managed Database. + +* `created` - When this Managed Database was created. + +* `encrypted` - Whether the Managed Databases is encrypted. + +* `engine` - The Managed Database engine. (e.g. `mysql`) + +* `host_primary` - The primary host for the Managed Database. + +* `host_secondary` - The secondary/private host for the managed database. + +* `pending_updates` - A set of pending updates. + +* `platform` - The back-end platform for relational databases used by the service. + +* `port` - The access port for this Managed Database. + +* `root_password` - The randomly-generated root password for the Managed Database instance. + +* `root_username` - The root username for the Managed Database instance. + +* `ssl_connection` - Whether to require SSL credentials to establish a connection to the Managed Database. + +* `status` - The operating status of the Managed Database. + +* `updated` - When this Managed Database was last updated. + +* `version` - The Managed Database engine version. (e.g. `13.2`) + +## pending_updates + +The following arguments are exposed by each entry in the `pending_updates` attribute: + +* `deadline` - The time when a mandatory update needs to be applied. + +* `description` - A description of the update. + +* `planned_for` - The date and time a maintenance update will be applied. + +## updates + +The following arguments are supported in the `updates` specification block: + +* `day_of_week` - (Required) The day to perform maintenance. (`monday`, `tuesday`, ...) + +* `duration` - (Required) The maximum maintenance window time in hours. (`1`..`3`) + +* `frequency` - (Required) Whether maintenance occurs on a weekly or monthly basis. (`weekly`, `monthly`) + +* `hour_of_day` - (Required) The hour to begin maintenance based in UTC time. (`0`..`23`) + +* `week_of_month` - (Optional) The week of the month to perform monthly frequency updates. Required for `monthly` frequency updates. (`1`..`4`) + +## Import + +Linode MySQL Databases can be imported using the `id`, e.g. + +```sh +terraform import linode_database_mysql_v2.foobar 1234567 +``` diff --git a/linode/databaseaccesscontrols/resource_test.go b/linode/databaseaccesscontrols/resource_test.go index 2e11f0a91..73c058559 100644 --- a/linode/databaseaccesscontrols/resource_test.go +++ b/linode/databaseaccesscontrols/resource_test.go @@ -53,7 +53,6 @@ func init() { } func TestAccResourceDatabaseAccessControls_MySQL(t *testing.T) { - acceptance.LongRunningTest(t) t.Parallel() resName := "linode_database_access_controls.foobar" diff --git a/linode/databaseaccesscontrols/tmpl/mysql.gotf b/linode/databaseaccesscontrols/tmpl/mysql.gotf index 472ca0038..035e6410f 100644 --- a/linode/databaseaccesscontrols/tmpl/mysql.gotf +++ b/linode/databaseaccesscontrols/tmpl/mysql.gotf @@ -1,6 +1,6 @@ {{ define "database_access_controls_mysql" }} -resource "linode_database_mysql" "foobar" { +resource "linode_database_mysql_v2" "foobar" { engine_id = "{{.Engine}}" label = "{{.Label}}" region = "{{ .Region }}" @@ -8,7 +8,7 @@ resource "linode_database_mysql" "foobar" { } resource "linode_database_access_controls" "foobar" { - database_id = linode_database_mysql.foobar.id + database_id = linode_database_mysql_v2.foobar.id database_type = "mysql" allow_list = ["{{.AllowedIP}}"] diff --git a/linode/databasemysqlv2/framework_models.go b/linode/databasemysqlv2/framework_models.go new file mode 100644 index 000000000..79904e758 --- /dev/null +++ b/linode/databasemysqlv2/framework_models.go @@ -0,0 +1,342 @@ +package databasemysqlv2 + +import ( + "context" + "strconv" + + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v2/linode/helper" +) + +type ModelHosts struct { + Primary types.String `tfsdk:"primary"` + Secondary types.String `tfsdk:"secondary"` +} + +type ModelUpdates struct { + DayOfWeek types.Int64 `tfsdk:"day_of_week"` + Duration types.Int64 `tfsdk:"duration"` + Frequency types.String `tfsdk:"frequency"` + HourOfDay types.Int64 `tfsdk:"hour_of_day"` +} + +func (m ModelUpdates) ToLinodego(d diag.Diagnostics) *linodego.DatabaseMaintenanceWindow { + return &linodego.DatabaseMaintenanceWindow{ + DayOfWeek: linodego.DatabaseDayOfWeek(helper.FrameworkSafeInt64ToInt(m.DayOfWeek.ValueInt64(), &d)), + Duration: helper.FrameworkSafeInt64ToInt(m.Duration.ValueInt64(), &d), + Frequency: linodego.DatabaseMaintenanceFrequency(m.Frequency.ValueString()), + HourOfDay: helper.FrameworkSafeInt64ToInt(m.HourOfDay.ValueInt64(), &d), + } +} + +type ModelPendingUpdate struct { + Deadline timetypes.RFC3339 `tfsdk:"deadline"` + Description types.String `tfsdk:"description"` + PlannedFor timetypes.RFC3339 `tfsdk:"planned_for"` +} + +type Model struct { + Timeouts timeouts.Value `tfsdk:"timeouts"` + + ID types.String `tfsdk:"id"` + + AllowList types.Set `tfsdk:"allow_list"` + CACert types.String `tfsdk:"ca_cert"` + ClusterSize types.Int64 `tfsdk:"cluster_size"` + Created timetypes.RFC3339 `tfsdk:"created"` + Encrypted types.Bool `tfsdk:"encrypted"` + Engine types.String `tfsdk:"engine"` + EngineID types.String `tfsdk:"engine_id"` + HostPrimary types.String `tfsdk:"host_primary"` + HostSecondary types.String `tfsdk:"host_secondary"` + Label types.String `tfsdk:"label"` + Members types.Map `tfsdk:"members"` + Platform types.String `tfsdk:"platform"` + Port types.Int64 `tfsdk:"port"` + Region types.String `tfsdk:"region"` + RootPassword types.String `tfsdk:"root_password"` + RootUsername types.String `tfsdk:"root_username"` + SSLConnection types.Bool `tfsdk:"ssl_connection"` + Status types.String `tfsdk:"status"` + Type types.String `tfsdk:"type"` + Updated timetypes.RFC3339 `tfsdk:"updated"` + Version types.String `tfsdk:"version"` + + // Fork-specific fields + OldestRestoreTime timetypes.RFC3339 `tfsdk:"oldest_restore_time"` + ForkSource types.Int64 `tfsdk:"fork_source"` + ForkRestoreTime timetypes.RFC3339 `tfsdk:"fork_restore_time"` + + Updates types.Object `tfsdk:"updates"` + PendingUpdates types.Set `tfsdk:"pending_updates"` +} + +func (m *Model) Refresh( + ctx context.Context, + client *linodego.Client, + dbID int, + preserveKnown bool, +) (d diag.Diagnostics) { + tflog.SetField(ctx, "id", dbID) + + tflog.Debug(ctx, "Refreshing the MySQL database...") + + tflog.Debug(ctx, "client.GetMySQLDatabase(...)") + db, err := client.GetMySQLDatabase(ctx, dbID) + if err != nil { + d.AddError("Failed to refresh MySQL database", err.Error()) + return + } + + tflog.Debug(ctx, "client.GetMySQLDatabaseSSL(...)") + dbSSL, err := client.GetMySQLDatabaseSSL(ctx, dbID) + if err != nil { + d.AddError("Failed to refresh MySQL database SSL", err.Error()) + return + } + + tflog.Debug(ctx, "client.GetMySQLDatabaseCredentials(...)") + dbCreds, err := client.GetMySQLDatabaseCredentials(ctx, dbID) + if err != nil { + d.AddError("Failed to refresh MySQL database credentials", err.Error()) + return + } + + m.Flatten(ctx, db, dbSSL, dbCreds, preserveKnown) + return +} + +func (m *Model) Flatten( + ctx context.Context, + db *linodego.MySQLDatabase, + ssl *linodego.MySQLDatabaseSSL, + creds *linodego.MySQLDatabaseCredential, + preserveKnown bool, +) (d diag.Diagnostics) { + m.ID = helper.KeepOrUpdateString(m.ID, strconv.Itoa(db.ID), preserveKnown) + + m.CACert = helper.KeepOrUpdateString(m.CACert, string(ssl.CACertificate), preserveKnown) + m.ClusterSize = helper.KeepOrUpdateInt64(m.ClusterSize, int64(db.ClusterSize), preserveKnown) + m.Created = helper.KeepOrUpdateValue(m.Created, timetypes.NewRFC3339TimePointerValue(db.Created), preserveKnown) + m.Encrypted = helper.KeepOrUpdateBool(m.Encrypted, db.Encrypted, preserveKnown) + m.Engine = helper.KeepOrUpdateString(m.Engine, db.Engine, preserveKnown) + m.EngineID = helper.KeepOrUpdateString( + m.EngineID, + helper.CreateDatabaseEngineSlug(db.Engine, db.Version), + preserveKnown, + ) + m.HostPrimary = helper.KeepOrUpdateString(m.HostPrimary, db.Hosts.Primary, preserveKnown) + m.HostSecondary = helper.KeepOrUpdateString(m.HostSecondary, db.Hosts.Secondary, preserveKnown) + m.Label = helper.KeepOrUpdateString(m.Label, db.Label, preserveKnown) + m.OldestRestoreTime = helper.KeepOrUpdateValue(m.OldestRestoreTime, timetypes.NewRFC3339TimePointerValue(db.OldestRestoreTime), preserveKnown) + m.Platform = helper.KeepOrUpdateString(m.Platform, string(db.Platform), preserveKnown) + + // TODO + m.Port = helper.KeepOrUpdateInt64(m.Port, int64(0), preserveKnown) + + m.Region = helper.KeepOrUpdateString(m.Region, db.Region, preserveKnown) + m.RootPassword = helper.KeepOrUpdateString(m.RootPassword, creds.Password, preserveKnown) + m.RootUsername = helper.KeepOrUpdateString(m.RootUsername, creds.Username, preserveKnown) + m.SSLConnection = helper.KeepOrUpdateBool(m.SSLConnection, db.SSLConnection, preserveKnown) + m.Status = helper.KeepOrUpdateString(m.Status, string(db.Status), preserveKnown) + m.Type = helper.KeepOrUpdateString(m.Type, db.Type, preserveKnown) + m.Updated = helper.KeepOrUpdateValue(m.Updated, timetypes.NewRFC3339TimePointerValue(db.Updated), preserveKnown) + m.Version = helper.KeepOrUpdateString(m.Version, db.Version, preserveKnown) + + m.AllowList = helper.KeepOrUpdateSet( + types.StringType, + m.AllowList, + helper.StringSliceToFrameworkValueSlice(db.AllowList), + preserveKnown, + &d, + ) + if d.HasError() { + return + } + + membersCasted := helper.MapMap( + db.Members, + func(key string, value linodego.DatabaseMemberType) (string, string) { + return key, string(value) + }, + ) + + m.Members = helper.KeepOrUpdateStringMap(ctx, m.Members, membersCasted, preserveKnown, &d) + if d.HasError() { + return + } + + if db.Fork != nil { + m.ForkSource = helper.KeepOrUpdateInt64( + m.ForkSource, + int64(db.Fork.Source), + preserveKnown, + ) + + m.ForkRestoreTime = helper.KeepOrUpdateValue( + m.ForkRestoreTime, + timetypes.NewRFC3339TimePointerValue(db.Fork.RestoreTime), + preserveKnown, + ) + + } else { + m.ForkSource = helper.KeepOrUpdateValue( + m.ForkSource, + types.Int64Null(), + preserveKnown, + ) + + m.ForkRestoreTime = helper.KeepOrUpdateValue( + m.ForkRestoreTime, + timetypes.NewRFC3339Null(), + preserveKnown, + ) + } + + updatesObject, rd := types.ObjectValueFrom( + ctx, + updatesAttributes, + &ModelUpdates{ + DayOfWeek: types.Int64Value(int64(db.Updates.DayOfWeek)), + Duration: types.Int64Value(int64(db.Updates.Duration)), + Frequency: types.StringValue(string(db.Updates.Frequency)), + HourOfDay: types.Int64Value(int64(db.Updates.HourOfDay)), + }, + ) + d.Append(rd...) + m.Updates = helper.KeepOrUpdateValue(m.Updates, updatesObject, preserveKnown) + + pendingObjects := helper.MapSlice( + db.Updates.Pending, + func(pending linodego.DatabaseMaintenanceWindowPending) types.Object { + result, rd := types.ObjectValueFrom( + ctx, + pendingUpdateAttributes, + &ModelPendingUpdate{ + Deadline: timetypes.NewRFC3339TimePointerValue(pending.Deadline), + Description: types.StringValue(pending.Description), + PlannedFor: timetypes.NewRFC3339TimePointerValue(pending.PlannedFor), + }, + ) + d.Append(rd...) + + return result + }, + ) + + pendingSet, rd := types.SetValueFrom( + ctx, + types.ObjectType{ + AttrTypes: pendingUpdateAttributes, + }, + pendingObjects, + ) + d.Append(rd...) + + m.PendingUpdates = helper.KeepOrUpdateValue(m.PendingUpdates, pendingSet, preserveKnown) + + return nil +} + +func (m *Model) CopyFrom(other *Model, preserveKnown bool) { + m.ForkSource = helper.KeepOrUpdateValue(m.ForkSource, other.ForkSource, preserveKnown) + m.ID = helper.KeepOrUpdateValue(m.ID, other.ID, preserveKnown) + + m.AllowList = helper.KeepOrUpdateValue(m.AllowList, other.AllowList, preserveKnown) + m.CACert = helper.KeepOrUpdateValue(m.CACert, other.CACert, preserveKnown) + m.ClusterSize = helper.KeepOrUpdateValue(m.ClusterSize, other.ClusterSize, preserveKnown) + m.Created = helper.KeepOrUpdateValue(m.Created, other.Created, preserveKnown) + m.Encrypted = helper.KeepOrUpdateValue(m.Encrypted, other.Encrypted, preserveKnown) + m.Engine = helper.KeepOrUpdateValue(m.Engine, other.Engine, preserveKnown) + m.EngineID = helper.KeepOrUpdateValue(m.EngineID, other.EngineID, preserveKnown) + m.ForkRestoreTime = helper.KeepOrUpdateValue(m.ForkRestoreTime, other.ForkRestoreTime, preserveKnown) + m.HostPrimary = helper.KeepOrUpdateValue(m.HostPrimary, other.HostPrimary, preserveKnown) + m.HostSecondary = helper.KeepOrUpdateValue(m.HostSecondary, other.HostSecondary, preserveKnown) + m.Label = helper.KeepOrUpdateValue(m.Label, other.Label, preserveKnown) + m.Members = helper.KeepOrUpdateValue(m.Members, other.Members, preserveKnown) + m.OldestRestoreTime = helper.KeepOrUpdateValue(m.OldestRestoreTime, other.OldestRestoreTime, preserveKnown) + m.PendingUpdates = helper.KeepOrUpdateValue(m.PendingUpdates, other.PendingUpdates, preserveKnown) + m.Platform = helper.KeepOrUpdateValue(m.Platform, other.Platform, preserveKnown) + m.Port = helper.KeepOrUpdateValue(m.Port, other.Port, preserveKnown) + m.Region = helper.KeepOrUpdateValue(m.Region, other.Region, preserveKnown) + m.RootPassword = helper.KeepOrUpdateValue(m.RootPassword, other.RootPassword, preserveKnown) + m.RootUsername = helper.KeepOrUpdateValue(m.RootUsername, other.RootUsername, preserveKnown) + m.SSLConnection = helper.KeepOrUpdateValue(m.SSLConnection, other.SSLConnection, preserveKnown) + m.Status = helper.KeepOrUpdateValue(m.Status, other.Status, preserveKnown) + m.Type = helper.KeepOrUpdateValue(m.Type, other.Type, preserveKnown) + m.Updated = helper.KeepOrUpdateValue(m.Updated, other.Updated, preserveKnown) + m.Updates = helper.KeepOrUpdateValue(m.Updates, other.Updates, preserveKnown) + m.Version = helper.KeepOrUpdateValue(m.Version, other.Version, preserveKnown) +} + +// GetFork returns the linodego.DatabaseFork for this model if specified, else nil. +func (m *Model) GetFork(d diag.Diagnostics) *linodego.DatabaseFork { + var result linodego.DatabaseFork + + isSpecified := false + + if !m.ForkSource.IsUnknown() && !m.ForkSource.IsNull() { + isSpecified = true + + result.Source = helper.FrameworkSafeInt64ToInt(m.ForkSource.ValueInt64(), &d) + } + + if !m.ForkRestoreTime.IsUnknown() && !m.ForkRestoreTime.IsNull() { + isSpecified = true + + restoreTime, rd := m.ForkRestoreTime.ValueRFC3339Time() + d.Append(rd...) + + result.RestoreTime = &restoreTime + } + + if d.HasError() || !isSpecified { + return nil + } + + return &result +} + +// GetAllowList returns the allow list slice for this model if specified, else nil. +func (m *Model) GetAllowList(ctx context.Context, d diag.Diagnostics) []string { + if m.AllowList.IsUnknown() || m.AllowList.IsNull() { + return nil + } + + var result []string + + d.Append( + m.AllowList.ElementsAs( + ctx, + &result, + false, + )..., + ) + + return result +} + +// GetUpdates returns the ModelUpdates for this model if specified, else nil. +func (m *Model) GetUpdates(ctx context.Context, d diag.Diagnostics) *ModelUpdates { + if m.Updates.IsUnknown() || m.Updates.IsNull() { + return nil + } + + var result ModelUpdates + + d.Append( + m.Updates.As( + ctx, + &result, + basetypes.ObjectAsOptions{UnhandledUnknownAsEmpty: true}, + )..., + ) + + return &result +} diff --git a/linode/databasemysqlv2/framework_models_unit_test.go b/linode/databasemysqlv2/framework_models_unit_test.go new file mode 100644 index 000000000..c66be83a1 --- /dev/null +++ b/linode/databasemysqlv2/framework_models_unit_test.go @@ -0,0 +1,142 @@ +//go:build unit + +package databasemysqlv2_test + +import ( + "context" + "testing" + "time" + + "github.com/linode/terraform-provider-linode/v2/linode/databasemysqlv2" + + "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v2/linode/helper/unit" + "github.com/stretchr/testify/require" +) + +var ( + currentTime = time.Now() + currentTimeFWValue = timetypes.NewRFC3339TimePointerValue(¤tTime) + + testDB = linodego.MySQLDatabase{ + ID: 12345, + Status: linodego.DatabaseStatusProvisioning, + Label: "foobar", + Region: "us-mia", + Type: "g6-nanode-1", + Engine: "mysql", + Version: "8", + Encrypted: true, + AllowList: []string{"0.0.0.0/0", "10.0.0.1/32"}, + + // TODO: sdf + // Port: 1234, + + SSLConnection: true, + ClusterSize: 3, + Hosts: linodego.DatabaseHost{ + Primary: "1.2.3.4", + Secondary: "4.3.2.1", + }, + Updates: linodego.DatabaseMaintenanceWindow{ + DayOfWeek: 1, + Duration: 1, + Frequency: linodego.DatabaseMaintenanceFrequencyWeekly, + HourOfDay: 1, + Pending: []linodego.DatabaseMaintenanceWindowPending{ + { + Deadline: ¤tTime, + Description: "foobar", + PlannedFor: ¤tTime, + }, + }, + }, + Created: ¤tTime, + Updated: ¤tTime, + Fork: &linodego.DatabaseFork{ + Source: 12345, + RestoreTime: ¤tTime, + }, + OldestRestoreTime: ¤tTime, + Platform: "foobar", + } + + testDBSSL = linodego.MySQLDatabaseSSL{CACertificate: []byte("Zm9vYmFy")} + + testDBCreds = linodego.MySQLDatabaseCredential{ + Username: "foobar", + Password: "barfoo", + } +) + +func TestModel_Flatten(t *testing.T) { + var model databasemysqlv2.Model + + model.Flatten(context.Background(), &testDB, &testDBSSL, &testDBCreds, false) + + updates := unit.FrameworkObjectAs[databasemysqlv2.ModelUpdates](t, model.Updates) + + require.Equal(t, "12345", model.ID.ValueString()) + + require.Equal(t, "provisioning", model.Status.ValueString()) + require.Equal(t, "foobar", model.Label.ValueString()) + require.Equal(t, "us-mia", model.Region.ValueString()) + require.Equal(t, "g6-nanode-1", model.Type.ValueString()) + require.Equal(t, "mysql/8", model.EngineID.ValueString()) + require.Equal(t, "mysql", model.Engine.ValueString()) + require.Equal(t, "8", model.Version.ValueString()) + require.Equal(t, true, model.Encrypted.ValueBool()) + require.Equal(t, "foobar", model.Platform.ValueString()) + + // TODO + require.Equal(t, int64(0), model.Port.ValueInt64()) + + require.Equal(t, true, model.SSLConnection.ValueBool()) + require.Equal(t, "Zm9vYmFy", model.CACert.ValueString()) + require.Equal(t, int64(12345), model.ForkSource.ValueInt64()) + require.Equal(t, currentTimeFWValue, model.ForkRestoreTime) + require.Equal(t, "1.2.3.4", model.HostPrimary.ValueString()) + require.Equal(t, "4.3.2.1", model.HostSecondary.ValueString()) + require.Equal(t, "foobar", model.RootUsername.ValueString()) + require.Equal(t, "barfoo", model.RootPassword.ValueString()) + require.Equal(t, currentTimeFWValue, model.Created) + require.Equal(t, currentTimeFWValue, model.Updated) + require.Equal(t, currentTimeFWValue, model.OldestRestoreTime) + + require.Equal(t, int64(1), updates.DayOfWeek.ValueInt64()) + require.Equal(t, int64(1), updates.Duration.ValueInt64()) + require.Equal(t, "weekly", updates.Frequency.ValueString()) + require.Equal(t, int64(1), updates.HourOfDay.ValueInt64()) + + allowListElements := model.AllowList.Elements() + require.Contains(t, allowListElements, types.StringValue("0.0.0.0/0")) + require.Contains(t, allowListElements, types.StringValue("10.0.0.1/32")) + + expectedPendingElement, d := types.ObjectValue( + map[string]attr.Type{ + "deadline": timetypes.RFC3339Type{}, + "description": types.StringType, + "planned_for": timetypes.RFC3339Type{}, + }, + map[string]attr.Value{ + "deadline": currentTimeFWValue, + "description": types.StringValue("foobar"), + "planned_for": currentTimeFWValue, + }, + ) + require.False(t, d.HasError(), d.Errors()) + + require.True(t, model.PendingUpdates.Elements()[0].Equal(expectedPendingElement)) +} + +func TestModel_Copy(t *testing.T) { + var modelOld, modelNew databasemysqlv2.Model + modelOld.Flatten(context.Background(), &testDB, &testDBSSL, &testDBCreds, false) + + modelNew.CopyFrom(&modelOld, false) + + require.Equal(t, modelOld, modelNew) +} diff --git a/linode/databasemysqlv2/framework_resource.go b/linode/databasemysqlv2/framework_resource.go new file mode 100644 index 000000000..68dbd01c8 --- /dev/null +++ b/linode/databasemysqlv2/framework_resource.go @@ -0,0 +1,439 @@ +package databasemysqlv2 + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/hashicorp/terraform-plugin-framework/path" + + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v2/linode/helper" +) + +const ( + DefaultCreateTimeout = 60 * time.Minute + DefaultUpdateTimeout = 60 * time.Minute + DefaultDeleteTimeout = 5 * time.Minute +) + +func NewResource() resource.Resource { + return &Resource{ + BaseResource: helper.NewBaseResource( + helper.BaseResourceConfig{ + Name: "linode_database_mysql_v2", + IDType: types.StringType, + Schema: &frameworkResourceSchema, + TimeoutOpts: &timeouts.Opts{ + Update: true, + Create: true, + Delete: true, + }, + }, + ), + } +} + +type Resource struct { + helper.BaseResource +} + +func (r *Resource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + tflog.Debug(ctx, "Create linode_database_mysql_v2") + + var data Model + client := r.Meta.Client + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + createTimeout, diags := data.Timeouts.Create(ctx, DefaultCreateTimeout) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx, cancel := context.WithTimeout(ctx, createTimeout) + defer cancel() + + createOpts := linodego.MySQLCreateOptions{ + Label: data.Label.ValueString(), + Region: data.Region.ValueString(), + Type: data.Type.ValueString(), + Engine: data.EngineID.ValueString(), + ClusterSize: helper.FrameworkSafeInt64ToInt(data.ClusterSize.ValueInt64(), &resp.Diagnostics), + Fork: data.GetFork(resp.Diagnostics), + AllowList: data.GetAllowList(ctx, resp.Diagnostics), + } + + if resp.Diagnostics.HasError() { + return + } + + createPoller, err := client.NewEventPollerWithoutEntity(linodego.EntityDatabase, linodego.ActionDatabaseCreate) + if err != nil { + resp.Diagnostics.AddError( + "Failed to create event poller", + err.Error(), + ) + return + } + + tflog.Debug(ctx, "client.CreateMySQLDatabase(...)", map[string]any{ + "options": createOpts, + }) + + db, err := client.CreateMySQLDatabase(ctx, createOpts) + if err != nil { + resp.Diagnostics.AddError( + "Failed to create MySQL database", + err.Error(), + ) + return + } + + // We explicitly set the ID in state here to prevent leaking the resource + // in the case of a polling failure + resp.State.SetAttribute(ctx, path.Root("id"), strconv.Itoa(db.ID)) + + ctx = tflog.SetField(ctx, "id", db.ID) + + createPoller.EntityID = db.ID + + tflog.Debug(ctx, "Waiting for database to finish provisioning") + + if _, err := createPoller.WaitForFinished(ctx, int(createTimeout.Seconds())); err != nil { + resp.Diagnostics.AddError( + "Failed to wait for MySQL database to finish creating", + err.Error(), + ) + } + + // Sometimes the creation event finishes before the status becomes `active` + tflog.Debug(ctx, "Waiting for database to enter active status", map[string]any{ + "options": createOpts, + }) + + if err = client.WaitForDatabaseStatus( + ctx, + db.ID, + linodego.DatabaseEngineTypeMySQL, + linodego.DatabaseStatusActive, + int(createTimeout.Seconds()), + ); err != nil { + resp.Diagnostics.AddError("Failed to wait for MySQL database active", err.Error()) + return + } + + // The `updates` field can only be changed using PUT requests + updates := data.GetUpdates(ctx, resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + if updates != nil { + updateOpts := linodego.MySQLUpdateOptions{Updates: updates.ToLinodego(resp.Diagnostics)} + if resp.Diagnostics.HasError() { + return + } + + tflog.Debug(ctx, "client.UpdateMySQLDatabase(...)", map[string]any{ + "options": updateOpts, + }) + + db, err = client.UpdateMySQLDatabase( + ctx, + db.ID, + updateOpts, + ) + if err != nil { + resp.Diagnostics.AddError( + "Failed to update MySQL database", + err.Error(), + ) + return + } + } + + resp.Diagnostics.Append(data.Refresh(ctx, client, db.ID, true)...) + if resp.Diagnostics.HasError() { + return + } + + // IDs should always be overridden during creation (see #1085) + // TODO: Remove when Crossplane empty string ID issue is resolved + data.ID = types.StringValue(strconv.Itoa(db.ID)) + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *Resource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + tflog.Debug(ctx, "Read linode_database_mysql_v2") + + var data Model + client := r.Meta.Client + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + ctx = populateLogAttributes(ctx, data) + + if helper.FrameworkAttemptRemoveResourceForEmptyID(ctx, data.ID, resp) { + return + } + + id := helper.FrameworkSafeStringToInt(data.ID.ValueString(), &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + tflog.Debug(ctx, "client.GetMySQLDatabase(...)") + + db, err := client.GetMySQLDatabase(ctx, id) + if err != nil { + if lerr, ok := err.(*linodego.Error); ok && lerr.Code == 404 { + resp.Diagnostics.AddWarning( + "Database no longer exists", + fmt.Sprintf( + "Removing MySQL database with ID %v from state because it no longer exists", + id, + ), + ) + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError( + "Unable to refresh the Database", + err.Error(), + ) + return + } + + resp.Diagnostics.Append(data.Refresh(ctx, client, db.ID, false)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *Resource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + tflog.Debug(ctx, "Update linode_database_mysql_v2") + + client := r.Meta.Client + var plan, state Model + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return + } + + updateTimeout, diags := plan.Timeouts.Update(ctx, DefaultUpdateTimeout) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx, cancel := context.WithTimeout(ctx, updateTimeout) + defer cancel() + + ctx = populateLogAttributes(ctx, state) + + var updateOpts linodego.MySQLUpdateOptions + shouldUpdate := false + + // `label` field updates + if !state.Label.Equal(plan.Label) { + shouldUpdate = true + updateOpts.Label = plan.Label.ValueString() + } + + // `allow_list` field updates + if !state.AllowList.Equal(plan.AllowList) { + shouldUpdate = true + + allowList := plan.GetAllowList(ctx, resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + updateOpts.AllowList = &allowList + } + + // `type` field updates + if !state.Type.Equal(plan.Type) { + shouldUpdate = true + updateOpts.Type = plan.Type.ValueString() + } + + // `updates` field updates + if !state.Updates.Equal(plan.Updates) { + shouldUpdate = true + + updates := plan.GetUpdates(ctx, resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + updateOpts.Updates = updates.ToLinodego(resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + } + + // `engine_id` field updates + if !state.EngineID.Equal(plan.EngineID) { + engine, version, err := helper.ParseDatabaseEngineSlug(plan.EngineID.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Failed to parse database engine slug", err.Error()) + return + } + + if engine != state.Engine.ValueString() { + resp.Diagnostics.AddError( + "Cannot update engine component of engine_id", + fmt.Sprintf("%s != %s", engine, state.Engine.ValueString()), + ) + } + + shouldUpdate = true + updateOpts.Version = version + } + + // `cluster_size` field updates + if !state.ClusterSize.Equal(plan.ClusterSize) { + shouldUpdate = true + + updateOpts.ClusterSize = helper.FrameworkSafeInt64ToInt(plan.ClusterSize.ValueInt64(), &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + } + + if shouldUpdate { + id := helper.FrameworkSafeStringToInt(plan.ID.ValueString(), &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + updatePoller, err := client.NewEventPoller(ctx, id, linodego.EntityDatabase, linodego.ActionDatabaseUpdate) + if err != nil { + resp.Diagnostics.AddError( + "Failed to create EventPoller for database", + err.Error(), + ) + return + } + + tflog.Debug(ctx, "client.UpdateMySQLDatabase(...)", map[string]any{ + "options": updateOpts, + }) + if _, err := client.UpdateMySQLDatabase(ctx, id, updateOpts); err != nil { + resp.Diagnostics.AddError( + "Failed to update database", + err.Error(), + ) + return + } + + timeoutSeconds := helper.FrameworkSafeFloat64ToInt(updateTimeout.Seconds(), &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + if _, err := updatePoller.WaitForFinished(ctx, timeoutSeconds); err != nil { + resp.Diagnostics.AddError( + "Failed to poll for database update event to finish", + err.Error(), + ) + return + } + + resp.Diagnostics.Append(plan.Refresh(ctx, client, id, false)...) + if resp.Diagnostics.HasError() { + return + } + } + + plan.CopyFrom(&state, true) + + // Workaround for Crossplane issue where ID is not + // properly populated in plan + // See TPT-2865 for more details + if plan.ID.ValueString() == "" { + plan.ID = state.ID + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *Resource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { + tflog.Debug(ctx, "Delete linode_database_mysql_v2") + + client := r.Meta.Client + var data Model + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + deleteTimeout, diags := data.Timeouts.Delete(ctx, DefaultDeleteTimeout) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + ctx, cancel := context.WithTimeout(ctx, deleteTimeout) + defer cancel() + + ctx = populateLogAttributes(ctx, data) + + id := helper.FrameworkSafeStringToInt(data.ID.ValueString(), &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + tflog.Debug(ctx, "client.DeleteMySQLDatabase(...)") + err := client.DeleteMySQLDatabase(ctx, id) + if err != nil { + if lerr, ok := err.(*linodego.Error); (ok && lerr.Code != 404) || !ok { + resp.Diagnostics.AddError( + "Failed to delete the database", + err.Error(), + ) + } + return + } +} + +func populateLogAttributes(ctx context.Context, data Model) context.Context { + return tflog.SetField(ctx, "id", data.ID) +} diff --git a/linode/databasemysqlv2/framework_resource_schema.go b/linode/databasemysqlv2/framework_resource_schema.go new file mode 100644 index 000000000..5cadb0ca4 --- /dev/null +++ b/linode/databasemysqlv2/framework_resource_schema.go @@ -0,0 +1,215 @@ +package databasemysqlv2 + +import ( + "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + updatesAttributes = map[string]attr.Type{ + "day_of_week": types.Int64Type, + "duration": types.Int64Type, + "frequency": types.StringType, + "hour_of_day": types.Int64Type, + } + + pendingUpdateAttributes = map[string]attr.Type{ + "deadline": timetypes.RFC3339Type{}, + "description": types.StringType, + "planned_for": timetypes.RFC3339Type{}, + } +) + +var frameworkResourceSchema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The id of the VPC.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + + "engine_id": schema.StringAttribute{ + Required: true, + Description: "The unique ID of the database engine and version to use. (e.g. mysql/8)", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "label": schema.StringAttribute{ + Required: true, + Description: "A unique, user-defined string referring to the Managed Database.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "region": schema.StringAttribute{ + Required: true, + Description: "The Region ID for the Managed Database.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "type": schema.StringAttribute{ + Required: true, + Description: "The Linode Instance type used by the Managed Database for its nodes.\n\n", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + + "allow_list": schema.SetAttribute{ + ElementType: types.StringType, + Optional: true, + Computed: true, + Description: "A list of IP addresses that can access the Managed Database. " + + "Each item can be a single IP address or a range in CIDR format.", + PlanModifiers: []planmodifier.Set{ + setplanmodifier.UseStateForUnknown(), + }, + }, + "ca_cert": schema.StringAttribute{ + Description: "The base64-encoded SSL CA certificate for the Managed Database.", + Computed: true, + Sensitive: true, + }, + "cluster_size": schema.Int64Attribute{ + Optional: true, + Computed: true, + Description: "The number of Linode instance nodes deployed to the Managed Database.", + Validators: []validator.Int64{ + int64validator.AtLeast(1), + }, + Default: int64default.StaticInt64(1), + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "fork_restore_time": schema.StringAttribute{ + Description: "The database timestamp from which it was restored.", + Optional: true, + Computed: true, + CustomType: timetypes.RFC3339Type{}, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + }, + "fork_source": schema.Int64Attribute{ + Description: "The ID of the database that was forked from.", + Optional: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + int64planmodifier.RequiresReplace(), + }, + }, + "updates": schema.ObjectAttribute{ + Description: "Configuration settings for automated patch update maintenance for the Managed Database.", + AttributeTypes: updatesAttributes, + Computed: true, + Optional: true, + PlanModifiers: []planmodifier.Object{objectplanmodifier.UseStateForUnknown()}, + }, + + "created": schema.StringAttribute{ + Description: "When this Managed Database was created.", + Computed: true, + CustomType: timetypes.RFC3339Type{}, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "encrypted": schema.BoolAttribute{ + Description: "Whether the Managed Databases is encrypted.", + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "engine": schema.StringAttribute{ + Description: "The Managed Database engine in engine/version format.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "host_primary": schema.StringAttribute{ + Description: "The primary host for the Managed Database.", + Computed: true, + }, + "host_secondary": schema.StringAttribute{ + Description: "The secondary/private host for the Managed Database.", + Computed: true, + }, + "members": schema.MapAttribute{ + ElementType: types.StringType, + Computed: true, + Description: "A mapping between IP addresses and strings designating them as primary or failover.", + }, + "oldest_restore_time": schema.StringAttribute{ + Description: "The oldest time to which a database can be restored.", + Computed: true, + CustomType: timetypes.RFC3339Type{}, + }, + "pending_updates": schema.SetAttribute{ + Description: "A set of pending updates.", + Computed: true, + ElementType: types.ObjectType{AttrTypes: pendingUpdateAttributes}, + PlanModifiers: []planmodifier.Set{setplanmodifier.UseStateForUnknown()}, + }, + "platform": schema.StringAttribute{ + Computed: true, + Description: "The back-end platform for relational databases used by the service.", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "port": schema.Int64Attribute{ + Description: "The access port for this Managed Database.", + Computed: true, + PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()}, + }, + "root_password": schema.StringAttribute{ + Description: "The randomly generated root password for the Managed Database instance.", + Computed: true, + Sensitive: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "root_username": schema.StringAttribute{ + Description: "The root username for the Managed Database instance.", + Computed: true, + Sensitive: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "ssl_connection": schema.BoolAttribute{ + Computed: true, + Description: "Whether to require SSL credentials to establish a connection to the Managed Database.", + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + "status": schema.StringAttribute{ + Computed: true, + Description: "The operating status of the Managed Database.", + }, + "updated": schema.StringAttribute{ + Description: "When this Managed Database was last updated.", + Computed: true, + CustomType: timetypes.RFC3339Type{}, + }, + "version": schema.StringAttribute{ + Description: "The Managed Database engine version.", + Computed: true, + }, + }, +} diff --git a/linode/databasemysqlv2/resource_test.go b/linode/databasemysqlv2/resource_test.go new file mode 100644 index 000000000..89bc2bd7c --- /dev/null +++ b/linode/databasemysqlv2/resource_test.go @@ -0,0 +1,405 @@ +//go:build integration || databasemysqlv2 + +package databasemysqlv2_test + +import ( + "context" + "fmt" + "log" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/linode/linodego" + "github.com/linode/terraform-provider-linode/v2/linode/acceptance" + "github.com/linode/terraform-provider-linode/v2/linode/databasemysqlv2/tmpl" + "github.com/linode/terraform-provider-linode/v2/linode/helper" +) + +var testRegion, testEngine string + +func init() { + resource.AddTestSweepers("linode_database_mysql_v2", &resource.Sweeper{ + Name: "linode_database_mysql_v2", + F: sweep, + }) + + client, err := acceptance.GetTestClient() + if err != nil { + log.Fatal(err) + } + + region, err := acceptance.GetRandomRegionWithCaps([]string{"Managed Databases"}, "core") + if err != nil { + log.Fatal(err) + } + + testRegion = region + + engine, err := helper.ResolveValidDBEngine( + context.Background(), + *client, + string(linodego.DatabaseEngineTypeMySQL), + ) + if err != nil { + log.Fatal(err) + } + + testEngine = engine.ID +} + +func sweep(prefix string) error { + client, err := acceptance.GetTestClient() + if err != nil { + return fmt.Errorf("failed to get client: %w", err) + } + + listOpts := acceptance.SweeperListOptions(prefix, "label") + + dbs, err := client.ListMySQLDatabases(context.Background(), listOpts) + if err != nil { + return fmt.Errorf("error getting mysql databases: %w", err) + } + for _, db := range dbs { + if !acceptance.ShouldSweep(prefix, db.Label) { + continue + } + err := client.DeleteMySQLDatabase(context.Background(), db.ID) + if err != nil { + return fmt.Errorf("error destroying %s during sweep: %w", db.Label, err) + } + } + + return nil +} + +func TestAccResource_basic(t *testing.T) { + t.Parallel() + + resName := "linode_database_mysql_v2.foobar" + label := acctest.RandomWithPrefix("tf_test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + CheckDestroy: acceptance.CheckVolumeDestroy, + Steps: []resource.TestStep{ + { + Config: tmpl.Basic(t, label, testRegion, testEngine, "g6-nanode-1"), + Check: resource.ComposeTestCheckFunc( + acceptance.CheckMySQLDatabaseExists(resName, nil), + + resource.TestCheckResourceAttrSet(resName, "id"), + + resource.TestCheckResourceAttrSet(resName, "ca_cert"), + resource.TestCheckResourceAttr(resName, "cluster_size", "1"), + resource.TestCheckResourceAttrSet(resName, "created"), + resource.TestCheckResourceAttr(resName, "encrypted", "true"), + resource.TestCheckResourceAttr(resName, "engine", "mysql"), + resource.TestCheckResourceAttr(resName, "engine_id", testEngine), + resource.TestCheckNoResourceAttr(resName, "fork_restore_time"), + resource.TestCheckNoResourceAttr(resName, "fork_source"), + resource.TestCheckResourceAttrSet(resName, "host_primary"), + resource.TestCheckResourceAttr(resName, "label", label), + resource.TestCheckResourceAttrSet(resName, "members.%"), + resource.TestCheckResourceAttrSet(resName, "root_password"), + resource.TestCheckResourceAttrSet(resName, "root_username"), + resource.TestCheckResourceAttr(resName, "platform", "rdbms-default"), + resource.TestCheckResourceAttrSet(resName, "port"), + resource.TestCheckResourceAttr(resName, "region", testRegion), + resource.TestCheckResourceAttr(resName, "ssl_connection", "true"), + resource.TestCheckResourceAttr(resName, "status", "active"), + resource.TestCheckResourceAttr(resName, "type", "g6-nanode-1"), + resource.TestCheckResourceAttrSet(resName, "updated"), + resource.TestCheckResourceAttrSet(resName, "version"), + + resource.TestCheckResourceAttr(resName, "allow_list.#", "0"), + + resource.TestCheckResourceAttrSet(resName, "updates.day_of_week"), + resource.TestCheckResourceAttrSet(resName, "updates.duration"), + resource.TestCheckResourceAttrSet(resName, "updates.frequency"), + resource.TestCheckResourceAttrSet(resName, "updates.hour_of_day"), + + resource.TestCheckResourceAttr(resName, "pending_updates.#", "0"), + ), + }, + { + ResourceName: resName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccResource_complex(t *testing.T) { + t.Parallel() + + resName := "linode_database_mysql_v2.foobar" + label := acctest.RandomWithPrefix("tf_test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + CheckDestroy: acceptance.CheckVolumeDestroy, + Steps: []resource.TestStep{ + { + Config: tmpl.Complex( + t, + tmpl.TemplateData{ + Label: label, + Region: testRegion, + EngineID: testEngine, + Type: "g6-nanode-1", + AllowedIP: "10.0.0.3/32", + ClusterSize: 1, + Updates: tmpl.TemplateDataUpdates{ + HourOfDay: 3, + DayOfWeek: 2, + Duration: 4, + Frequency: "weekly", + }, + }, + ), + Check: resource.ComposeTestCheckFunc( + acceptance.CheckMySQLDatabaseExists(resName, nil), + + resource.TestCheckResourceAttrSet(resName, "id"), + + resource.TestCheckResourceAttrSet(resName, "ca_cert"), + resource.TestCheckResourceAttr(resName, "cluster_size", "1"), + resource.TestCheckResourceAttrSet(resName, "created"), + resource.TestCheckResourceAttr(resName, "encrypted", "true"), + resource.TestCheckResourceAttr(resName, "engine", "mysql"), + resource.TestCheckResourceAttr(resName, "engine_id", testEngine), + resource.TestCheckNoResourceAttr(resName, "fork_restore_time"), + resource.TestCheckNoResourceAttr(resName, "fork_source"), + resource.TestCheckResourceAttrSet(resName, "host_primary"), + resource.TestCheckResourceAttr(resName, "label", label), + resource.TestCheckResourceAttrSet(resName, "members.%"), + resource.TestCheckResourceAttrSet(resName, "root_password"), + resource.TestCheckResourceAttrSet(resName, "root_username"), + resource.TestCheckResourceAttr(resName, "platform", "rdbms-default"), + resource.TestCheckResourceAttrSet(resName, "port"), + resource.TestCheckResourceAttr(resName, "region", testRegion), + resource.TestCheckResourceAttr(resName, "ssl_connection", "true"), + resource.TestCheckResourceAttr(resName, "status", "active"), + resource.TestCheckResourceAttr(resName, "type", "g6-nanode-1"), + resource.TestCheckResourceAttrSet(resName, "updated"), + resource.TestCheckResourceAttrSet(resName, "version"), + + resource.TestCheckResourceAttr(resName, "allow_list.#", "1"), + resource.TestCheckResourceAttr(resName, "allow_list.0", "10.0.0.3/32"), + + resource.TestCheckResourceAttr(resName, "updates.day_of_week", "2"), + resource.TestCheckResourceAttr(resName, "updates.duration", "4"), + resource.TestCheckResourceAttr(resName, "updates.frequency", "weekly"), + resource.TestCheckResourceAttr(resName, "updates.hour_of_day", "3"), + + resource.TestCheckResourceAttr(resName, "pending_updates.#", "0"), + ), + }, + { + Config: tmpl.Complex( + t, + tmpl.TemplateData{ + Label: label, + Region: testRegion, + EngineID: testEngine, + Type: "g6-standard-1", + AllowedIP: "10.0.0.4/32", + ClusterSize: 3, + Updates: tmpl.TemplateDataUpdates{ + HourOfDay: 2, + DayOfWeek: 3, + Duration: 4, + Frequency: "weekly", + }, + }, + ), + Check: resource.ComposeTestCheckFunc( + acceptance.CheckMySQLDatabaseExists(resName, nil), + + resource.TestCheckResourceAttrSet(resName, "id"), + + resource.TestCheckResourceAttrSet(resName, "ca_cert"), + resource.TestCheckResourceAttr(resName, "cluster_size", "3"), + resource.TestCheckResourceAttrSet(resName, "created"), + resource.TestCheckResourceAttr(resName, "encrypted", "true"), + resource.TestCheckResourceAttr(resName, "engine", "mysql"), + resource.TestCheckResourceAttr(resName, "engine_id", testEngine), + resource.TestCheckNoResourceAttr(resName, "fork_restore_time"), + resource.TestCheckNoResourceAttr(resName, "fork_source"), + resource.TestCheckResourceAttrSet(resName, "host_primary"), + resource.TestCheckResourceAttr(resName, "label", label), + resource.TestCheckResourceAttrSet(resName, "members.%"), + resource.TestCheckResourceAttrSet(resName, "root_password"), + resource.TestCheckResourceAttrSet(resName, "root_username"), + resource.TestCheckResourceAttr(resName, "platform", "rdbms-default"), + resource.TestCheckResourceAttrSet(resName, "port"), + resource.TestCheckResourceAttr(resName, "region", testRegion), + resource.TestCheckResourceAttr(resName, "ssl_connection", "true"), + resource.TestCheckResourceAttr(resName, "status", "active"), + resource.TestCheckResourceAttr(resName, "type", "g6-standard-1"), + resource.TestCheckResourceAttrSet(resName, "updated"), + resource.TestCheckResourceAttrSet(resName, "version"), + + resource.TestCheckResourceAttr(resName, "allow_list.#", "1"), + resource.TestCheckResourceAttr(resName, "allow_list.0", "10.0.0.4/32"), + + resource.TestCheckResourceAttr(resName, "updates.hour_of_day", "2"), + resource.TestCheckResourceAttr(resName, "updates.day_of_week", "3"), + resource.TestCheckResourceAttr(resName, "updates.duration", "4"), + resource.TestCheckResourceAttr(resName, "updates.frequency", "weekly"), + + resource.TestCheckResourceAttr(resName, "pending_updates.#", "0"), + ), + }, + { + ResourceName: resName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccResource_fork(t *testing.T) { + t.Parallel() + + resNameSource := "linode_database_mysql_v2.foobar" + resNameFork := "linode_database_mysql_v2.fork" + + var dbSource linodego.MySQLDatabase + + label := acctest.RandomWithPrefix("tf_test") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acceptance.PreCheck(t) }, + ProtoV5ProviderFactories: acceptance.ProtoV5ProviderFactories, + CheckDestroy: acceptance.CheckVolumeDestroy, + Steps: []resource.TestStep{ + { + Config: tmpl.Basic(t, label, testRegion, testEngine, "g6-nanode-1"), + Check: resource.ComposeTestCheckFunc( + acceptance.CheckMySQLDatabaseExists(resNameSource, &dbSource), + + resource.TestCheckResourceAttrSet(resNameSource, "id"), + + resource.TestCheckResourceAttrSet(resNameSource, "ca_cert"), + resource.TestCheckResourceAttr(resNameSource, "cluster_size", "1"), + resource.TestCheckResourceAttrSet(resNameSource, "created"), + resource.TestCheckResourceAttr(resNameSource, "encrypted", "true"), + resource.TestCheckResourceAttr(resNameSource, "engine", "mysql"), + resource.TestCheckResourceAttr(resNameSource, "engine_id", testEngine), + resource.TestCheckNoResourceAttr(resNameSource, "fork_restore_time"), + resource.TestCheckNoResourceAttr(resNameSource, "fork_source"), + resource.TestCheckResourceAttrSet(resNameSource, "host_primary"), + resource.TestCheckResourceAttr(resNameSource, "label", label), + resource.TestCheckResourceAttrSet(resNameSource, "members.%"), + resource.TestCheckResourceAttrSet(resNameSource, "root_password"), + resource.TestCheckResourceAttrSet(resNameSource, "root_username"), + resource.TestCheckResourceAttr(resNameSource, "platform", "rdbms-default"), + resource.TestCheckResourceAttrSet(resNameSource, "port"), + resource.TestCheckResourceAttr(resNameSource, "region", testRegion), + resource.TestCheckResourceAttr(resNameSource, "ssl_connection", "true"), + resource.TestCheckResourceAttr(resNameSource, "status", "active"), + resource.TestCheckResourceAttr(resNameSource, "type", "g6-nanode-1"), + resource.TestCheckResourceAttrSet(resNameSource, "updated"), + resource.TestCheckResourceAttrSet(resNameSource, "version"), + + resource.TestCheckResourceAttr(resNameSource, "allow_list.#", "0"), + + resource.TestCheckResourceAttrSet(resNameSource, "updates.day_of_week"), + resource.TestCheckResourceAttrSet(resNameSource, "updates.duration"), + resource.TestCheckResourceAttrSet(resNameSource, "updates.frequency"), + resource.TestCheckResourceAttrSet(resNameSource, "updates.hour_of_day"), + + resource.TestCheckResourceAttr(resNameSource, "pending_updates.#", "0"), + ), + }, + { + PreConfig: func() { + // Poll for the source database to be restorable + ctx := context.Background() + + client, err := acceptance.GetTestClient() + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(ctx, time.Minute*30) + defer cancel() + + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + db, err := client.GetMySQLDatabase(ctx, dbSource.ID) + if err != nil { + t.Fatalf("failed to get mysql database: %s", err) + } + + if db.OldestRestoreTime != nil { + return + } + case <-ctx.Done(): + return + } + } + }, + Config: tmpl.Fork(t, label, testRegion, testEngine, "g6-nanode-1"), + Check: resource.ComposeTestCheckFunc( + acceptance.CheckMySQLDatabaseExists(resNameFork, nil), + + resource.TestCheckResourceAttrSet(resNameFork, "id"), + + resource.TestCheckResourceAttrSet(resNameFork, "ca_cert"), + resource.TestCheckResourceAttr(resNameFork, "cluster_size", "1"), + resource.TestCheckResourceAttrSet(resNameFork, "created"), + resource.TestCheckResourceAttr(resNameFork, "encrypted", "true"), + resource.TestCheckResourceAttr(resNameFork, "engine", "mysql"), + resource.TestCheckResourceAttr(resNameFork, "engine_id", testEngine), + resource.TestCheckResourceAttrSet(resNameFork, "fork_restore_time"), + resource.TestCheckResourceAttrSet(resNameFork, "fork_source"), + resource.TestCheckResourceAttrSet(resNameFork, "host_primary"), + resource.TestCheckResourceAttr(resNameFork, "label", label+"-fork"), + resource.TestCheckResourceAttrSet(resNameFork, "members.%"), + resource.TestCheckResourceAttrSet(resNameSource, "oldest_restore_time"), + resource.TestCheckResourceAttr(resNameFork, "platform", "rdbms-default"), + resource.TestCheckResourceAttrSet(resNameFork, "port"), + resource.TestCheckResourceAttr(resNameFork, "region", testRegion), + resource.TestCheckResourceAttrSet(resNameFork, "root_password"), + resource.TestCheckResourceAttrSet(resNameFork, "root_username"), + resource.TestCheckResourceAttr(resNameFork, "ssl_connection", "true"), + resource.TestCheckResourceAttr(resNameFork, "status", "active"), + resource.TestCheckResourceAttr(resNameFork, "type", "g6-nanode-1"), + resource.TestCheckResourceAttrSet(resNameFork, "updated"), + resource.TestCheckResourceAttrSet(resNameFork, "version"), + + resource.TestCheckResourceAttr(resNameFork, "allow_list.#", "0"), + + resource.TestCheckResourceAttrSet(resNameFork, "updates.day_of_week"), + resource.TestCheckResourceAttrSet(resNameFork, "updates.duration"), + resource.TestCheckResourceAttrSet(resNameFork, "updates.frequency"), + resource.TestCheckResourceAttrSet(resNameFork, "updates.hour_of_day"), + + resource.TestCheckResourceAttr(resNameFork, "pending_updates.#", "0"), + ), + }, + { + ResourceName: resNameSource, + ImportState: true, + ImportStateVerify: true, + }, + { + ResourceName: resNameFork, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} diff --git a/linode/databasemysqlv2/tmpl/basic.gotf b/linode/databasemysqlv2/tmpl/basic.gotf new file mode 100644 index 000000000..8832d7ca1 --- /dev/null +++ b/linode/databasemysqlv2/tmpl/basic.gotf @@ -0,0 +1,10 @@ +{{ define "database_mysql_v2_basic" }} + +resource "linode_database_mysql_v2" "foobar" { + label = "{{.Label}}" + region = "{{ .Region }}" + type = "g6-nanode-1" + engine_id = "{{ .EngineID }}" +} + +{{ end }} \ No newline at end of file diff --git a/linode/databasemysqlv2/tmpl/complex.gotf b/linode/databasemysqlv2/tmpl/complex.gotf new file mode 100644 index 000000000..f4d016386 --- /dev/null +++ b/linode/databasemysqlv2/tmpl/complex.gotf @@ -0,0 +1,20 @@ +{{ define "database_mysql_v2_complex" }} + +resource "linode_database_mysql_v2" "foobar" { + label = "{{.Label}}" + region = "{{ .Region }}" + type = "{{ .Type }}" + engine_id = "{{ .EngineID }}" + + allow_list = ["{{ .AllowedIP }}"] + cluster_size = {{ .ClusterSize }} + + updates = { + hour_of_day = {{ .Updates.HourOfDay }} + day_of_week = {{ .Updates.DayOfWeek }} + frequency = "{{ .Updates.Frequency }}" + duration = {{ .Updates.Duration }} + } +} + +{{ end }} \ No newline at end of file diff --git a/linode/databasemysqlv2/tmpl/fork.gotf b/linode/databasemysqlv2/tmpl/fork.gotf new file mode 100644 index 000000000..7a49f8468 --- /dev/null +++ b/linode/databasemysqlv2/tmpl/fork.gotf @@ -0,0 +1,14 @@ +{{ define "database_mysql_v2_fork" }} + +{{ template "database_mysql_v2_basic" . }} + +resource "linode_database_mysql_v2" "fork" { + label = "{{.Label}}-fork" + region = "{{ .Region }}" + type = "{{ .Type }}" + engine_id = "{{ .EngineID }}" + + fork_source = linode_database_mysql_v2.foobar.id +} + +{{ end }} diff --git a/linode/databasemysqlv2/tmpl/template.go b/linode/databasemysqlv2/tmpl/template.go new file mode 100644 index 000000000..fd1e8d98d --- /dev/null +++ b/linode/databasemysqlv2/tmpl/template.go @@ -0,0 +1,59 @@ +package tmpl + +import ( + "testing" + + "github.com/linode/terraform-provider-linode/v2/linode/acceptance" +) + +type TemplateDataUpdates struct { + HourOfDay, DayOfWeek, Duration int + Frequency string +} + +type TemplateData struct { + Label string + Region string + EngineID string + Type string + AllowedIP string + ClusterSize int + Updates TemplateDataUpdates +} + +func Basic(t testing.TB, label, region, engine, nodeType string) string { + return acceptance.ExecuteTemplate( + t, + "database_mysql_v2_basic", + TemplateData{ + Label: label, + Region: region, + EngineID: engine, + Type: nodeType, + }, + ) +} + +func Complex( + t testing.TB, + data TemplateData, +) string { + return acceptance.ExecuteTemplate( + t, + "database_mysql_v2_complex", + data, + ) +} + +func Fork(t testing.TB, label, region, engine, nodeType string) string { + return acceptance.ExecuteTemplate( + t, + "database_mysql_v2_fork", + TemplateData{ + Label: label, + Region: region, + EngineID: engine, + Type: nodeType, + }, + ) +} diff --git a/linode/framework_provider.go b/linode/framework_provider.go index ccc458bfd..d4e9e740a 100644 --- a/linode/framework_provider.go +++ b/linode/framework_provider.go @@ -3,6 +3,8 @@ package linode import ( "context" + "github.com/linode/terraform-provider-linode/v2/linode/databasemysqlv2" + "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/schema" @@ -241,6 +243,7 @@ func (p *FrameworkProvider) Resources(ctx context.Context) []func() resource.Res networkingip.NewResource, networkingipassignment.NewResource, obj.NewResource, + databasemysqlv2.NewResource, } }