package provider import ( "context" "crypto/rand" "fmt" "strings" "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/maxvojtkov/terraform-provider-dokploy/internal/client" ) // libSQL is the sixth managed database engine, added in Dokploy v0.30.0. // // Its create endpoint is the most awkward in the API: it insists on eleven // keys being present (several only meaningfully null), it will not generate an // appName the way the other engines do, and it returns `true` rather than the // created row. All three are worked around below. type libsqlModel struct { ID types.String `tfsdk:"id" dokploy:"libsqlId,id"` Name types.String `tfsdk:"name" dokploy:"name"` AppName types.String `tfsdk:"app_name" dokploy:"appName"` Description types.String `tfsdk:"description" dokploy:"description,nullable"` EnvironmentID types.String `tfsdk:"environment_id" dokploy:"environmentId"` ServerID types.String `tfsdk:"server_id" dokploy:"serverId,nullable"` DatabaseUser types.String `tfsdk:"database_user" dokploy:"databaseUser"` DatabasePassword types.String `tfsdk:"database_password" dokploy:"databasePassword"` DockerImage types.String `tfsdk:"docker_image" dokploy:"dockerImage"` SqldNode types.String `tfsdk:"sqld_node" dokploy:"sqldNode"` SqldPrimaryURL types.String `tfsdk:"sqld_primary_url" dokploy:"sqldPrimaryUrl,nullable"` EnableNamespaces types.Bool `tfsdk:"enable_namespaces" dokploy:"enableNamespaces"` Command types.String `tfsdk:"command" dokploy:"command,nullable"` Env types.String `tfsdk:"env" dokploy:"env,nullable"` MemoryReserve types.String `tfsdk:"memory_reservation" dokploy:"memoryReservation,nullable"` MemoryLimit types.String `tfsdk:"memory_limit" dokploy:"memoryLimit,nullable"` CPUReserve types.String `tfsdk:"cpu_reservation" dokploy:"cpuReservation,nullable"` CPULimit types.String `tfsdk:"cpu_limit" dokploy:"cpuLimit,nullable"` Replicas types.Int64 `tfsdk:"replicas" dokploy:"replicas"` ExternalPort types.Int64 `tfsdk:"external_port" dokploy:"externalPort,nullable"` ExternalAdminPort types.Int64 `tfsdk:"external_admin_port" dokploy:"externalAdminPort,nullable"` ExternalGRPCPort types.Int64 `tfsdk:"external_grpc_port" dokploy:"externalGRPCPort,nullable"` NetworkIDs types.List `tfsdk:"network_ids" dokploy:"networkIds"` DetachDokployNetwork types.Bool `tfsdk:"detach_dokploy_network" dokploy:"detachDokployNetwork"` HealthCheckSwarm jsontypes.Normalized `tfsdk:"health_check_swarm" dokploy:"healthCheckSwarm,nullable"` RestartPolicySwarm jsontypes.Normalized `tfsdk:"restart_policy_swarm" dokploy:"restartPolicySwarm,nullable"` PlacementSwarm jsontypes.Normalized `tfsdk:"placement_swarm" dokploy:"placementSwarm,nullable"` UpdateConfigSwarm jsontypes.Normalized `tfsdk:"update_config_swarm" dokploy:"updateConfigSwarm,nullable"` RollbackConfigSwarm jsontypes.Normalized `tfsdk:"rollback_config_swarm" dokploy:"rollbackConfigSwarm,nullable"` ModeSwarm jsontypes.Normalized `tfsdk:"mode_swarm" dokploy:"modeSwarm,nullable"` LabelsSwarm jsontypes.Normalized `tfsdk:"labels_swarm" dokploy:"labelsSwarm,nullable"` NetworkSwarm jsontypes.Normalized `tfsdk:"network_swarm" dokploy:"networkSwarm,nullable"` EndpointSpecSwarm jsontypes.Normalized `tfsdk:"endpoint_spec_swarm" dokploy:"endpointSpecSwarm,nullable"` StopGracePeriodSwarm types.Int64 `tfsdk:"stop_grace_period_swarm" dokploy:"stopGracePeriodSwarm,nullable"` ApplicationStatus types.String `tfsdk:"application_status" dokploy:"applicationStatus,ro"` CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"` } func libsqlResource() ResourceSpec { return ResourceSpec{ Name: "libsql", UpdateAfterCreate: true, CreateProc: "libsql.create", ReadProc: "libsql.one", UpdateProc: "libsql.update", DeleteProc: "libsql.remove", NewModel: func() any { return &libsqlModel{} }, // `libsql.create` rejects a body that merely omits a key it considers // required, even when null is the only sensible value, and it refuses // to generate an appName. Fill both in. CreateDefaults: func(model any) map[string]any { libsql, ok := model.(*libsqlModel) if !ok { return nil } defaults := map[string]any{ "description": nil, "serverId": nil, "sqldPrimaryUrl": nil, } if libsql.AppName.IsNull() || libsql.AppName.IsUnknown() { defaults["appName"] = generateAppName(libsql.Name.ValueString()) } return defaults }, // `libsql.create` returns `true`, so the new ID is found by diffing the // environment's libSQL list. ListIDs: func(ctx context.Context, api *client.Client, model any) (map[string]struct{}, error) { libsql, ok := model.(*libsqlModel) if !ok { return nil, fmt.Errorf("expected *libsqlModel, got %T", model) } raw, err := api.Query(ctx, "environment.one", map[string]any{ "environmentId": libsql.EnvironmentID.ValueString(), }) if err != nil { return nil, err } return collectNestedIDs(raw, "libsql", "libsqlId") }, Schema: schema.Schema{ MarkdownDescription: databaseNote("libSQL") + "\n\n" + "libSQL runs as a `sqld` server. A `primary` node owns the data; a `replica` node follows a " + "primary named by `sqld_primary_url`.", Attributes: map[string]schema.Attribute{ "id": computedID("Unique libSQL identifier."), "name": requiredString("Display name of the database."), "app_name": optionalComputedReplaceString("Unique Docker service name. Generated from `name` when " + "omitted, because Dokploy's libSQL endpoint does not generate one. Changing it forces a new database."), "description": optionalString("Free-form description."), "environment_id": requiredReplaceString("Environment this database belongs to."), "server_id": optionalReplaceString("Remote server to deploy on. Omit to use the Dokploy host itself."), "database_user": requiredString("Database user to create."), "database_password": sensitiveString("Password for the database user.", true), "docker_image": requiredString("libSQL server image to run, for example " + "`ghcr.io/tursodatabase/libsql-server:latest`."), "sqld_node": enumStringWithDefault("Role this node plays in a libSQL cluster.", sqldNodes, "primary"), "sqld_primary_url": optionalString("URL of the primary node, when `sqld_node` is `replica`."), "enable_namespaces": optionalComputedBool("Serve multiple logical databases from one instance " + "through libSQL namespaces."), "command": optionalString("Override the container entrypoint command."), "env": optionalString("Environment variables in `KEY=value` format, one per line."), "memory_reservation": optionalString("Soft memory reservation, for example `256m`."), "memory_limit": optionalString("Hard memory limit, for example `512m`."), "cpu_reservation": optionalString("Soft CPU reservation, for example `0.5`."), "cpu_limit": optionalString("Hard CPU limit, for example `1`."), "replicas": optionalComputedInt("Number of replicas to run."), "external_port": schema.Int64Attribute{Optional: true, MarkdownDescription: "Host port exposing the HTTP API."}, "external_admin_port": schema.Int64Attribute{Optional: true, MarkdownDescription: "Host port exposing the admin API."}, "external_grpc_port": schema.Int64Attribute{Optional: true, MarkdownDescription: "Host port exposing the gRPC replication endpoint."}, "network_ids": optionalComputedStringList("IDs of additional Docker networks to attach."), "detach_dokploy_network": optionalComputedBool("Detach the service from the shared `dokploy-network`."), "health_check_swarm": optionalJSON("Docker Swarm health check configuration, as a JSON object."), "restart_policy_swarm": optionalJSON("Docker Swarm restart policy, as a JSON object."), "placement_swarm": optionalJSON("Docker Swarm placement constraints, as a JSON object."), "update_config_swarm": optionalJSON("Docker Swarm rolling update configuration, as a JSON object."), "rollback_config_swarm": optionalJSON("Docker Swarm rollback configuration, as a JSON object."), "mode_swarm": optionalJSON("Docker Swarm service mode, as a JSON object."), "labels_swarm": optionalJSON("Docker Swarm service labels, as a JSON object."), "network_swarm": optionalJSON("Docker Swarm network attachments, as a JSON array."), "endpoint_spec_swarm": optionalJSON("Docker Swarm endpoint specification, as a JSON object."), "stop_grace_period_swarm": schema.Int64Attribute{Optional: true, MarkdownDescription: "Grace period in nanoseconds before a container is killed."}, "application_status": computedString("Current status reported by Dokploy: `idle`, `running`, `done` or `error`."), "created_at": computedString("RFC 3339 timestamp of when the database was created."), }, }, } } // generateAppName mirrors how Dokploy names a service: a slug of the display // name plus a short random suffix, so two databases called "cache" in // different projects do not collide on the Docker host. func generateAppName(name string) string { var slug strings.Builder lastDash := true for _, r := range strings.ToLower(name) { switch { case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'): slug.WriteRune(r) lastDash = false case !lastDash: slug.WriteByte('-') lastDash = true } } base := strings.Trim(slug.String(), "-") if base == "" { base = "libsql" } return base + "-" + randomSuffix(6) } const suffixAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789" func randomSuffix(n int) string { buf := make([]byte, n) if _, err := rand.Read(buf); err != nil { // crypto/rand does not fail in practice; a fixed suffix still yields a // usable name and Dokploy rejects a genuine collision. return strings.Repeat("0", n) } for i, b := range buf { buf[i] = suffixAlphabet[int(b)%len(suffixAlphabet)] } return string(buf) }