From 665f1a2c461769d4bc138e7ad31236245226ac97 Mon Sep 17 00:00:00 2001 From: Levi van Noort <73097785+levivannoort@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:45:46 +0200 Subject: [PATCH] feat: add dedicated database resources Onboards Appwrite dedicated databases (managed PostgreSQL, MySQL, and Mongo instances) onto the provider: - appwrite_dedicated_database: CRUD + import, waits for async provisioning so connection details are populated, exposes connection and status/sizing fields (password/URI marked sensitive). - appwrite_dedicated_database_backup_policy: CRUD + import. A single resource selects the engine via an `engine` attribute. The three engines share byte-identical params, models, and REST paths (differing only in the URL segment), so this package issues raw engine-keyed REST calls and decodes into the shared SDK models, mirroring the existing GetColumnRaw helper rather than triplicating typed option builders. Built against the unreleased dedicated-databases SDK, which currently lives on a fork with a different module path. To avoid a module-identity conflict the fork is required additionally and used only by this new package (which builds its own client from the raw provider credentials); no existing imports change. Swap the import prefix back to appwrite/sdk-for-go and drop the fork require once the feature ships in the official SDK. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/resources/dedicated_database.md | 89 ++++ .../dedicated_database_backup_policy.md | 54 +++ .../appwrite_dedicated_database/import.sh | 2 + .../appwrite_dedicated_database/resource.tf | 24 ++ .../import.sh | 2 + .../resource.tf | 8 + go.mod | 1 + go.sum | 2 + internal/common/helpers.go | 8 + internal/provider/provider.go | 8 + .../backup_policy_resource.go | 282 +++++++++++++ .../services/dedicateddatabase/helpers.go | 150 +++++++ .../dedicateddatabase/helpers_test.go | 51 +++ .../services/dedicateddatabase/resource.go | 391 ++++++++++++++++++ 14 files changed, 1072 insertions(+) create mode 100644 docs/resources/dedicated_database.md create mode 100644 docs/resources/dedicated_database_backup_policy.md create mode 100644 examples/resources/appwrite_dedicated_database/import.sh create mode 100644 examples/resources/appwrite_dedicated_database/resource.tf create mode 100644 examples/resources/appwrite_dedicated_database_backup_policy/import.sh create mode 100644 examples/resources/appwrite_dedicated_database_backup_policy/resource.tf create mode 100644 internal/services/dedicateddatabase/backup_policy_resource.go create mode 100644 internal/services/dedicateddatabase/helpers.go create mode 100644 internal/services/dedicateddatabase/helpers_test.go create mode 100644 internal/services/dedicateddatabase/resource.go diff --git a/docs/resources/dedicated_database.md b/docs/resources/dedicated_database.md new file mode 100644 index 0000000..32f28e8 --- /dev/null +++ b/docs/resources/dedicated_database.md @@ -0,0 +1,89 @@ +--- +page_title: "Resource: appwrite_dedicated_database" +description: |- + Manages an Appwrite dedicated database (a managed PostgreSQL, MySQL, or MongoDB instance). +--- + +# Resource: appwrite_dedicated_database + +Manages an Appwrite dedicated database (a managed PostgreSQL, MySQL, or MongoDB instance). + +## Example Usage + +```terraform +resource "appwrite_dedicated_database" "postgres" { + engine = "postgresql" + name = "analytics" + version = "16" + specification = "s-1vcpu-2gb" +} + +resource "appwrite_dedicated_database" "highly_available" { + engine = "mysql" + name = "orders" + specification = "s-2vcpu-4gb" + replicas = 2 + sync_mode = "sync" + pitr = true + pitr_retention_days = 7 + storage_autoscaling = true + network_ip_allowlist = ["10.0.0.0/8"] +} + +# Connection details are computed once the database finishes provisioning. +output "orders_connection_string" { + value = appwrite_dedicated_database.highly_available.connection_string + sensitive = true +} +``` + + +## Schema + +### Required + +- `engine` (String) The database engine. One of: mongo, mysql, postgresql. +- `name` (String) The database display name. + +### Optional + +- `api` (String) The product API that owns this database: nativedb, documentsdb, or vectorsdb. Changing this forces a new database. +- `id` (String) The dedicated database ID. Must be unique within the project. +- `idle_timeout_minutes` (Number) Minutes of inactivity before the container scales to zero. +- `network_idle_timeout_seconds` (Number) Connection idle timeout in seconds. +- `network_ip_allowlist` (List of String) IP addresses/CIDR ranges allowed to connect. +- `pitr` (Boolean) Whether point-in-time recovery is enabled. +- `pitr_retention_days` (Number) Number of days to retain point-in-time-recovery data. +- `project_id` (String) The Appwrite project ID. Defaults to the provider-level project_id. +- `replicas` (Number) Number of high-availability replicas. High availability is enabled when greater than 0. +- `specification` (String) The compute specification identifier (e.g. a size tier). See the engine's list-specifications API for valid values. +- `storage_autoscaling` (Boolean) Whether automatic storage expansion is enabled. +- `storage_autoscaling_max_gb` (Number) Maximum storage size in GB for autoscaling. 0 means no limit. +- `storage_autoscaling_threshold_percent` (Number) Storage usage percentage that triggers automatic expansion. +- `sync_mode` (String) Replication sync mode: async, sync, or quorum. +- `version` (String) The engine version. Changing this forces a new database; use the Appwrite console to perform an in-place upgrade. + +### Read-Only + +- `backend` (String) Database backend provider (prisma or edge). +- `connection_password` (String, Sensitive) Database password for connections. +- `connection_port` (Number) Database port for connections. +- `connection_string` (String, Sensitive) Full database connection string (URI format). +- `connection_user` (String) Database username for connections. +- `cpu` (Number) CPU allocated in millicores. +- `created_at` (String) The database creation timestamp in ISO 8601 format. +- `hostname` (String) Database hostname for connections. +- `memory` (Number) Memory allocated in MB. +- `ssl` (Boolean) Whether SSL/TLS is required for client connections. +- `status` (String) Database status (e.g. provisioning, ready, paused, failed). +- `storage` (Number) Storage allocated in GB. +- `updated_at` (String) The database last update timestamp in ISO 8601 format. + +## Import + +Import is supported using the following syntax: + +```shell +# Dedicated databases are imported as "engine/database_id". +terraform import appwrite_dedicated_database.postgres postgresql/6812a1b2c3d4e5f6a7b8 +``` diff --git a/docs/resources/dedicated_database_backup_policy.md b/docs/resources/dedicated_database_backup_policy.md new file mode 100644 index 0000000..d45accf --- /dev/null +++ b/docs/resources/dedicated_database_backup_policy.md @@ -0,0 +1,54 @@ +--- +page_title: "Resource: appwrite_dedicated_database_backup_policy" +description: |- + Manages a backup policy for an Appwrite dedicated database. +--- + +# Resource: appwrite_dedicated_database_backup_policy + +Manages a backup policy for an Appwrite dedicated database. + +## Example Usage + +```terraform +resource "appwrite_dedicated_database_backup_policy" "daily" { + engine = appwrite_dedicated_database.postgres.engine + database_id = appwrite_dedicated_database.postgres.id + name = "daily" + schedule = "0 3 * * *" + retention = 30 + type = "full" +} +``` + + +## Schema + +### Required + +- `database_id` (String) The dedicated database this policy backs up. +- `engine` (String) The engine of the target database. One of: mongo, mysql, postgresql. +- `name` (String) The backup policy name. +- `retention` (Number) How many days to keep each backup before automatic deletion. +- `schedule` (String) Backup schedule in CRON format. + +### Optional + +- `enabled` (Boolean) Whether the policy is enabled. Defaults to true. +- `id` (String) The backup policy ID. +- `project_id` (String) The Appwrite project ID. Defaults to the provider-level project_id. +- `type` (String) The backup type (e.g. full). Changing this forces a new policy. + +### Read-Only + +- `created_at` (String) The policy creation timestamp in ISO 8601 format. +- `updated_at` (String) The policy last update timestamp in ISO 8601 format. + +## Import + +Import is supported using the following syntax: + +```shell +# Backup policies are imported as "engine/database_id/policy_id". +terraform import appwrite_dedicated_database_backup_policy.daily postgresql/6812a1b2c3d4e5f6a7b8/6900aabbccddeeff0011 +``` diff --git a/examples/resources/appwrite_dedicated_database/import.sh b/examples/resources/appwrite_dedicated_database/import.sh new file mode 100644 index 0000000..47a8984 --- /dev/null +++ b/examples/resources/appwrite_dedicated_database/import.sh @@ -0,0 +1,2 @@ +# Dedicated databases are imported as "engine/database_id". +terraform import appwrite_dedicated_database.postgres postgresql/6812a1b2c3d4e5f6a7b8 diff --git a/examples/resources/appwrite_dedicated_database/resource.tf b/examples/resources/appwrite_dedicated_database/resource.tf new file mode 100644 index 0000000..811385b --- /dev/null +++ b/examples/resources/appwrite_dedicated_database/resource.tf @@ -0,0 +1,24 @@ +resource "appwrite_dedicated_database" "postgres" { + engine = "postgresql" + name = "analytics" + version = "16" + specification = "s-1vcpu-2gb" +} + +resource "appwrite_dedicated_database" "highly_available" { + engine = "mysql" + name = "orders" + specification = "s-2vcpu-4gb" + replicas = 2 + sync_mode = "sync" + pitr = true + pitr_retention_days = 7 + storage_autoscaling = true + network_ip_allowlist = ["10.0.0.0/8"] +} + +# Connection details are computed once the database finishes provisioning. +output "orders_connection_string" { + value = appwrite_dedicated_database.highly_available.connection_string + sensitive = true +} diff --git a/examples/resources/appwrite_dedicated_database_backup_policy/import.sh b/examples/resources/appwrite_dedicated_database_backup_policy/import.sh new file mode 100644 index 0000000..dfcb79f --- /dev/null +++ b/examples/resources/appwrite_dedicated_database_backup_policy/import.sh @@ -0,0 +1,2 @@ +# Backup policies are imported as "engine/database_id/policy_id". +terraform import appwrite_dedicated_database_backup_policy.daily postgresql/6812a1b2c3d4e5f6a7b8/6900aabbccddeeff0011 diff --git a/examples/resources/appwrite_dedicated_database_backup_policy/resource.tf b/examples/resources/appwrite_dedicated_database_backup_policy/resource.tf new file mode 100644 index 0000000..4af145c --- /dev/null +++ b/examples/resources/appwrite_dedicated_database_backup_policy/resource.tf @@ -0,0 +1,8 @@ +resource "appwrite_dedicated_database_backup_policy" "daily" { + engine = appwrite_dedicated_database.postgres.engine + database_id = appwrite_dedicated_database.postgres.id + name = "daily" + schedule = "0 3 * * *" + retention = 30 + type = "full" +} diff --git a/go.mod b/go.mod index 5866c4f..16c2cdf 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/agext/levenshtein v1.2.2 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/armon/go-radix v1.0.0 // indirect + github.com/aw-tests/sdk-for-go/v6 v6.0.0-20260717085122-b0c41417939c github.com/bgentry/speakeasy v0.1.0 // indirect github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect github.com/cloudflare/circl v1.6.1 // indirect diff --git a/go.sum b/go.sum index 587545c..ccf5941 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,8 @@ github.com/appwrite/sdk-for-go/v6 v6.0.0 h1:npGhGJNhvWx8DN7OdxTap5g22wknVSTUHAkp github.com/appwrite/sdk-for-go/v6 v6.0.0/go.mod h1:5oTdAwNsSNCY0FxwUzs1J0XAgxue2veWSdVcCSsKg8I= github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/aw-tests/sdk-for-go/v6 v6.0.0-20260717085122-b0c41417939c h1:Wu9yfIzioyOd9epEkwo+EW7AYl/BUsMiajRX3H7xTao= +github.com/aw-tests/sdk-for-go/v6 v6.0.0-20260717085122-b0c41417939c/go.mod h1:DQqf5bOjLP3TkP+AQXH8OJWiSBQt3jGB6QzTshKOFSs= github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= diff --git a/internal/common/helpers.go b/internal/common/helpers.go index 89637ec..84040ed 100644 --- a/internal/common/helpers.go +++ b/internal/common/helpers.go @@ -24,6 +24,14 @@ type AppwriteClients struct { BaseOptions []client.ClientOption // ProjectID is the provider-level default project ID. ProjectID string + + // Raw credentials, exposed so services built against a different SDK module + // (e.g. the unreleased dedicated-databases fork) can construct their own + // client without re-plumbing the provider config. + Endpoint string + APIKey string + SelfSigned bool + UserAgent string } // WithUserAgent returns a ClientOption that sets the User-Agent header to identify diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 841b5d6..cdd2900 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -2,6 +2,7 @@ package provider import ( "context" + "fmt" "os" "github.com/appwrite/sdk-for-go/v6/appwrite" @@ -17,6 +18,7 @@ import ( bucketsvc "github.com/appwrite/terraform-provider-appwrite/internal/services/bucket" columnsvc "github.com/appwrite/terraform-provider-appwrite/internal/services/column" databasesvc "github.com/appwrite/terraform-provider-appwrite/internal/services/database" + dedicateddbsvc "github.com/appwrite/terraform-provider-appwrite/internal/services/dedicateddatabase" filesvc "github.com/appwrite/terraform-provider-appwrite/internal/services/file" functionsvc "github.com/appwrite/terraform-provider-appwrite/internal/services/function" indexsvc "github.com/appwrite/terraform-provider-appwrite/internal/services/index" @@ -119,6 +121,10 @@ func (p *appwriteProvider) Configure(ctx context.Context, req provider.Configure clients := &common.AppwriteClients{ BaseOptions: baseOpts, ProjectID: projectID, + Endpoint: endpoint, + APIKey: apiKey, + SelfSigned: !config.SelfSigned.IsNull() && config.SelfSigned.ValueBool(), + UserAgent: fmt.Sprintf("terraform-provider-appwrite/%s", p.version), } resp.DataSourceData = clients @@ -128,6 +134,8 @@ func (p *appwriteProvider) Configure(ctx context.Context, req provider.Configure func (p *appwriteProvider) Resources(_ context.Context) []func() resource.Resource { return []func() resource.Resource{ databasesvc.NewDatabaseResource, + dedicateddbsvc.NewDatabaseResource, + dedicateddbsvc.NewBackupPolicyResource, tablesvc.NewTableResource, columnsvc.NewColumnResource, indexsvc.NewIndexResource, diff --git a/internal/services/dedicateddatabase/backup_policy_resource.go b/internal/services/dedicateddatabase/backup_policy_resource.go new file mode 100644 index 0000000..efa0564 --- /dev/null +++ b/internal/services/dedicateddatabase/backup_policy_resource.go @@ -0,0 +1,282 @@ +package dedicateddatabase + +import ( + "context" + "fmt" + + "github.com/appwrite/terraform-provider-appwrite/internal/common" + "github.com/aw-tests/sdk-for-go/v6/id" + "github.com/aw-tests/sdk-for-go/v6/models" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "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" +) + +var ( + _ resource.Resource = &backupPolicyResource{} + _ resource.ResourceWithConfigure = &backupPolicyResource{} + _ resource.ResourceWithImportState = &backupPolicyResource{} +) + +type backupPolicyResource struct { + clients *common.AppwriteClients +} + +type backupPolicyResourceModel struct { + ID types.String `tfsdk:"id"` + Engine types.String `tfsdk:"engine"` + DatabaseID types.String `tfsdk:"database_id"` + Name types.String `tfsdk:"name"` + Schedule types.String `tfsdk:"schedule"` + Retention types.Int64 `tfsdk:"retention"` + Type types.String `tfsdk:"type"` + Enabled types.Bool `tfsdk:"enabled"` + CreatedAt types.String `tfsdk:"created_at"` + UpdatedAt types.String `tfsdk:"updated_at"` + ProjectID types.String `tfsdk:"project_id"` +} + +func NewBackupPolicyResource() resource.Resource { + return &backupPolicyResource{} +} + +func (r *backupPolicyResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_dedicated_database_backup_policy" +} + +func (r *backupPolicyResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + forceNewString := []planmodifier.String{stringplanmodifier.RequiresReplace()} + resp.Schema = schema.Schema{ + Description: "Manages a backup policy for an Appwrite dedicated database.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The backup policy ID.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace(), stringplanmodifier.UseStateForUnknown()}, + }, + "engine": schema.StringAttribute{ + Description: fmt.Sprintf("The engine of the target database. One of: %s.", validEngines()), + Required: true, + Validators: []validator.String{stringvalidator.OneOf("postgresql", "mysql", "mongo")}, + PlanModifiers: forceNewString, + }, + "database_id": schema.StringAttribute{ + Description: "The dedicated database this policy backs up.", + Required: true, + PlanModifiers: forceNewString, + }, + "name": schema.StringAttribute{ + Description: "The backup policy name.", + Required: true, + }, + "schedule": schema.StringAttribute{ + Description: "Backup schedule in CRON format.", + Required: true, + }, + "retention": schema.Int64Attribute{ + Description: "How many days to keep each backup before automatic deletion.", + Required: true, + }, + "type": schema.StringAttribute{ + Description: "The backup type (e.g. full). Changing this forces a new policy.", + Optional: true, + Computed: true, + PlanModifiers: append([]planmodifier.String{stringplanmodifier.UseStateForUnknown()}, forceNewString...), + }, + "enabled": schema.BoolAttribute{ + Description: "Whether the policy is enabled. Defaults to true.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, + "created_at": schema.StringAttribute{ + Description: "The policy creation timestamp in ISO 8601 format.", + Computed: true, + }, + "updated_at": schema.StringAttribute{ + Description: "The policy last update timestamp in ISO 8601 format.", + Computed: true, + PlanModifiers: []planmodifier.String{common.UseStateForUnknownUnlessUpdating()}, + }, + "project_id": common.ProjectIDAttribute(), + }, + } +} + +func (r *backupPolicyResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + clients, ok := req.ProviderData.(*common.AppwriteClients) + if !ok { + resp.Diagnostics.AddError("Unexpected Resource Configure Type", fmt.Sprintf("Expected *common.AppwriteClients, got: %T", req.ProviderData)) + return + } + r.clients = clients +} + +func (r *backupPolicyResource) policyPath(engine, databaseID, policyID string) string { + base := "/" + engine + "/" + databaseID + "/backups/policies" + if policyID != "" { + return base + "/" + policyID + } + return base +} + +func (r *backupPolicyResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan backupPolicyResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + projectID, err := common.ResolveProjectID(r.clients, plan.ProjectID) + if err != nil { + resp.Diagnostics.AddError("Error resolving project ID", err.Error()) + return + } + c := engineClient(r.clients, projectID) + engine := plan.Engine.ValueString() + + policyID := plan.ID.ValueString() + if plan.ID.IsNull() || plan.ID.IsUnknown() { + policyID = id.Unique() + } + + params := map[string]interface{}{ + "policyId": policyID, + "name": plan.Name.ValueString(), + "schedule": plan.Schedule.ValueString(), + "retention": int(plan.Retention.ValueInt64()), + } + setStr(params, "type", plan.Type) + setBool(params, "enabled", plan.Enabled) + + var policy models.BackupPolicy + if err := apiCall(c, r.clients.UserAgent, "POST", r.policyPath(engine, plan.DatabaseID.ValueString(), ""), params, &policy); err != nil { + resp.Diagnostics.AddError("Error creating backup policy", common.FormatError(err)) + return + } + + plan.ProjectID = types.StringValue(projectID) + r.mapToState(&policy, &plan) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *backupPolicyResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state backupPolicyResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + projectID, err := common.ResolveProjectID(r.clients, state.ProjectID) + if err != nil { + resp.Diagnostics.AddError("Error resolving project ID", err.Error()) + return + } + c := engineClient(r.clients, projectID) + + var policy models.BackupPolicy + if err := apiCall(c, r.clients.UserAgent, "GET", r.policyPath(state.Engine.ValueString(), state.DatabaseID.ValueString(), state.ID.ValueString()), nil, &policy); err != nil { + if common.IsNotFoundError(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Error reading backup policy", common.FormatError(err)) + return + } + + state.ProjectID = types.StringValue(projectID) + r.mapToState(&policy, &state) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *backupPolicyResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan backupPolicyResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + projectID, err := common.ResolveProjectID(r.clients, plan.ProjectID) + if err != nil { + resp.Diagnostics.AddError("Error resolving project ID", err.Error()) + return + } + c := engineClient(r.clients, projectID) + + params := map[string]interface{}{ + "name": plan.Name.ValueString(), + "schedule": plan.Schedule.ValueString(), + "retention": int(plan.Retention.ValueInt64()), + } + setBool(params, "enabled", plan.Enabled) + + var policy models.BackupPolicy + if err := apiCall(c, r.clients.UserAgent, "PATCH", r.policyPath(plan.Engine.ValueString(), plan.DatabaseID.ValueString(), plan.ID.ValueString()), params, &policy); err != nil { + resp.Diagnostics.AddError("Error updating backup policy", common.FormatError(err)) + return + } + + plan.ProjectID = types.StringValue(projectID) + r.mapToState(&policy, &plan) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *backupPolicyResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state backupPolicyResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + projectID, err := common.ResolveProjectID(r.clients, state.ProjectID) + if err != nil { + resp.Diagnostics.AddError("Error resolving project ID", err.Error()) + return + } + c := engineClient(r.clients, projectID) + + if err := apiCall[any](c, r.clients.UserAgent, "DELETE", r.policyPath(state.Engine.ValueString(), state.DatabaseID.ValueString(), state.ID.ValueString()), nil, nil); err != nil && !common.IsNotFoundError(err) { + resp.Diagnostics.AddError("Error deleting backup policy", common.FormatError(err)) + } +} + +// ImportState expects "engine/database_id/policy_id". +func (r *backupPolicyResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + engine, rest, ok := splitTwo(req.ID) + if ok { + var dbID, policyID string + dbID, policyID, ok = splitTwo(rest) + if ok { + if _, valid := engines[engine]; !valid { + resp.Diagnostics.AddError("Invalid engine", fmt.Sprintf("Engine must be one of: %s", validEngines())) + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("engine"), engine)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("database_id"), dbID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), policyID)...) + return + } + } + resp.Diagnostics.AddError("Invalid import ID", fmt.Sprintf("Expected format: engine/database_id/policy_id, got: %s", req.ID)) +} + +func (r *backupPolicyResource) mapToState(policy *models.BackupPolicy, model *backupPolicyResourceModel) { + model.ID = types.StringValue(policy.Id) + model.Name = types.StringValue(policy.Name) + model.Schedule = types.StringValue(policy.Schedule) + model.Retention = types.Int64Value(int64(policy.Retention)) + model.Type = types.StringValue(policy.Type) + model.Enabled = types.BoolValue(policy.Enabled) + model.CreatedAt = types.StringValue(policy.CreatedAt) + model.UpdatedAt = types.StringValue(policy.UpdatedAt) +} diff --git a/internal/services/dedicateddatabase/helpers.go b/internal/services/dedicateddatabase/helpers.go new file mode 100644 index 0000000..dba2efb --- /dev/null +++ b/internal/services/dedicateddatabase/helpers.go @@ -0,0 +1,150 @@ +// Package dedicateddatabase implements the appwrite_dedicated_database and +// appwrite_dedicated_database_backup_policy resources. +// +// It is built against the unreleased dedicated-databases SDK fork +// (github.com/aw-tests/sdk-for-go), whose module path differs from the released +// SDK. To avoid a module-identity conflict we do NOT touch the rest of the +// provider's imports; this package alone talks to the fork and builds its own +// client from the raw provider credentials. When the feature ships in the +// official SDK, swap the import prefix below back to appwrite/sdk-for-go and +// drop the fork require from go.mod. +// +// The three engines (postgresql, mysql, mongo) expose byte-identical Create/ +// Get/Update/Delete + backup-policy APIs — same params, same response models — +// differing only in the URL segment. So instead of triplicating ~30 typed +// option builders per method, we issue the raw REST calls keyed by engine and +// decode into the shared SDK models. This mirrors the existing GetColumnRaw +// helper in internal/common. +package dedicateddatabase + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/appwrite/terraform-provider-appwrite/internal/common" + fwappwrite "github.com/aw-tests/sdk-for-go/v6/appwrite" + fwclient "github.com/aw-tests/sdk-for-go/v6/client" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// setStr/setInt/setBool add a param only when the attribute is explicitly set +// (known and non-null), so omitted optional attributes fall back to server +// defaults rather than being sent as zero values. +func setStr(params map[string]interface{}, key string, v types.String) { + if !v.IsNull() && !v.IsUnknown() { + params[key] = v.ValueString() + } +} + +func setInt(params map[string]interface{}, key string, v types.Int64) { + if !v.IsNull() && !v.IsUnknown() { + params[key] = int(v.ValueInt64()) + } +} + +func setBool(params map[string]interface{}, key string, v types.Bool) { + if !v.IsNull() && !v.IsUnknown() { + params[key] = v.ValueBool() + } +} + +// splitTwo splits "a/b" into its two non-empty halves. +func splitTwo(s string) (string, string, bool) { + parts := strings.SplitN(s, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + return parts[0], parts[1], true +} + +// engines maps the terraform `engine` value to its REST path segment. +var engines = map[string]string{ + "postgresql": "postgresql", + "mysql": "mysql", + "mongo": "mongo", +} + +// engineClient builds a fork SDK client scoped to the given project. +func engineClient(clients *common.AppwriteClients, projectID string) fwclient.Client { + opts := []fwclient.ClientOption{ + fwappwrite.WithEndpoint(clients.Endpoint), + fwappwrite.WithKey(clients.APIKey), + fwappwrite.WithProject(projectID), + } + if clients.SelfSigned { + opts = append(opts, fwappwrite.WithSelfSigned(true)) + } + return fwappwrite.NewClient(opts...) +} + +// apiCall issues a raw request and decodes the JSON response into T. Pass nil +// for out when the response body is not needed (e.g. deletes). +func apiCall[T any](c fwclient.Client, userAgent, method, path string, params map[string]interface{}, out *T) error { + headers := map[string]interface{}{ + "X-Appwrite-Project": c.Config["project"], + "content-type": "application/json", + "accept": "application/json", + "user-agent": userAgent, + } + resp, err := c.Call(method, path, headers, params) + if err != nil { + return err + } + if out == nil { + return nil + } + body, ok := resp.Result.(string) + if !ok { + return fmt.Errorf("unexpected response result type: %T", resp.Result) + } + if err := json.Unmarshal([]byte(body), out); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + return nil +} + +// waitForDatabaseReady polls the database until its status leaves +// "provisioning"/"scaling"/"restoring", returning an error on a failed status +// or when ctx is canceled. Provisioning a dedicated database is asynchronous; +// connection details are only populated once it is ready. +func waitForDatabaseReady(ctx context.Context, get func() (string, error), databaseID string) error { + deadline := time.After(30 * time.Minute) + for { + select { + case <-ctx.Done(): + return fmt.Errorf("canceled while waiting for database %q to become ready", databaseID) + case <-deadline: + return fmt.Errorf("database %q did not become ready within 30m", databaseID) + default: + } + + status, err := get() + if err != nil { + return fmt.Errorf("error checking database %q status: %w", databaseID, err) + } + switch status { + case "ready", "inactive", "paused": + return nil + case "failed": + return fmt.Errorf("database %q entered failed state", databaseID) + case "deleted": + return fmt.Errorf("database %q was deleted during provisioning", databaseID) + } + + time.Sleep(5 * time.Second) + } +} + +// validEngines is the sorted list used in validation and doc messages. +func validEngines() string { + keys := make([]string, 0, len(engines)) + for k := range engines { + keys = append(keys, k) + } + sort.Strings(keys) + return strings.Join(keys, ", ") +} diff --git a/internal/services/dedicateddatabase/helpers_test.go b/internal/services/dedicateddatabase/helpers_test.go new file mode 100644 index 0000000..138e0a7 --- /dev/null +++ b/internal/services/dedicateddatabase/helpers_test.go @@ -0,0 +1,51 @@ +package dedicateddatabase + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestParamSetters(t *testing.T) { + p := map[string]interface{}{} + setStr(p, "a", types.StringValue("x")) + setStr(p, "skip_null", types.StringNull()) + setStr(p, "skip_unknown", types.StringUnknown()) + setInt(p, "n", types.Int64Value(3)) + setInt(p, "skip_int", types.Int64Null()) + setBool(p, "b", types.BoolValue(true)) + setBool(p, "skip_bool", types.BoolNull()) + + if len(p) != 3 { + t.Fatalf("expected 3 params, got %d: %v", len(p), p) + } + if p["a"] != "x" || p["n"] != 3 || p["b"] != true { + t.Fatalf("wrong values: %v", p) + } +} + +func TestSplitTwo(t *testing.T) { + cases := []struct { + in string + a, b string + valid bool + }{ + {"postgresql/abc", "postgresql", "abc", true}, + {"mysql/db/policy", "mysql", "db/policy", true}, // only first slash splits + {"noslash", "", "", false}, + {"/trailing", "", "", false}, + {"leading/", "", "", false}, + } + for _, c := range cases { + a, b, ok := splitTwo(c.in) + if ok != c.valid || a != c.a || b != c.b { + t.Errorf("splitTwo(%q) = (%q,%q,%v), want (%q,%q,%v)", c.in, a, b, ok, c.a, c.b, c.valid) + } + } +} + +func TestValidEnginesDeterministic(t *testing.T) { + if got := validEngines(); got != "mongo, mysql, postgresql" { + t.Fatalf("validEngines() = %q, want sorted list", got) + } +} diff --git a/internal/services/dedicateddatabase/resource.go b/internal/services/dedicateddatabase/resource.go new file mode 100644 index 0000000..0bb8588 --- /dev/null +++ b/internal/services/dedicateddatabase/resource.go @@ -0,0 +1,391 @@ +package dedicateddatabase + +import ( + "context" + "fmt" + + "github.com/appwrite/terraform-provider-appwrite/internal/common" + "github.com/aw-tests/sdk-for-go/v6/id" + "github.com/aw-tests/sdk-for-go/v6/models" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &databaseResource{} + _ resource.ResourceWithConfigure = &databaseResource{} + _ resource.ResourceWithImportState = &databaseResource{} +) + +type databaseResource struct { + clients *common.AppwriteClients +} + +type databaseResourceModel struct { + ID types.String `tfsdk:"id"` + Engine types.String `tfsdk:"engine"` + Name types.String `tfsdk:"name"` + Version types.String `tfsdk:"version"` + Specification types.String `tfsdk:"specification"` + API types.String `tfsdk:"api"` + + Replicas types.Int64 `tfsdk:"replicas"` + SyncMode types.String `tfsdk:"sync_mode"` + NetworkIdleTimeoutSeconds types.Int64 `tfsdk:"network_idle_timeout_seconds"` + NetworkIPAllowlist types.List `tfsdk:"network_ip_allowlist"` + IdleTimeoutMinutes types.Int64 `tfsdk:"idle_timeout_minutes"` + Pitr types.Bool `tfsdk:"pitr"` + PitrRetentionDays types.Int64 `tfsdk:"pitr_retention_days"` + StorageAutoscaling types.Bool `tfsdk:"storage_autoscaling"` + StorageAutoscalingThresholdPercent types.Int64 `tfsdk:"storage_autoscaling_threshold_percent"` + StorageAutoscalingMaxGb types.Int64 `tfsdk:"storage_autoscaling_max_gb"` + + Backend types.String `tfsdk:"backend"` + Hostname types.String `tfsdk:"hostname"` + ConnectionPort types.Int64 `tfsdk:"connection_port"` + ConnectionUser types.String `tfsdk:"connection_user"` + ConnectionPassword types.String `tfsdk:"connection_password"` + ConnectionString types.String `tfsdk:"connection_string"` + Ssl types.Bool `tfsdk:"ssl"` + Status types.String `tfsdk:"status"` + CPU types.Int64 `tfsdk:"cpu"` + Memory types.Int64 `tfsdk:"memory"` + Storage types.Int64 `tfsdk:"storage"` + CreatedAt types.String `tfsdk:"created_at"` + UpdatedAt types.String `tfsdk:"updated_at"` + ProjectID types.String `tfsdk:"project_id"` +} + +func NewDatabaseResource() resource.Resource { + return &databaseResource{} +} + +func (r *databaseResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_dedicated_database" +} + +func (r *databaseResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + optionalComputedInt := func(desc string) schema.Int64Attribute { + return schema.Int64Attribute{Description: desc, Optional: true, Computed: true} + } + optionalComputedBool := func(desc string) schema.BoolAttribute { + return schema.BoolAttribute{Description: desc, Optional: true, Computed: true} + } + + resp.Schema = schema.Schema{ + Description: "Manages an Appwrite dedicated database (a managed PostgreSQL, MySQL, or MongoDB instance).", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The dedicated database ID. Must be unique within the project.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace(), stringplanmodifier.UseStateForUnknown()}, + }, + "engine": schema.StringAttribute{ + Description: fmt.Sprintf("The database engine. One of: %s.", validEngines()), + Required: true, + Validators: []validator.String{stringvalidator.OneOf("postgresql", "mysql", "mongo")}, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "name": schema.StringAttribute{ + Description: "The database display name.", + Required: true, + }, + "version": schema.StringAttribute{ + Description: "The engine version. Changing this forces a new database; use the Appwrite console to perform an in-place upgrade.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace(), stringplanmodifier.UseStateForUnknown()}, + }, + "specification": schema.StringAttribute{ + Description: "The compute specification identifier (e.g. a size tier). See the engine's list-specifications API for valid values.", + Optional: true, + Computed: true, + }, + "api": schema.StringAttribute{ + Description: "The product API that owns this database: nativedb, documentsdb, or vectorsdb. Changing this forces a new database.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace(), stringplanmodifier.UseStateForUnknown()}, + }, + + "replicas": optionalComputedInt("Number of high-availability replicas. High availability is enabled when greater than 0."), + "sync_mode": schema.StringAttribute{Description: "Replication sync mode: async, sync, or quorum.", Optional: true, Computed: true}, + "network_idle_timeout_seconds": optionalComputedInt("Connection idle timeout in seconds."), + "network_ip_allowlist": schema.ListAttribute{Description: "IP addresses/CIDR ranges allowed to connect.", Optional: true, Computed: true, ElementType: types.StringType}, + "idle_timeout_minutes": optionalComputedInt("Minutes of inactivity before the container scales to zero."), + "pitr": optionalComputedBool("Whether point-in-time recovery is enabled."), + "pitr_retention_days": optionalComputedInt("Number of days to retain point-in-time-recovery data."), + "storage_autoscaling": optionalComputedBool("Whether automatic storage expansion is enabled."), + "storage_autoscaling_threshold_percent": optionalComputedInt("Storage usage percentage that triggers automatic expansion."), + "storage_autoscaling_max_gb": optionalComputedInt("Maximum storage size in GB for autoscaling. 0 means no limit."), + + "backend": schema.StringAttribute{Description: "Database backend provider (prisma or edge).", Computed: true}, + "hostname": schema.StringAttribute{Description: "Database hostname for connections.", Computed: true}, + "connection_port": schema.Int64Attribute{Description: "Database port for connections.", Computed: true}, + "connection_user": schema.StringAttribute{Description: "Database username for connections.", Computed: true}, + "connection_password": schema.StringAttribute{Description: "Database password for connections.", Computed: true, Sensitive: true}, + "connection_string": schema.StringAttribute{Description: "Full database connection string (URI format).", Computed: true, Sensitive: true}, + "ssl": schema.BoolAttribute{Description: "Whether SSL/TLS is required for client connections.", Computed: true}, + "status": schema.StringAttribute{Description: "Database status (e.g. provisioning, ready, paused, failed).", Computed: true}, + "cpu": schema.Int64Attribute{Description: "CPU allocated in millicores.", Computed: true}, + "memory": schema.Int64Attribute{Description: "Memory allocated in MB.", Computed: true}, + "storage": schema.Int64Attribute{Description: "Storage allocated in GB.", Computed: true}, + "created_at": schema.StringAttribute{Description: "The database creation timestamp in ISO 8601 format.", Computed: true}, + "updated_at": schema.StringAttribute{ + Description: "The database last update timestamp in ISO 8601 format.", + Computed: true, + PlanModifiers: []planmodifier.String{common.UseStateForUnknownUnlessUpdating()}, + }, + "project_id": common.ProjectIDAttribute(), + }, + } +} + +func (r *databaseResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + clients, ok := req.ProviderData.(*common.AppwriteClients) + if !ok { + resp.Diagnostics.AddError("Unexpected Resource Configure Type", fmt.Sprintf("Expected *common.AppwriteClients, got: %T", req.ProviderData)) + return + } + r.clients = clients +} + +func (r *databaseResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan databaseResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + projectID, err := common.ResolveProjectID(r.clients, plan.ProjectID) + if err != nil { + resp.Diagnostics.AddError("Error resolving project ID", err.Error()) + return + } + c := engineClient(r.clients, projectID) + engine := plan.Engine.ValueString() + + dbID := plan.ID.ValueString() + if plan.ID.IsNull() || plan.ID.IsUnknown() { + dbID = id.Unique() + } + + params := map[string]interface{}{ + "databaseId": dbID, + "name": plan.Name.ValueString(), + } + setStr(params, "version", plan.Version) + setStr(params, "specification", plan.Specification) + setStr(params, "api", plan.API) + setStr(params, "syncMode", plan.SyncMode) + setInt(params, "replicas", plan.Replicas) + setInt(params, "networkIdleTimeoutSeconds", plan.NetworkIdleTimeoutSeconds) + setInt(params, "idleTimeoutMinutes", plan.IdleTimeoutMinutes) + setBool(params, "pitr", plan.Pitr) + setInt(params, "pitrRetentionDays", plan.PitrRetentionDays) + setBool(params, "storageAutoscaling", plan.StorageAutoscaling) + setInt(params, "storageAutoscalingThresholdPercent", plan.StorageAutoscalingThresholdPercent) + setInt(params, "storageAutoscalingMaxGb", plan.StorageAutoscalingMaxGb) + if allow, ok := r.stringList(ctx, plan.NetworkIPAllowlist, &resp.Diagnostics); ok { + params["networkIPAllowlist"] = allow + } + if resp.Diagnostics.HasError() { + return + } + + var db models.DedicatedDatabase + if err := apiCall(c, r.clients.UserAgent, "POST", "/"+engine, params, &db); err != nil { + resp.Diagnostics.AddError("Error creating dedicated database", common.FormatError(err)) + return + } + + // Provisioning is asynchronous; wait so connection details are populated. + if err := waitForDatabaseReady(ctx, func() (string, error) { + var cur models.DedicatedDatabase + if err := apiCall(c, r.clients.UserAgent, "GET", "/"+engine+"/"+db.Id, nil, &cur); err != nil { + return "", err + } + return cur.Status, nil + }, db.Id); err != nil { + resp.Diagnostics.AddError("Error waiting for dedicated database", err.Error()) + return + } + + var ready models.DedicatedDatabase + if err := apiCall(c, r.clients.UserAgent, "GET", "/"+engine+"/"+db.Id, nil, &ready); err != nil { + resp.Diagnostics.AddError("Error reading dedicated database after create", common.FormatError(err)) + return + } + + plan.ProjectID = types.StringValue(projectID) + r.mapToState(ctx, &ready, &plan, &resp.Diagnostics) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *databaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state databaseResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + projectID, err := common.ResolveProjectID(r.clients, state.ProjectID) + if err != nil { + resp.Diagnostics.AddError("Error resolving project ID", err.Error()) + return + } + c := engineClient(r.clients, projectID) + engine := state.Engine.ValueString() + + var db models.DedicatedDatabase + if err := apiCall(c, r.clients.UserAgent, "GET", "/"+engine+"/"+state.ID.ValueString(), nil, &db); err != nil { + if common.IsNotFoundError(err) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Error reading dedicated database", common.FormatError(err)) + return + } + + state.ProjectID = types.StringValue(projectID) + r.mapToState(ctx, &db, &state, &resp.Diagnostics) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *databaseResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan databaseResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + projectID, err := common.ResolveProjectID(r.clients, plan.ProjectID) + if err != nil { + resp.Diagnostics.AddError("Error resolving project ID", err.Error()) + return + } + c := engineClient(r.clients, projectID) + engine := plan.Engine.ValueString() + + params := map[string]interface{}{"name": plan.Name.ValueString()} + setStr(params, "specification", plan.Specification) + setStr(params, "syncMode", plan.SyncMode) + setInt(params, "replicas", plan.Replicas) + setInt(params, "networkIdleTimeoutSeconds", plan.NetworkIdleTimeoutSeconds) + setInt(params, "idleTimeoutMinutes", plan.IdleTimeoutMinutes) + setBool(params, "pitr", plan.Pitr) + setInt(params, "pitrRetentionDays", plan.PitrRetentionDays) + setBool(params, "storageAutoscaling", plan.StorageAutoscaling) + setInt(params, "storageAutoscalingThresholdPercent", plan.StorageAutoscalingThresholdPercent) + setInt(params, "storageAutoscalingMaxGb", plan.StorageAutoscalingMaxGb) + if allow, ok := r.stringList(ctx, plan.NetworkIPAllowlist, &resp.Diagnostics); ok { + params["networkIPAllowlist"] = allow + } + if resp.Diagnostics.HasError() { + return + } + + var db models.DedicatedDatabase + if err := apiCall(c, r.clients.UserAgent, "PATCH", "/"+engine+"/"+plan.ID.ValueString(), params, &db); err != nil { + resp.Diagnostics.AddError("Error updating dedicated database", common.FormatError(err)) + return + } + + plan.ProjectID = types.StringValue(projectID) + r.mapToState(ctx, &db, &plan, &resp.Diagnostics) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *databaseResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state databaseResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + projectID, err := common.ResolveProjectID(r.clients, state.ProjectID) + if err != nil { + resp.Diagnostics.AddError("Error resolving project ID", err.Error()) + return + } + c := engineClient(r.clients, projectID) + engine := state.Engine.ValueString() + + if err := apiCall[any](c, r.clients.UserAgent, "DELETE", "/"+engine+"/"+state.ID.ValueString(), nil, nil); err != nil && !common.IsNotFoundError(err) { + resp.Diagnostics.AddError("Error deleting dedicated database", common.FormatError(err)) + } +} + +// ImportState expects "engine/database_id" since the engine selects the API endpoint. +func (r *databaseResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + engine, dbID, ok := splitTwo(req.ID) + if !ok { + resp.Diagnostics.AddError("Invalid import ID", fmt.Sprintf("Expected format: engine/database_id (e.g. postgresql/abc123), got: %s", req.ID)) + return + } + if _, valid := engines[engine]; !valid { + resp.Diagnostics.AddError("Invalid engine", fmt.Sprintf("Engine must be one of: %s", validEngines())) + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("engine"), engine)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), dbID)...) +} + +func (r *databaseResource) stringList(ctx context.Context, list types.List, diags *diag.Diagnostics) ([]string, bool) { + if list.IsNull() || list.IsUnknown() { + return nil, false + } + var out []string + diags.Append(list.ElementsAs(ctx, &out, false)...) + return out, true +} + +func (r *databaseResource) mapToState(ctx context.Context, db *models.DedicatedDatabase, model *databaseResourceModel, diags *diag.Diagnostics) { + // engine is intentionally not overwritten: it is the config-supplied endpoint + // selector (postgresql/mysql/mongo), which may differ from the API's engine + // field (e.g. "mongodb"). + model.ID = types.StringValue(db.Id) + model.Name = types.StringValue(db.Name) + model.Version = types.StringValue(db.Version) + model.Specification = types.StringValue(db.Specification) + model.API = types.StringValue(db.Api) + + model.Replicas = types.Int64Value(int64(db.Replicas)) + model.SyncMode = types.StringValue(db.SyncMode) + model.NetworkIdleTimeoutSeconds = types.Int64Value(int64(db.NetworkIdleTimeoutSeconds)) + model.IdleTimeoutMinutes = types.Int64Value(int64(db.IdleTimeoutMinutes)) + model.Pitr = types.BoolValue(db.Pitr) + model.PitrRetentionDays = types.Int64Value(int64(db.PitrRetentionDays)) + model.StorageAutoscaling = types.BoolValue(db.StorageAutoscaling) + model.StorageAutoscalingThresholdPercent = types.Int64Value(int64(db.StorageAutoscalingThresholdPercent)) + model.StorageAutoscalingMaxGb = types.Int64Value(int64(db.StorageAutoscalingMaxGb)) + + allow, d := types.ListValueFrom(ctx, types.StringType, db.NetworkIPAllowlist) + diags.Append(d...) + model.NetworkIPAllowlist = allow + + model.Backend = types.StringValue(db.Backend) + model.Hostname = types.StringValue(db.Hostname) + model.ConnectionPort = types.Int64Value(int64(db.ConnectionPort)) + model.ConnectionUser = types.StringValue(db.ConnectionUser) + model.ConnectionPassword = types.StringValue(db.ConnectionPassword) + model.ConnectionString = types.StringValue(db.ConnectionString) + model.Ssl = types.BoolValue(db.Ssl) + model.Status = types.StringValue(db.Status) + model.CPU = types.Int64Value(int64(db.Cpu)) + model.Memory = types.Int64Value(int64(db.Memory)) + model.Storage = types.Int64Value(int64(db.Storage)) + model.CreatedAt = types.StringValue(db.CreatedAt) + model.UpdatedAt = types.StringValue(db.UpdatedAt) +}