A Terraform provider for Dokploy

Plugin-framework provider covering projects, environments, applications,
Compose stacks, managed databases, domains, mounts, ports, redirects,
basic auth, registries, SSH keys, certificates and backup destinations,
over Dokploy's tRPC-over-REST API.

The shim package exposes the provider to other Go modules, which is how
pulumi-dokploy bridges it.
This commit is contained in:
2026-08-09 12:17:26 +03:00
commit a6d8aa8b52
160 changed files with 24260 additions and 0 deletions

280
internal/client/client.go Normal file
View File

@@ -0,0 +1,280 @@
// Package client implements a thin HTTP client for the Dokploy API.
//
// Dokploy exposes its entire tRPC router over REST via @dokploy/trpc-openapi.
// Every procedure is reachable at `<host>/api/<router>.<procedure>`:
//
// queries -> GET with flat query parameters
// mutations -> POST with a flat JSON body
//
// Authentication is a static API token sent in the `x-api-key` header.
package client
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
const defaultTimeout = 60 * time.Second
// Client talks to a single Dokploy instance.
type Client struct {
baseURL string
token string
http *http.Client
// Several create endpoints validate an organizationId that the server then
// overrides with the token's own organization. It is fetched once and
// reused.
orgOnce sync.Once
orgID string
orgErr error
}
// OrganizationID returns the organization the API token belongs to.
//
// Dokploy's Zod schemas for `sshKey.create`, `certificates.create` and friends
// require an organizationId even though the router replaces it with the
// session's own organization, so the provider has to supply something valid.
func (c *Client) OrganizationID(ctx context.Context) (string, error) {
c.orgOnce.Do(func() {
raw, err := c.Query(ctx, "user.get", nil)
if err != nil {
c.orgErr = fmt.Errorf("resolving the token's organization via user.get: %w", err)
return
}
var payload struct {
OrganizationID string `json:"organizationId"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
c.orgErr = fmt.Errorf("decoding user.get response: %w", err)
return
}
if payload.OrganizationID == "" {
c.orgErr = fmt.Errorf("user.get did not report an organizationId")
return
}
c.orgID = payload.OrganizationID
})
return c.orgID, c.orgErr
}
// New builds a client for the given host (e.g. "https://dokploy.example.com").
// A trailing slash and/or a trailing "/api" are both tolerated.
func New(host, token string, timeout time.Duration, insecureClient *http.Client) (*Client, error) {
trimmed := strings.TrimRight(strings.TrimSpace(host), "/")
if trimmed == "" {
return nil, fmt.Errorf("host must not be empty")
}
trimmed = strings.TrimSuffix(trimmed, "/api")
u, err := url.Parse(trimmed)
if err != nil {
return nil, fmt.Errorf("invalid host %q: %w", host, err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("host must start with http:// or https://, got %q", host)
}
httpClient := insecureClient
if httpClient == nil {
httpClient = &http.Client{}
}
if timeout <= 0 {
timeout = defaultTimeout
}
httpClient.Timeout = timeout
return &Client{baseURL: trimmed + "/api", token: token, http: httpClient}, nil
}
// APIError is a structured Dokploy error response.
type APIError struct {
StatusCode int
Message string `json:"message"`
Code string `json:"code"`
Path string
// FieldErrors holds Zod per-field validation messages, when present.
FieldErrors map[string][]string
raw string
}
func (e *APIError) Error() string {
var b strings.Builder
fmt.Fprintf(&b, "dokploy API error (HTTP %d)", e.StatusCode)
if e.Code != "" {
fmt.Fprintf(&b, " %s", e.Code)
}
if e.Path != "" {
fmt.Fprintf(&b, " on %s", e.Path)
}
if e.Message != "" {
fmt.Fprintf(&b, ": %s", e.Message)
}
for field, msgs := range e.FieldErrors {
fmt.Fprintf(&b, "\n - %s: %s", field, strings.Join(msgs, "; "))
}
if e.Message == "" && len(e.FieldErrors) == 0 && e.raw != "" {
fmt.Fprintf(&b, ": %s", truncate(e.raw, 500))
}
return b.String()
}
// IsNotFound reports whether err represents a missing resource. Terraform uses
// this to drop a resource from state instead of failing the run.
func IsNotFound(err error) bool {
var apiErr *APIError
if !errors.As(err, &apiErr) {
return false
}
if apiErr.StatusCode == http.StatusNotFound || apiErr.Code == "NOT_FOUND" {
return true
}
// Dokploy frequently surfaces a missing row as a 500 with a message from
// the service layer rather than a typed NOT_FOUND.
msg := strings.ToLower(apiErr.Message)
return strings.Contains(msg, "not found") || strings.Contains(msg, "doesn't exist")
}
// Query performs a GET against a tRPC query procedure. Non-nil values in input
// are flattened into query parameters.
func (c *Client) Query(ctx context.Context, procedure string, input map[string]any) (json.RawMessage, error) {
endpoint := c.baseURL + "/" + procedure
if len(input) > 0 {
values := url.Values{}
for k, v := range input {
if v == nil {
continue
}
values.Set(k, stringify(v))
}
if encoded := values.Encode(); encoded != "" {
endpoint += "?" + encoded
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
return c.do(req, procedure)
}
// Mutate performs a POST against a tRPC mutation procedure with a JSON body.
func (c *Client) Mutate(ctx context.Context, procedure string, input map[string]any) (json.RawMessage, error) {
if input == nil {
input = map[string]any{}
}
body, err := json.Marshal(input)
if err != nil {
return nil, fmt.Errorf("encoding request body for %s: %w", procedure, err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/"+procedure, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return c.do(req, procedure)
}
func (c *Client) do(req *http.Request, procedure string) (json.RawMessage, error) {
req.Header.Set("x-api-key", c.token)
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("calling %s: %w", procedure, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response from %s: %w", procedure, err)
}
if resp.StatusCode >= 400 {
return nil, parseAPIError(resp.StatusCode, procedure, raw)
}
return json.RawMessage(raw), nil
}
func parseAPIError(status int, procedure string, raw []byte) *APIError {
apiErr := &APIError{StatusCode: status, Path: procedure, raw: string(raw)}
var envelope struct {
Message string `json:"message"`
Code string `json:"code"`
Data struct {
Code string `json:"code"`
Path string `json:"path"`
ZodError *struct {
FieldErrors map[string][]string `json:"fieldErrors"`
FormErrors []string `json:"formErrors"`
} `json:"zodError"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return apiErr
}
apiErr.Message = envelope.Message
apiErr.Code = firstNonEmpty(envelope.Code, envelope.Data.Code)
if envelope.Data.Path != "" {
apiErr.Path = envelope.Data.Path
}
if z := envelope.Data.ZodError; z != nil {
if len(z.FieldErrors) > 0 {
apiErr.FieldErrors = z.FieldErrors
}
if len(z.FormErrors) > 0 {
if apiErr.FieldErrors == nil {
apiErr.FieldErrors = map[string][]string{}
}
apiErr.FieldErrors["(form)"] = z.FormErrors
}
}
return apiErr
}
func stringify(v any) string {
switch t := v.(type) {
case string:
return t
case bool:
return strconv.FormatBool(t)
case int:
return strconv.Itoa(t)
case int64:
return strconv.FormatInt(t, 10)
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
default:
return fmt.Sprintf("%v", t)
}
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}

View File

@@ -0,0 +1,467 @@
package provider
import (
"context"
"encoding/json"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/datasource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/maxvojtkov/terraform-provider-dokploy/internal/client"
)
// baseDataSource wires the shared API client into each data source.
type baseDataSource struct {
api *client.Client
}
func (d *baseDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
api, ok := req.ProviderData.(*client.Client)
if !ok {
resp.Diagnostics.AddError(
"Unexpected provider data",
fmt.Sprintf("Expected *client.Client, got %T. This is a bug in the provider.", req.ProviderData),
)
return
}
d.api = api
}
// ------------------------------------------------------------ dokploy_project
type projectDataSourceModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
Description types.String `tfsdk:"description"`
Env types.String `tfsdk:"env"`
CreatedAt types.String `tfsdk:"created_at"`
OrganizationID types.String `tfsdk:"organization_id"`
DefaultEnvironmentID types.String `tfsdk:"default_environment_id"`
Environments types.List `tfsdk:"environments"`
}
type projectDataSource struct{ baseDataSource }
func newProjectDataSource() datasource.DataSource { return &projectDataSource{} }
func (d *projectDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_project"
}
var environmentObjectType = types.ObjectType{AttrTypes: map[string]attr.Type{
"id": types.StringType,
"name": types.StringType,
"is_default": types.BoolType,
}}
func (d *projectDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Look up an existing Dokploy project by ID, including its environments.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{Required: true, MarkdownDescription: "Project identifier."},
"name": schema.StringAttribute{Computed: true, MarkdownDescription: "Project name."},
"description": schema.StringAttribute{Computed: true, MarkdownDescription: "Project description."},
"env": schema.StringAttribute{Computed: true, MarkdownDescription: "Project-wide environment variables."},
"created_at": schema.StringAttribute{Computed: true, MarkdownDescription: "Creation timestamp."},
"organization_id": schema.StringAttribute{Computed: true, MarkdownDescription: "Owning organization."},
"default_environment_id": schema.StringAttribute{
Computed: true,
MarkdownDescription: "ID of the project's default environment.",
},
"environments": schema.ListAttribute{
Computed: true,
ElementType: environmentObjectType,
MarkdownDescription: "Every environment in the project.",
},
},
}
}
func (d *projectDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
var config projectDataSourceModel
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}
raw, err := d.api.Query(ctx, "project.one", map[string]any{"projectId": config.ID.ValueString()})
if err != nil {
resp.Diagnostics.AddError("Failed to read project", err.Error())
return
}
var payload struct {
ProjectID string `json:"projectId"`
Name string `json:"name"`
Description *string `json:"description"`
Env string `json:"env"`
CreatedAt string `json:"createdAt"`
OrganizationID string `json:"organizationId"`
Environments []struct {
EnvironmentID string `json:"environmentId"`
Name string `json:"name"`
IsDefault bool `json:"isDefault"`
} `json:"environments"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
resp.Diagnostics.AddError("Failed to decode project", err.Error())
return
}
config.Name = types.StringValue(payload.Name)
config.Description = optionalStringValue(payload.Description)
config.Env = types.StringValue(payload.Env)
config.CreatedAt = types.StringValue(payload.CreatedAt)
config.OrganizationID = types.StringValue(payload.OrganizationID)
config.DefaultEnvironmentID = types.StringNull()
elements := make([]attr.Value, 0, len(payload.Environments))
for _, env := range payload.Environments {
if env.IsDefault {
config.DefaultEnvironmentID = types.StringValue(env.EnvironmentID)
}
object, diags := types.ObjectValue(environmentObjectType.AttrTypes, map[string]attr.Value{
"id": types.StringValue(env.EnvironmentID),
"name": types.StringValue(env.Name),
"is_default": types.BoolValue(env.IsDefault),
})
resp.Diagnostics.Append(diags...)
elements = append(elements, object)
}
if resp.Diagnostics.HasError() {
return
}
list, diags := types.ListValue(environmentObjectType, elements)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
config.Environments = list
resp.Diagnostics.Append(resp.State.Set(ctx, &config)...)
}
// ----------------------------------------------------------- dokploy_projects
type projectsDataSourceModel struct {
Projects types.List `tfsdk:"projects"`
}
type projectsDataSource struct{ baseDataSource }
func newProjectsDataSource() datasource.DataSource { return &projectsDataSource{} }
func (d *projectsDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_projects"
}
var projectSummaryType = types.ObjectType{AttrTypes: map[string]attr.Type{
"id": types.StringType,
"name": types.StringType,
"description": types.StringType,
"default_environment_id": types.StringType,
}}
func (d *projectsDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "List every Dokploy project the API token can see.",
Attributes: map[string]schema.Attribute{
"projects": schema.ListAttribute{
Computed: true,
ElementType: projectSummaryType,
MarkdownDescription: "All visible projects.",
},
},
}
}
func (d *projectsDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) {
raw, err := d.api.Query(ctx, "project.all", nil)
if err != nil {
resp.Diagnostics.AddError("Failed to list projects", err.Error())
return
}
var payload []struct {
ProjectID string `json:"projectId"`
Name string `json:"name"`
Description *string `json:"description"`
Environments []struct {
EnvironmentID string `json:"environmentId"`
IsDefault bool `json:"isDefault"`
} `json:"environments"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
resp.Diagnostics.AddError("Failed to decode projects", err.Error())
return
}
elements := make([]attr.Value, 0, len(payload))
for _, project := range payload {
defaultEnv := types.StringNull()
for _, env := range project.Environments {
if env.IsDefault {
defaultEnv = types.StringValue(env.EnvironmentID)
break
}
}
object, diags := types.ObjectValue(projectSummaryType.AttrTypes, map[string]attr.Value{
"id": types.StringValue(project.ProjectID),
"name": types.StringValue(project.Name),
"description": optionalStringValue(project.Description),
"default_environment_id": defaultEnv,
})
resp.Diagnostics.Append(diags...)
elements = append(elements, object)
}
if resp.Diagnostics.HasError() {
return
}
list, diags := types.ListValue(projectSummaryType, elements)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, &projectsDataSourceModel{Projects: list})...)
}
// -------------------------------------------------------- dokploy_environment
type environmentDataSourceModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
Description types.String `tfsdk:"description"`
ProjectID types.String `tfsdk:"project_id"`
Env types.String `tfsdk:"env"`
IsDefault types.Bool `tfsdk:"is_default"`
CreatedAt types.String `tfsdk:"created_at"`
}
type environmentDataSource struct{ baseDataSource }
func newEnvironmentDataSource() datasource.DataSource { return &environmentDataSource{} }
func (d *environmentDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_environment"
}
func (d *environmentDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Look up an existing environment by ID.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{Required: true, MarkdownDescription: "Environment identifier."},
"name": schema.StringAttribute{Computed: true, MarkdownDescription: "Environment name."},
"description": schema.StringAttribute{Computed: true, MarkdownDescription: "Environment description."},
"project_id": schema.StringAttribute{Computed: true, MarkdownDescription: "Owning project."},
"env": schema.StringAttribute{Computed: true, MarkdownDescription: "Environment-wide variables."},
"is_default": schema.BoolAttribute{Computed: true, MarkdownDescription: "Whether this is the project's default environment."},
"created_at": schema.StringAttribute{Computed: true, MarkdownDescription: "Creation timestamp."},
},
}
}
func (d *environmentDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
var config environmentDataSourceModel
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}
raw, err := d.api.Query(ctx, "environment.one", map[string]any{"environmentId": config.ID.ValueString()})
if err != nil {
resp.Diagnostics.AddError("Failed to read environment", err.Error())
return
}
var payload struct {
Name string `json:"name"`
Description *string `json:"description"`
ProjectID string `json:"projectId"`
Env string `json:"env"`
IsDefault bool `json:"isDefault"`
CreatedAt string `json:"createdAt"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
resp.Diagnostics.AddError("Failed to decode environment", err.Error())
return
}
config.Name = types.StringValue(payload.Name)
config.Description = optionalStringValue(payload.Description)
config.ProjectID = types.StringValue(payload.ProjectID)
config.Env = types.StringValue(payload.Env)
config.IsDefault = types.BoolValue(payload.IsDefault)
config.CreatedAt = types.StringValue(payload.CreatedAt)
resp.Diagnostics.Append(resp.State.Set(ctx, &config)...)
}
// -------------------------------------------------------- dokploy_application
type applicationDataSourceModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
AppName types.String `tfsdk:"app_name"`
Description types.String `tfsdk:"description"`
EnvironmentID types.String `tfsdk:"environment_id"`
ApplicationStatus types.String `tfsdk:"application_status"`
SourceType types.String `tfsdk:"source_type"`
BuildType types.String `tfsdk:"build_type"`
CreatedAt types.String `tfsdk:"created_at"`
}
type applicationDataSource struct{ baseDataSource }
func newApplicationDataSource() datasource.DataSource { return &applicationDataSource{} }
func (d *applicationDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_application"
}
func (d *applicationDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Look up an existing application by ID.",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{Required: true, MarkdownDescription: "Application identifier."},
"name": schema.StringAttribute{Computed: true, MarkdownDescription: "Application name."},
"app_name": schema.StringAttribute{Computed: true, MarkdownDescription: "Docker service name."},
"description": schema.StringAttribute{Computed: true, MarkdownDescription: "Application description."},
"environment_id": schema.StringAttribute{Computed: true, MarkdownDescription: "Owning environment."},
"application_status": schema.StringAttribute{Computed: true, MarkdownDescription: "Current status."},
"source_type": schema.StringAttribute{Computed: true, MarkdownDescription: "Source of the code or image."},
"build_type": schema.StringAttribute{Computed: true, MarkdownDescription: "Build strategy."},
"created_at": schema.StringAttribute{Computed: true, MarkdownDescription: "Creation timestamp."},
},
}
}
func (d *applicationDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
var config applicationDataSourceModel
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}
raw, err := d.api.Query(ctx, "application.one", map[string]any{"applicationId": config.ID.ValueString()})
if err != nil {
resp.Diagnostics.AddError("Failed to read application", err.Error())
return
}
var payload struct {
Name string `json:"name"`
AppName string `json:"appName"`
Description *string `json:"description"`
EnvironmentID string `json:"environmentId"`
ApplicationStatus string `json:"applicationStatus"`
SourceType string `json:"sourceType"`
BuildType string `json:"buildType"`
CreatedAt string `json:"createdAt"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
resp.Diagnostics.AddError("Failed to decode application", err.Error())
return
}
config.Name = types.StringValue(payload.Name)
config.AppName = types.StringValue(payload.AppName)
config.Description = optionalStringValue(payload.Description)
config.EnvironmentID = types.StringValue(payload.EnvironmentID)
config.ApplicationStatus = types.StringValue(payload.ApplicationStatus)
config.SourceType = types.StringValue(payload.SourceType)
config.BuildType = types.StringValue(payload.BuildType)
config.CreatedAt = types.StringValue(payload.CreatedAt)
resp.Diagnostics.Append(resp.State.Set(ctx, &config)...)
}
// ------------------------------------------------------------ dokploy_servers
type serversDataSourceModel struct {
Servers types.List `tfsdk:"servers"`
}
type serversDataSource struct{ baseDataSource }
func newServersDataSource() datasource.DataSource { return &serversDataSource{} }
func (d *serversDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_servers"
}
var serverSummaryType = types.ObjectType{AttrTypes: map[string]attr.Type{
"id": types.StringType,
"name": types.StringType,
"ip_address": types.StringType,
"server_status": types.StringType,
}}
func (d *serversDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "List the remote servers registered with Dokploy. Use a server's `id` as the " +
"`server_id` of an application, stack, or database to deploy it away from the Dokploy host.",
Attributes: map[string]schema.Attribute{
"servers": schema.ListAttribute{
Computed: true,
ElementType: serverSummaryType,
MarkdownDescription: "All registered servers.",
},
},
}
}
func (d *serversDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) {
raw, err := d.api.Query(ctx, "server.all", nil)
if err != nil {
resp.Diagnostics.AddError("Failed to list servers", err.Error())
return
}
var payload []struct {
ServerID string `json:"serverId"`
Name string `json:"name"`
IPAddress *string `json:"ipAddress"`
ServerStatus *string `json:"serverStatus"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
resp.Diagnostics.AddError("Failed to decode servers", err.Error())
return
}
elements := make([]attr.Value, 0, len(payload))
for _, server := range payload {
object, diags := types.ObjectValue(serverSummaryType.AttrTypes, map[string]attr.Value{
"id": types.StringValue(server.ServerID),
"name": types.StringValue(server.Name),
"ip_address": optionalStringValue(server.IPAddress),
"server_status": optionalStringValue(server.ServerStatus),
})
resp.Diagnostics.Append(diags...)
elements = append(elements, object)
}
if resp.Diagnostics.HasError() {
return
}
list, diags := types.ListValue(serverSummaryType, elements)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, &serversDataSourceModel{Servers: list})...)
}
func optionalStringValue(value *string) types.String {
if value == nil {
return types.StringNull()
}
return types.StringValue(*value)
}

View File

@@ -0,0 +1,508 @@
package provider
import (
"context"
"encoding/json"
"fmt"
"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-log/tflog"
"github.com/maxvojtkov/terraform-provider-dokploy/internal/client"
"github.com/maxvojtkov/terraform-provider-dokploy/internal/tfmap"
)
// ResourceSpec describes a Dokploy resource in terms of the tRPC procedures
// that back it. The generic resource implementation below turns one of these
// into a full Terraform resource.
type ResourceSpec struct {
// Name is the type name without the provider prefix, e.g. "project"
// becomes `dokploy_project`.
Name string
// Schema is the Terraform schema presented to practitioners.
Schema schema.Schema
// NewModel returns a pointer to a zero-valued model struct.
NewModel func() any
CreateProc string
ReadProc string
UpdateProc string
DeleteProc string
// CreateResponseKey extracts a nested object from the create response.
// `project.create`, for example, returns {"project":{...},"environment":{...}}.
CreateResponseKey string
// UpdateAfterCreate issues an update immediately after create. Dokploy's
// `application.create` and `compose.create` accept only a small subset of
// fields; everything else has to be written through `*.update`.
UpdateAfterCreate bool
// DeleteExtra contributes fixed fields to the delete body.
DeleteExtra map[string]any
// DeleteBody contributes fields derived from state, for delete procedures
// that take options — such as compose's required `deleteVolumes`.
DeleteBody func(model any) map[string]any
// ImportIDAttribute names the attribute that `terraform import` populates.
// Defaults to "id".
ImportIDAttribute string
// NeedsOrganizationID adds the token's organization to the create body.
// Dokploy validates `organizationId` on a few create endpoints and then
// overwrites it with the session's own organization, so it has to be
// present but its value does not matter.
NeedsOrganizationID bool
// ListIDs snapshots the IDs that currently exist, for resources whose
// create procedure returns no identifier (Dokploy's `sshKey.create`,
// `redirects.create` and `security.create` return `true` or nothing).
// The generic create diffs the snapshot taken before and after the call to
// discover the new ID.
ListIDs func(ctx context.Context, api *client.Client, model any) (map[string]struct{}, error)
// PostRead derives extra model fields from the raw read response, for
// values that are not plain top-level columns (for example a project's
// default environment, which arrives nested under `environments`).
PostRead func(raw json.RawMessage, model any) error
}
// genericResource adapts a ResourceSpec to the plugin framework interfaces.
type genericResource struct {
spec ResourceSpec
api *client.Client
}
var (
_ resource.Resource = &genericResource{}
_ resource.ResourceWithConfigure = &genericResource{}
_ resource.ResourceWithImportState = &genericResource{}
)
func newGenericResource(spec ResourceSpec) func() resource.Resource {
return func() resource.Resource { return &genericResource{spec: spec} }
}
func (r *genericResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_" + r.spec.Name
}
func (r *genericResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = r.spec.Schema
}
func (r *genericResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
api, ok := req.ProviderData.(*client.Client)
if !ok {
resp.Diagnostics.AddError(
"Unexpected provider data",
fmt.Sprintf("Expected *client.Client, got %T. This is a bug in the provider.", req.ProviderData),
)
return
}
r.api = api
}
func (r *genericResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
model := r.spec.NewModel()
resp.Diagnostics.Append(req.Plan.Get(ctx, model)...)
if resp.Diagnostics.HasError() {
return
}
body, err := tfmap.ToAPI(model, tfmap.PhaseCreate)
if err != nil {
resp.Diagnostics.AddError("Failed to build create request", err.Error())
return
}
if r.spec.NeedsOrganizationID {
orgID, err := r.api.OrganizationID(ctx)
if err != nil {
resp.Diagnostics.AddError("Failed to resolve the Dokploy organization", err.Error())
return
}
body["organizationId"] = orgID
}
// Snapshot existing IDs when the create procedure will not return one.
var before map[string]struct{}
if r.spec.ListIDs != nil {
before, err = r.spec.ListIDs(ctx, r.api, model)
if err != nil {
resp.Diagnostics.AddError(
fmt.Sprintf("Failed to enumerate existing %s resources", r.spec.Name),
err.Error(),
)
return
}
}
tflog.Debug(ctx, "dokploy create", map[string]any{"procedure": r.spec.CreateProc})
raw, err := r.api.Mutate(ctx, r.spec.CreateProc, body)
if err != nil {
resp.Diagnostics.AddError(fmt.Sprintf("Failed to create %s", r.spec.Name), err.Error())
return
}
var id string
if r.spec.ListIDs != nil {
id, err = discoverNewID(ctx, r.api, r.spec, model, before)
if err != nil {
resp.Diagnostics.AddError(fmt.Sprintf("Failed to identify the new %s", r.spec.Name), err.Error())
return
}
if err := tfmap.SetID(model, id); err != nil {
resp.Diagnostics.AddError("Failed to set ID", err.Error())
return
}
} else {
created, err := unwrap(raw, r.spec.CreateResponseKey)
if err != nil {
resp.Diagnostics.AddError(fmt.Sprintf("Unexpected create response for %s", r.spec.Name), err.Error())
return
}
if err := tfmap.FromAPI(created, model); err != nil {
resp.Diagnostics.AddError(fmt.Sprintf("Failed to decode created %s", r.spec.Name), err.Error())
return
}
if id, err = tfmap.IDValue(model); err != nil || id == "" {
resp.Diagnostics.AddError(
fmt.Sprintf("Create response for %s did not contain an ID", r.spec.Name),
fmt.Sprintf("response: %s", truncate(string(created), 500)),
)
return
}
}
// Dokploy's create procedures accept only a subset of fields for some
// resources. Write the remainder through update before reading back.
if r.spec.UpdateAfterCreate && r.spec.UpdateProc != "" {
// Re-read the plan so update-only fields are taken from configuration
// rather than from the create response.
planModel := r.spec.NewModel()
resp.Diagnostics.Append(req.Plan.Get(ctx, planModel)...)
if resp.Diagnostics.HasError() {
return
}
if err := tfmap.SetID(planModel, id); err != nil {
resp.Diagnostics.AddError("Failed to set ID on plan model", err.Error())
return
}
updateBody, err := tfmap.ToAPI(planModel, tfmap.PhaseUpdate)
if err != nil {
resp.Diagnostics.AddError("Failed to build post-create update request", err.Error())
return
}
if len(updateBody) > 1 { // more than the ID alone
if _, err := r.api.Mutate(ctx, r.spec.UpdateProc, updateBody); err != nil {
resp.Diagnostics.AddError(
fmt.Sprintf("Failed to apply configuration to new %s", r.spec.Name),
fmt.Sprintf("The %s was created (id %s) but configuring it failed: %s", r.spec.Name, id, err.Error()),
)
return
}
}
}
if !r.refresh(ctx, model, id, &resp.Diagnostics, true) {
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, model)...)
}
func (r *genericResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
model := r.spec.NewModel()
resp.Diagnostics.Append(req.State.Get(ctx, model)...)
if resp.Diagnostics.HasError() {
return
}
id, err := tfmap.IDValue(model)
if err != nil {
resp.Diagnostics.AddError("Failed to read ID from state", err.Error())
return
}
raw, err := r.read(ctx, id)
if err != nil {
if client.IsNotFound(err) {
tflog.Info(ctx, "dokploy resource is gone, removing from state", map[string]any{
"resource": r.spec.Name, "id": id,
})
resp.State.RemoveResource(ctx)
return
}
resp.Diagnostics.AddError(fmt.Sprintf("Failed to read %s", r.spec.Name), err.Error())
return
}
if err := tfmap.FromAPI(raw, model); err != nil {
resp.Diagnostics.AddError(fmt.Sprintf("Failed to decode %s", r.spec.Name), err.Error())
return
}
if r.spec.PostRead != nil {
if err := r.spec.PostRead(raw, model); err != nil {
resp.Diagnostics.AddError(fmt.Sprintf("Failed to derive attributes for %s", r.spec.Name), err.Error())
return
}
}
resp.Diagnostics.Append(resp.State.Set(ctx, model)...)
}
func (r *genericResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
model := r.spec.NewModel()
resp.Diagnostics.Append(req.Plan.Get(ctx, model)...)
if resp.Diagnostics.HasError() {
return
}
// The plan may not carry the ID if it is computed; fall back to state.
id, err := tfmap.IDValue(model)
if err != nil {
resp.Diagnostics.AddError("Failed to read ID from plan", err.Error())
return
}
if id == "" {
stateModel := r.spec.NewModel()
resp.Diagnostics.Append(req.State.Get(ctx, stateModel)...)
if resp.Diagnostics.HasError() {
return
}
if id, err = tfmap.IDValue(stateModel); err != nil {
resp.Diagnostics.AddError("Failed to read ID from state", err.Error())
return
}
if err := tfmap.SetID(model, id); err != nil {
resp.Diagnostics.AddError("Failed to set ID on plan model", err.Error())
return
}
}
if r.spec.UpdateProc == "" {
resp.Diagnostics.AddError(
fmt.Sprintf("%s does not support in-place updates", r.spec.Name),
"This is a bug in the provider: the resource should mark all attributes as RequiresReplace.",
)
return
}
body, err := tfmap.ToAPI(model, tfmap.PhaseUpdate)
if err != nil {
resp.Diagnostics.AddError("Failed to build update request", err.Error())
return
}
tflog.Debug(ctx, "dokploy update", map[string]any{"procedure": r.spec.UpdateProc, "id": id})
if _, err := r.api.Mutate(ctx, r.spec.UpdateProc, body); err != nil {
resp.Diagnostics.AddError(fmt.Sprintf("Failed to update %s", r.spec.Name), err.Error())
return
}
if !r.refresh(ctx, model, id, &resp.Diagnostics, true) {
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, model)...)
}
func (r *genericResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
model := r.spec.NewModel()
resp.Diagnostics.Append(req.State.Get(ctx, model)...)
if resp.Diagnostics.HasError() {
return
}
id, err := tfmap.IDValue(model)
if err != nil {
resp.Diagnostics.AddError("Failed to read ID from state", err.Error())
return
}
idField, err := tfmap.IDAPIName(model)
if err != nil {
resp.Diagnostics.AddError("Failed to resolve ID field", err.Error())
return
}
body := map[string]any{idField: id}
for k, v := range r.spec.DeleteExtra {
body[k] = v
}
if r.spec.DeleteBody != nil {
for k, v := range r.spec.DeleteBody(model) {
body[k] = v
}
}
tflog.Debug(ctx, "dokploy delete", map[string]any{"procedure": r.spec.DeleteProc, "id": id})
if _, err := r.api.Mutate(ctx, r.spec.DeleteProc, body); err != nil {
if client.IsNotFound(err) {
return // Already gone; deletion is idempotent.
}
resp.Diagnostics.AddError(fmt.Sprintf("Failed to delete %s", r.spec.Name), err.Error())
}
}
func (r *genericResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
attribute := r.spec.ImportIDAttribute
if attribute == "" {
attribute = "id"
}
resource.ImportStatePassthroughID(ctx, path.Root(attribute), req, resp)
}
// read fetches the current remote representation of a resource.
func (r *genericResource) read(ctx context.Context, id string) (json.RawMessage, error) {
model := r.spec.NewModel()
idField, err := tfmap.IDAPIName(model)
if err != nil {
return nil, err
}
return r.api.Query(ctx, r.spec.ReadProc, map[string]any{idField: id})
}
// refresh re-reads the resource and decodes it into model. It returns false if
// diagnostics were added.
func (r *genericResource) refresh(ctx context.Context, model any, id string, diags *diag.Diagnostics, failOnMissing bool) bool {
raw, err := r.read(ctx, id)
if err != nil {
if client.IsNotFound(err) && !failOnMissing {
return true
}
diags.AddError(
fmt.Sprintf("Failed to read back %s after write", r.spec.Name),
fmt.Sprintf("The %s was written (id %s) but could not be read back: %s", r.spec.Name, id, err.Error()),
)
return false
}
if err := tfmap.FromAPI(raw, model); err != nil {
diags.AddError(fmt.Sprintf("Failed to decode %s", r.spec.Name), err.Error())
return false
}
if r.spec.PostRead != nil {
if err := r.spec.PostRead(raw, model); err != nil {
diags.AddError(fmt.Sprintf("Failed to derive attributes for %s", r.spec.Name), err.Error())
return false
}
}
// Dokploy omits some columns from its responses. Anything the read did not
// report stays unknown, which Terraform rejects after apply, so settle it
// to null.
if err := tfmap.NullifyUnknown(model); err != nil {
diags.AddError(fmt.Sprintf("Failed to finalize %s state", r.spec.Name), err.Error())
return false
}
return true
}
// discoverNewID diffs the ID snapshots taken around a create call.
func discoverNewID(
ctx context.Context,
api *client.Client,
spec ResourceSpec,
model any,
before map[string]struct{},
) (string, error) {
after, err := spec.ListIDs(ctx, api, model)
if err != nil {
return "", err
}
var found []string
for id := range after {
if _, existed := before[id]; !existed {
found = append(found, id)
}
}
switch len(found) {
case 1:
return found[0], nil
case 0:
return "", fmt.Errorf(
"the %s was created but no new record appeared; it may have been removed concurrently",
spec.Name,
)
default:
return "", fmt.Errorf(
"the %s was created but %d new records appeared, so the new one is ambiguous. "+
"This happens when several are created outside Terraform at the same time; "+
"import the resource manually to continue",
spec.Name, len(found),
)
}
}
// collectIDs pulls a field out of every element of a JSON array.
func collectIDs(raw json.RawMessage, idField string) (map[string]struct{}, error) {
var items []map[string]json.RawMessage
if err := json.Unmarshal(raw, &items); err != nil {
return nil, fmt.Errorf("decoding list response: %w", err)
}
out := make(map[string]struct{}, len(items))
for _, item := range items {
payload, ok := item[idField]
if !ok {
continue
}
var id string
if err := json.Unmarshal(payload, &id); err != nil {
continue
}
out[id] = struct{}{}
}
return out, nil
}
// collectNestedIDs pulls IDs out of an array nested under a key, such as the
// `redirects` array inside an `application.one` response.
func collectNestedIDs(raw json.RawMessage, arrayKey, idField string) (map[string]struct{}, error) {
var envelope map[string]json.RawMessage
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}
nested, ok := envelope[arrayKey]
if !ok {
return map[string]struct{}{}, nil
}
return collectIDs(nested, idField)
}
// unwrap pulls a nested object out of a create response when the API wraps it.
func unwrap(raw json.RawMessage, key string) (json.RawMessage, error) {
if key == "" {
return raw, nil
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil, fmt.Errorf("decoding response envelope: %w", err)
}
nested, ok := envelope[key]
if !ok {
return nil, fmt.Errorf("expected key %q in response, got keys %v", key, keysOf(envelope))
}
return nested, nil
}
func keysOf(m map[string]json.RawMessage) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}

View File

@@ -0,0 +1,171 @@
package provider
import (
"context"
"crypto/tls"
"net/http"
"os"
"strconv"
"time"
"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/provider"
"github.com/hashicorp/terraform-plugin-framework/provider/schema"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/maxvojtkov/terraform-provider-dokploy/internal/client"
)
// New returns the provider factory used by main.go and by acceptance tests.
func New(version string) func() provider.Provider {
return func() provider.Provider {
return &dokployProvider{version: version}
}
}
type dokployProvider struct {
version string
}
type providerModel struct {
Host types.String `tfsdk:"host"`
APIKey types.String `tfsdk:"api_key"`
Timeout types.Int64 `tfsdk:"timeout_seconds"`
SkipVerify types.Bool `tfsdk:"insecure_skip_verify"`
}
func (p *dokployProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) {
resp.TypeName = "dokploy"
resp.Version = p.version
}
func (p *dokployProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Manage [Dokploy](https://dokploy.com) projects, environments, applications, " +
"compose stacks, databases and networking with Terraform.",
Attributes: map[string]schema.Attribute{
"host": schema.StringAttribute{
Optional: true,
MarkdownDescription: "Base URL of the Dokploy instance, for example `https://dokploy.example.com`. " +
"A trailing `/api` is optional. May also be set with the `DOKPLOY_HOST` environment variable.",
},
"api_key": schema.StringAttribute{
Optional: true,
Sensitive: true,
MarkdownDescription: "API token generated in Dokploy under *Settings -> Profile -> API/CLI*. " +
"May also be set with the `DOKPLOY_API_KEY` environment variable.",
},
"timeout_seconds": schema.Int64Attribute{
Optional: true,
MarkdownDescription: "Per-request timeout in seconds. Defaults to `60`. May also be set with the " +
"`DOKPLOY_TIMEOUT_SECONDS` environment variable.",
},
"insecure_skip_verify": schema.BoolAttribute{
Optional: true,
MarkdownDescription: "Skip TLS certificate verification. Only use this for instances behind a " +
"self-signed certificate. Defaults to `false`.",
},
},
}
}
func (p *dokployProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
var config providerModel
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}
host := stringOrEnv(config.Host, "DOKPLOY_HOST")
apiKey := stringOrEnv(config.APIKey, "DOKPLOY_API_KEY")
if host == "" {
resp.Diagnostics.AddAttributeError(
path.Root("host"),
"Missing Dokploy host",
"Set the `host` provider attribute or the DOKPLOY_HOST environment variable.",
)
}
if apiKey == "" {
resp.Diagnostics.AddAttributeError(
path.Root("api_key"),
"Missing Dokploy API key",
"Set the `api_key` provider attribute or the DOKPLOY_API_KEY environment variable.",
)
}
if resp.Diagnostics.HasError() {
return
}
timeout := 60 * time.Second
if !config.Timeout.IsNull() && !config.Timeout.IsUnknown() {
timeout = time.Duration(config.Timeout.ValueInt64()) * time.Second
} else if env := os.Getenv("DOKPLOY_TIMEOUT_SECONDS"); env != "" {
if seconds, err := strconv.Atoi(env); err == nil {
timeout = time.Duration(seconds) * time.Second
}
}
var httpClient *http.Client
if config.SkipVerify.ValueBool() {
httpClient = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // opt-in
},
}
}
api, err := client.New(host, apiKey, timeout, httpClient)
if err != nil {
resp.Diagnostics.AddError("Invalid Dokploy provider configuration", err.Error())
return
}
resp.DataSourceData = api
resp.ResourceData = api
}
func (p *dokployProvider) Resources(_ context.Context) []func() resource.Resource {
return []func() resource.Resource{
newGenericResource(projectResource()),
newGenericResource(environmentResource()),
newGenericResource(applicationResource()),
newGenericResource(composeResource()),
newGenericResource(postgresResource()),
newGenericResource(mysqlResource()),
newGenericResource(mariadbResource()),
newGenericResource(mongoResource()),
newGenericResource(redisResource()),
newGenericResource(domainResource()),
newGenericResource(mountResource()),
newGenericResource(portResource()),
newGenericResource(redirectResource()),
newGenericResource(securityResource()),
newGenericResource(registryResource()),
newGenericResource(sshKeyResource()),
newGenericResource(certificateResource()),
newGenericResource(destinationResource()),
}
}
func (p *dokployProvider) DataSources(_ context.Context) []func() datasource.DataSource {
return []func() datasource.DataSource{
newProjectDataSource,
newProjectsDataSource,
newEnvironmentDataSource,
newApplicationDataSource,
newServersDataSource,
}
}
func stringOrEnv(value types.String, envVar string) string {
if !value.IsNull() && !value.IsUnknown() && value.ValueString() != "" {
return value.ValueString()
}
return os.Getenv(envVar)
}

View File

@@ -0,0 +1,293 @@
package provider_test
import (
"fmt"
"os"
"testing"
"github.com/hashicorp/terraform-plugin-framework/providerserver"
"github.com/hashicorp/terraform-plugin-go/tfprotov6"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/maxvojtkov/terraform-provider-dokploy/internal/provider"
)
// Acceptance tests talk to a real Dokploy instance. They create resources with
// a `tfacc-` prefix and destroy them again, so point them at a scratch
// instance rather than production:
//
// TF_ACC=1 \
// DOKPLOY_HOST=https://dokploy.example.com \
// DOKPLOY_API_KEY=... \
// go test ./internal/provider/ -v -timeout 30m
var protoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){
"dokploy": providerserver.NewProtocol6WithError(provider.New("test")()),
}
func testAccPreCheck(t *testing.T) {
t.Helper()
for _, key := range []string{"DOKPLOY_HOST", "DOKPLOY_API_KEY"} {
if os.Getenv(key) == "" {
t.Fatalf("%s must be set for acceptance tests", key)
}
}
}
func TestAccProject(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: protoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: `
resource "dokploy_project" "test" {
name = "tfacc-project"
description = "created by acceptance tests"
}`,
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("dokploy_project.test", "name", "tfacc-project"),
resource.TestCheckResourceAttr("dokploy_project.test", "description", "created by acceptance tests"),
resource.TestCheckResourceAttrSet("dokploy_project.test", "id"),
// Dokploy creates a default environment with every project.
resource.TestCheckResourceAttrSet("dokploy_project.test", "default_environment_id"),
resource.TestCheckResourceAttrSet("dokploy_project.test", "organization_id"),
),
},
{
ResourceName: "dokploy_project.test",
ImportState: true,
ImportStateVerify: true,
},
{
Config: `
resource "dokploy_project" "test" {
name = "tfacc-project-renamed"
description = "updated"
env = "SHARED=1"
}`,
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("dokploy_project.test", "name", "tfacc-project-renamed"),
resource.TestCheckResourceAttr("dokploy_project.test", "description", "updated"),
resource.TestCheckResourceAttr("dokploy_project.test", "env", "SHARED=1"),
),
},
},
})
}
func TestAccEnvironment(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: protoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: `
resource "dokploy_project" "test" {
name = "tfacc-env-project"
}
resource "dokploy_environment" "test" {
name = "tfacc-staging"
project_id = dokploy_project.test.id
env = "STAGE=1"
}`,
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("dokploy_environment.test", "name", "tfacc-staging"),
// env is only writable through a follow-up update; assert it stuck.
resource.TestCheckResourceAttr("dokploy_environment.test", "env", "STAGE=1"),
resource.TestCheckResourceAttr("dokploy_environment.test", "is_default", "false"),
),
},
},
})
}
// Applications are created with a minimal payload and then configured through
// application.update, so this asserts that update-only fields survive create.
func TestAccApplicationDockerSource(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: protoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: testAccApplicationConfig("512m", 1),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("dokploy_application.test", "source_type", "docker"),
resource.TestCheckResourceAttr("dokploy_application.test", "docker_image", "traefik/whoami:latest"),
resource.TestCheckResourceAttr("dokploy_application.test", "memory_limit", "512m"),
resource.TestCheckResourceAttr("dokploy_application.test", "replicas", "1"),
resource.TestCheckResourceAttr("dokploy_application.test", "env", "GREETING=hello"),
resource.TestCheckResourceAttrSet("dokploy_application.test", "app_name"),
),
},
{
ResourceName: "dokploy_application.test",
ImportState: true,
ImportStateVerify: true,
},
{
// An in-place update must not force replacement.
Config: testAccApplicationConfig("1g", 3),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("dokploy_application.test", "memory_limit", "1g"),
resource.TestCheckResourceAttr("dokploy_application.test", "replicas", "3"),
),
},
},
})
}
func testAccApplicationConfig(memoryLimit string, replicas int) string {
return fmt.Sprintf(`
resource "dokploy_project" "test" {
name = "tfacc-app-project"
}
resource "dokploy_application" "test" {
name = "tfacc-app"
environment_id = dokploy_project.test.default_environment_id
source_type = "docker"
docker_image = "traefik/whoami:latest"
env = "GREETING=hello"
memory_limit = %q
replicas = %d
}`, memoryLimit, replicas)
}
func TestAccComposeAndDomain(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: protoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: `
resource "dokploy_project" "test" {
name = "tfacc-compose-project"
}
resource "dokploy_compose" "test" {
name = "tfacc-stack"
environment_id = dokploy_project.test.default_environment_id
compose_type = "docker-compose"
source_type = "raw"
compose_file = <<-YAML
services:
whoami:
image: traefik/whoami:latest
YAML
}
resource "dokploy_domain" "test" {
compose_id = dokploy_compose.test.id
domain_type = "compose"
service_name = "whoami"
host = "tfacc.example.invalid"
port = 80
https = false
certificate_type = "none"
}`,
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("dokploy_compose.test", "compose_type", "docker-compose"),
resource.TestCheckResourceAttrSet("dokploy_compose.test", "app_name"),
resource.TestCheckResourceAttr("dokploy_domain.test", "host", "tfacc.example.invalid"),
resource.TestCheckResourceAttr("dokploy_domain.test", "service_name", "whoami"),
),
},
},
})
}
// Databases follow the same create-then-update path as applications.
func TestAccPostgres(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: protoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: `
resource "dokploy_project" "test" {
name = "tfacc-pg-project"
}
resource "dokploy_postgres" "test" {
name = "tfacc-pg"
environment_id = dokploy_project.test.default_environment_id
docker_image = "postgres:16-alpine"
database_name = "acc"
database_user = "acc"
database_password = "acc-password"
memory_limit = "512m"
}`,
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("dokploy_postgres.test", "database_name", "acc"),
// memory_limit is rejected by postgres.create and must be
// written by the follow-up update.
resource.TestCheckResourceAttr("dokploy_postgres.test", "memory_limit", "512m"),
),
},
},
})
}
// redirects.create returns `true` rather than a row, so the provider finds the
// new ID by diffing the application's redirect list.
func TestAccRedirectIDDiscovery(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: protoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: `
resource "dokploy_project" "test" {
name = "tfacc-redirect-project"
}
resource "dokploy_application" "test" {
name = "tfacc-redirect-app"
environment_id = dokploy_project.test.default_environment_id
source_type = "docker"
docker_image = "traefik/whoami:latest"
}
resource "dokploy_redirect" "test" {
application_id = dokploy_application.test.id
regex = "^https://old\\.(.*)"
replacement = "https://new.$${1}"
permanent = true
}`,
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttrSet("dokploy_redirect.test", "id"),
resource.TestCheckResourceAttr("dokploy_redirect.test", "permanent", "true"),
),
},
},
})
}
func TestAccProjectsDataSource(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: protoV6ProviderFactories,
Steps: []resource.TestStep{
{
Config: `
resource "dokploy_project" "test" {
name = "tfacc-ds-project"
}
data "dokploy_project" "test" {
id = dokploy_project.test.id
}`,
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("data.dokploy_project.test", "name", "tfacc-ds-project"),
resource.TestCheckResourceAttrSet("data.dokploy_project.test", "default_environment_id"),
resource.TestCheckResourceAttr("data.dokploy_project.test", "environments.#", "1"),
),
},
},
})
}

View File

@@ -0,0 +1,190 @@
package provider
import (
"context"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/maxvojtkov/terraform-provider-dokploy/internal/client"
)
// ------------------------------------------------------------------ Registry
type registryModel struct {
ID types.String `tfsdk:"id" dokploy:"registryId,id"`
RegistryName types.String `tfsdk:"registry_name" dokploy:"registryName"`
Username types.String `tfsdk:"username" dokploy:"username"`
Password types.String `tfsdk:"password" dokploy:"password"`
RegistryURL types.String `tfsdk:"registry_url" dokploy:"registryUrl"`
RegistryType types.String `tfsdk:"registry_type" dokploy:"registryType"`
ImagePrefix types.String `tfsdk:"image_prefix" dokploy:"imagePrefix,nullable"`
ServerID types.String `tfsdk:"server_id" dokploy:"serverId,create"`
CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"`
}
func registryResource() ResourceSpec {
return ResourceSpec{
Name: "registry",
CreateProc: "registry.create",
ReadProc: "registry.one",
UpdateProc: "registry.update",
DeleteProc: "registry.remove",
NewModel: func() any { return &registryModel{} },
Schema: schema.Schema{
MarkdownDescription: "A container registry that Dokploy pushes built images to and pulls them from.",
Attributes: map[string]schema.Attribute{
"id": computedID("Unique registry identifier."),
"registry_name": requiredString("Display name of the registry."),
"username": requiredString("Username used to authenticate to the registry."),
"password": sensitiveString("Password or access token used to authenticate.", true),
"registry_url": requiredString("Registry hostname, for example `ghcr.io`."),
"registry_type": enumStringWithDefault("Registry kind. Dokploy currently accepts only `cloud`.",
[]string{"cloud"}, "cloud"),
"image_prefix": optionalString("Prefix prepended to pushed image names, for example an " +
"organization or namespace."),
"server_id": optionalReplaceString("Server this registry is scoped to."),
"created_at": computedString("RFC 3339 timestamp of when the registry was created."),
},
},
}
}
// ------------------------------------------------------------------- SSH key
type sshKeyModel struct {
ID types.String `tfsdk:"id" dokploy:"sshKeyId,id"`
Name types.String `tfsdk:"name" dokploy:"name"`
Description types.String `tfsdk:"description" dokploy:"description,nullable"`
PublicKey types.String `tfsdk:"public_key" dokploy:"publicKey,create"`
PrivateKey types.String `tfsdk:"private_key" dokploy:"privateKey,create"`
LastUsedAt types.String `tfsdk:"last_used_at" dokploy:"lastUsedAt,ro"`
CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"`
}
func sshKeyResource() ResourceSpec {
return ResourceSpec{
Name: "ssh_key",
NeedsOrganizationID: true,
CreateProc: "sshKey.create",
ReadProc: "sshKey.one",
UpdateProc: "sshKey.update",
DeleteProc: "sshKey.remove",
NewModel: func() any { return &sshKeyModel{} },
// `sshKey.create` returns nothing, so the new ID is discovered by
// diffing the key list around the call.
ListIDs: func(ctx context.Context, api *client.Client, _ any) (map[string]struct{}, error) {
raw, err := api.Query(ctx, "sshKey.all", nil)
if err != nil {
return nil, err
}
return collectIDs(raw, "sshKeyId")
},
Schema: schema.Schema{
MarkdownDescription: "An SSH key pair Dokploy uses to clone private Git repositories and to reach " +
"remote servers.\n\n" +
"~> The private key is stored in Terraform state. Use a state backend with encryption at rest.\n\n" +
"~> Dokploy's update endpoint only accepts `name` and `description`. Changing either key forces " +
"a new resource.",
Attributes: map[string]schema.Attribute{
"id": computedID("Unique SSH key identifier."),
"name": requiredString("Display name of the key pair."),
"description": optionalString("Free-form description."),
"public_key": requiredReplaceString("OpenSSH-formatted public key."),
"private_key": schema.StringAttribute{
Required: true,
Sensitive: true,
MarkdownDescription: "PEM-encoded private key.",
PlanModifiers: requiresReplaceString(),
},
"last_used_at": computedString("RFC 3339 timestamp of when the key was last used, if ever."),
"created_at": computedString("RFC 3339 timestamp of when the key was created."),
},
},
}
}
// --------------------------------------------------------------- Certificate
type certificateModel struct {
ID types.String `tfsdk:"id" dokploy:"certificateId,id"`
Name types.String `tfsdk:"name" dokploy:"name"`
CertificateData types.String `tfsdk:"certificate_data" dokploy:"certificateData"`
PrivateKey types.String `tfsdk:"private_key" dokploy:"privateKey"`
AutoRenew types.Bool `tfsdk:"auto_renew" dokploy:"autoRenew,create"`
ServerID types.String `tfsdk:"server_id" dokploy:"serverId,create"`
CertificatePath types.String `tfsdk:"certificate_path" dokploy:"certificatePath,ro"`
}
func certificateResource() ResourceSpec {
return ResourceSpec{
Name: "certificate",
NeedsOrganizationID: true,
CreateProc: "certificates.create",
ReadProc: "certificates.one",
UpdateProc: "certificates.update",
DeleteProc: "certificates.remove",
NewModel: func() any { return &certificateModel{} },
Schema: schema.Schema{
MarkdownDescription: "A TLS certificate uploaded to Dokploy, for domains that use " +
"`certificate_type = \"custom\"`.\n\n" +
"~> The private key is stored in Terraform state. Use a state backend with encryption at rest.",
Attributes: map[string]schema.Attribute{
"id": computedID("Unique certificate identifier."),
"name": requiredString("Display name of the certificate."),
"certificate_data": requiredString("PEM-encoded certificate chain."),
"private_key": sensitiveString("PEM-encoded private key.", true),
"auto_renew": optionalComputedBool("Whether Dokploy should renew this certificate automatically."),
"server_id": optionalReplaceString("Server this certificate is installed on."),
"certificate_path": computedString("Path where Dokploy writes the certificate on disk."),
},
},
}
}
// --------------------------------------------------------------- Destination
type destinationModel struct {
ID types.String `tfsdk:"id" dokploy:"destinationId,id"`
Name types.String `tfsdk:"name" dokploy:"name"`
// `provider` is a reserved root attribute name in Terraform, so the
// attribute is exposed as `provider_name`.
Provider types.String `tfsdk:"provider_name" dokploy:"provider,nullable"`
AccessKey types.String `tfsdk:"access_key" dokploy:"accessKey"`
SecretAccessKey types.String `tfsdk:"secret_access_key" dokploy:"secretAccessKey"`
Bucket types.String `tfsdk:"bucket" dokploy:"bucket"`
Region types.String `tfsdk:"region" dokploy:"region"`
Endpoint types.String `tfsdk:"endpoint" dokploy:"endpoint"`
AdditionalFlags types.List `tfsdk:"additional_flags" dokploy:"additionalFlags,nullable"`
ServerID types.String `tfsdk:"server_id" dokploy:"serverId"`
CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"`
}
func destinationResource() ResourceSpec {
return ResourceSpec{
Name: "destination",
CreateProc: "destination.create",
ReadProc: "destination.one",
UpdateProc: "destination.update",
DeleteProc: "destination.remove",
NewModel: func() any { return &destinationModel{} },
Schema: schema.Schema{
MarkdownDescription: "An S3-compatible bucket that Dokploy writes database and volume backups to.\n\n" +
"~> The secret access key is stored in Terraform state. Use a state backend with encryption at rest.",
Attributes: map[string]schema.Attribute{
"id": computedID("Unique destination identifier."),
"name": requiredString("Display name of the destination."),
"provider_name": optionalString("Provider label, for example `s3` or `cloudflare`. " +
"Named `provider_name` because `provider` is reserved by Terraform."),
"access_key": requiredString("S3 access key ID."),
"secret_access_key": sensitiveString("S3 secret access key.", true),
"bucket": requiredString("Bucket name."),
"region": requiredString("Bucket region, for example `us-east-1`."),
"endpoint": requiredString("S3 endpoint URL."),
"additional_flags": optionalComputedStringList("Extra flags passed to the underlying `rclone` invocation."),
"server_id": optionalString("Server this destination is scoped to."),
"created_at": computedString("Timestamp of when the destination was created."),
},
},
}
}

View File

@@ -0,0 +1,257 @@
package provider
import (
"github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
)
type applicationModel struct {
ID types.String `tfsdk:"id" dokploy:"applicationId,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,create"`
// Source
SourceType types.String `tfsdk:"source_type" dokploy:"sourceType"`
DockerImage types.String `tfsdk:"docker_image" dokploy:"dockerImage,nullable"`
Username types.String `tfsdk:"username" dokploy:"username,nullable"`
Password types.String `tfsdk:"password" dokploy:"password,nullable"`
RegistryURL types.String `tfsdk:"registry_url" dokploy:"registryUrl,nullable"`
Repository types.String `tfsdk:"repository" dokploy:"repository,nullable"`
Owner types.String `tfsdk:"owner" dokploy:"owner,nullable"`
Branch types.String `tfsdk:"branch" dokploy:"branch,nullable"`
BuildPath types.String `tfsdk:"build_path" dokploy:"buildPath,nullable"`
GithubID types.String `tfsdk:"github_id" dokploy:"githubId,nullable"`
TriggerType types.String `tfsdk:"trigger_type" dokploy:"triggerType,nullable"`
CustomGitURL types.String `tfsdk:"custom_git_url" dokploy:"customGitUrl,nullable"`
CustomGitBranch types.String `tfsdk:"custom_git_branch" dokploy:"customGitBranch,nullable"`
CustomGitPath types.String `tfsdk:"custom_git_build_path" dokploy:"customGitBuildPath,nullable"`
CustomGitSSHKey types.String `tfsdk:"custom_git_ssh_key_id" dokploy:"customGitSSHKeyId,nullable"`
GitlabID types.String `tfsdk:"gitlab_id" dokploy:"gitlabId,nullable"`
GitlabProjectID types.Int64 `tfsdk:"gitlab_project_id" dokploy:"gitlabProjectId,nullable"`
GitlabRepository types.String `tfsdk:"gitlab_repository" dokploy:"gitlabRepository,nullable"`
GitlabOwner types.String `tfsdk:"gitlab_owner" dokploy:"gitlabOwner,nullable"`
GitlabBranch types.String `tfsdk:"gitlab_branch" dokploy:"gitlabBranch,nullable"`
GitlabBuildPath types.String `tfsdk:"gitlab_build_path" dokploy:"gitlabBuildPath,nullable"`
GitlabNamespace types.String `tfsdk:"gitlab_path_namespace" dokploy:"gitlabPathNamespace,nullable"`
GiteaID types.String `tfsdk:"gitea_id" dokploy:"giteaId,nullable"`
GiteaRepository types.String `tfsdk:"gitea_repository" dokploy:"giteaRepository,nullable"`
GiteaOwner types.String `tfsdk:"gitea_owner" dokploy:"giteaOwner,nullable"`
GiteaBranch types.String `tfsdk:"gitea_branch" dokploy:"giteaBranch,nullable"`
GiteaBuildPath types.String `tfsdk:"gitea_build_path" dokploy:"giteaBuildPath,nullable"`
BitbucketID types.String `tfsdk:"bitbucket_id" dokploy:"bitbucketId,nullable"`
BitbucketRepo types.String `tfsdk:"bitbucket_repository" dokploy:"bitbucketRepository,nullable"`
BitbucketSlug types.String `tfsdk:"bitbucket_repository_slug" dokploy:"bitbucketRepositorySlug,nullable"`
BitbucketOwner types.String `tfsdk:"bitbucket_owner" dokploy:"bitbucketOwner,nullable"`
BitbucketBranch types.String `tfsdk:"bitbucket_branch" dokploy:"bitbucketBranch,nullable"`
BitbucketPath types.String `tfsdk:"bitbucket_build_path" dokploy:"bitbucketBuildPath,nullable"`
EnableSubmodules types.Bool `tfsdk:"enable_submodules" dokploy:"enableSubmodules"`
WatchPaths types.List `tfsdk:"watch_paths" dokploy:"watchPaths,nullable"`
AutoDeploy types.Bool `tfsdk:"auto_deploy" dokploy:"autoDeploy,nullable"`
// Build
BuildType types.String `tfsdk:"build_type" dokploy:"buildType"`
Dockerfile types.String `tfsdk:"dockerfile" dokploy:"dockerfile,nullable"`
DockerContextPath types.String `tfsdk:"docker_context_path" dokploy:"dockerContextPath,nullable"`
DockerBuildStage types.String `tfsdk:"docker_build_stage" dokploy:"dockerBuildStage,nullable"`
PublishDirectory types.String `tfsdk:"publish_directory" dokploy:"publishDirectory,nullable"`
IsStaticSpa types.Bool `tfsdk:"is_static_spa" dokploy:"isStaticSpa,nullable"`
HerokuVersion types.String `tfsdk:"heroku_version" dokploy:"herokuVersion,nullable"`
RailpackVersion types.String `tfsdk:"railpack_version" dokploy:"railpackVersion,nullable"`
CleanCache types.Bool `tfsdk:"clean_cache" dokploy:"cleanCache,nullable"`
// Runtime
Env types.String `tfsdk:"env" dokploy:"env,nullable"`
BuildArgs types.String `tfsdk:"build_args" dokploy:"buildArgs,nullable"`
BuildSecrets types.String `tfsdk:"build_secrets" dokploy:"buildSecrets,nullable"`
CreateEnvFile types.Bool `tfsdk:"create_env_file" dokploy:"createEnvFile"`
Command types.String `tfsdk:"command" dokploy:"command,nullable"`
Args types.List `tfsdk:"args" dokploy:"args,nullable"`
Replicas types.Int64 `tfsdk:"replicas" dokploy:"replicas"`
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"`
Title types.String `tfsdk:"title" dokploy:"title,nullable"`
Subtitle types.String `tfsdk:"subtitle" dokploy:"subtitle,nullable"`
Enabled types.Bool `tfsdk:"enabled" dokploy:"enabled,nullable"`
RegistryID types.String `tfsdk:"registry_id" dokploy:"registryId,nullable"`
RollbackRegistryID types.String `tfsdk:"rollback_registry_id" dokploy:"rollbackRegistryId,nullable"`
BuildRegistryID types.String `tfsdk:"build_registry_id" dokploy:"buildRegistryId,nullable"`
BuildServerID types.String `tfsdk:"build_server_id" dokploy:"buildServerId,nullable"`
RollbackActive types.Bool `tfsdk:"rollback_active" dokploy:"rollbackActive,nullable"`
NetworkIDs types.List `tfsdk:"network_ids" dokploy:"networkIds"`
DetachDokployNetwork types.Bool `tfsdk:"detach_dokploy_network" dokploy:"detachDokployNetwork"`
// Docker Swarm service settings, expressed as JSON documents.
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"`
UlimitsSwarm jsontypes.Normalized `tfsdk:"ulimits_swarm" dokploy:"ulimitsSwarm,nullable"`
StopGracePeriodSwarm types.Int64 `tfsdk:"stop_grace_period_swarm" dokploy:"stopGracePeriodSwarm,nullable"`
// Preview deployments
PreviewActive types.Bool `tfsdk:"is_preview_deployments_active" dokploy:"isPreviewDeploymentsActive,nullable"`
PreviewEnv types.String `tfsdk:"preview_env" dokploy:"previewEnv,nullable"`
PreviewBuildArgs types.String `tfsdk:"preview_build_args" dokploy:"previewBuildArgs,nullable"`
PreviewBuildSecrets types.String `tfsdk:"preview_build_secrets" dokploy:"previewBuildSecrets,nullable"`
PreviewWildcard types.String `tfsdk:"preview_wildcard" dokploy:"previewWildcard,nullable"`
PreviewPort types.Int64 `tfsdk:"preview_port" dokploy:"previewPort,nullable"`
PreviewHTTPS types.Bool `tfsdk:"preview_https" dokploy:"previewHttps,nullable"`
PreviewPath types.String `tfsdk:"preview_path" dokploy:"previewPath,nullable"`
PreviewCertType types.String `tfsdk:"preview_certificate_type" dokploy:"previewCertificateType,nullable"`
PreviewCertResolver types.String `tfsdk:"preview_custom_cert_resolver" dokploy:"previewCustomCertResolver,nullable"`
PreviewLimit types.Int64 `tfsdk:"preview_limit" dokploy:"previewLimit,nullable"`
PreviewLabels types.List `tfsdk:"preview_labels" dokploy:"previewLabels,nullable"`
// Computed
ApplicationStatus types.String `tfsdk:"application_status" dokploy:"applicationStatus,ro"`
CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"`
}
func applicationResource() ResourceSpec {
return ResourceSpec{
Name: "application",
CreateProc: "application.create",
ReadProc: "application.one",
UpdateProc: "application.update",
DeleteProc: "application.delete",
UpdateAfterCreate: true,
NewModel: func() any { return &applicationModel{} },
Schema: schema.Schema{
MarkdownDescription: "A Dokploy application: a single service built from a Git repository, a Docker " +
"image, or an uploaded artifact.\n\n" +
"Dokploy's `application.create` endpoint accepts only a handful of fields, so this resource creates " +
"the application and then applies the rest of the configuration through `application.update`.\n\n" +
"~> Creating this resource does **not** deploy the application. Trigger a deployment from the " +
"Dokploy UI, the CLI, or a `terraform_data` provisioner calling `application.deploy`.",
Attributes: map[string]schema.Attribute{
"id": computedID("Unique application identifier."),
"name": requiredString("Display name of the application."),
"app_name": optionalComputedReplaceString("Unique Docker service name. Generated by Dokploy when " +
"omitted. Changing it forces a new application."),
"description": optionalString("Free-form description."),
"environment_id": requiredReplaceString("Environment this application belongs to."),
"server_id": optionalReplaceString("Remote server to deploy on. Omit to use the Dokploy host " +
"itself."),
"source_type": enumString("Where the application's code or image comes from.", sourceTypes, false),
"docker_image": optionalString("Docker image reference, when `source_type` is `docker`."),
"username": optionalString("Registry username, when pulling a private image."),
"password": sensitiveString("Registry password, when pulling a private image.", false),
"registry_url": optionalString("Registry URL, when pulling a private image."),
"repository": optionalString("GitHub repository name."),
"owner": optionalString("GitHub repository owner."),
"branch": optionalString("GitHub branch to build."),
"build_path": optionalComputedString("Path within the GitHub repository to build from."),
"github_id": optionalString("ID of the configured GitHub provider connection."),
"trigger_type": enumString("What triggers an automatic GitHub deployment.", triggerTypes, false),
"custom_git_url": optionalString("Git remote URL, when `source_type` is `git`."),
"custom_git_branch": optionalString("Branch to build for a custom Git remote."),
"custom_git_build_path": optionalString("Path within a custom Git repository to build from."),
"custom_git_ssh_key_id": optionalString("SSH key used to clone a private custom Git remote."),
"gitlab_id": optionalString("ID of the configured GitLab provider connection."),
"gitlab_project_id": schema.Int64Attribute{Optional: true, MarkdownDescription: "Numeric GitLab project ID."},
"gitlab_repository": optionalString("GitLab repository name."),
"gitlab_owner": optionalString("GitLab repository owner."),
"gitlab_branch": optionalString("GitLab branch to build."),
"gitlab_build_path": optionalComputedString("Path within the GitLab repository to build from."),
"gitlab_path_namespace": optionalString("Full GitLab namespace path."),
"gitea_id": optionalString("ID of the configured Gitea provider connection."),
"gitea_repository": optionalString("Gitea repository name."),
"gitea_owner": optionalString("Gitea repository owner."),
"gitea_branch": optionalString("Gitea branch to build."),
"gitea_build_path": optionalComputedString("Path within the Gitea repository to build from."),
"bitbucket_id": optionalString("ID of the configured Bitbucket provider connection."),
"bitbucket_repository": optionalString("Bitbucket repository name."),
"bitbucket_repository_slug": optionalString("Bitbucket repository slug."),
"bitbucket_owner": optionalString("Bitbucket repository owner."),
"bitbucket_branch": optionalString("Bitbucket branch to build."),
"bitbucket_build_path": optionalComputedString("Path within the Bitbucket repository to build from."),
"enable_submodules": optionalComputedBool("Clone Git submodules when checking out the repository."),
"watch_paths": optionalComputedStringList("Glob patterns that limit which changed paths trigger " +
"an automatic deployment."),
"auto_deploy": optionalComputedBool("Deploy automatically when the configured trigger fires."),
"build_type": enumString("How the application is built.", buildTypes, false),
"dockerfile": optionalComputedString("Path to the Dockerfile, when `build_type` is `dockerfile`."),
"docker_context_path": optionalString("Docker build context path."),
"docker_build_stage": optionalString("Target stage for a multi-stage Docker build."),
"publish_directory": optionalString("Directory served when `build_type` is `static`."),
"is_static_spa": optionalComputedBool("Serve a static build as a single-page application."),
"heroku_version": optionalComputedString("Heroku buildpack stack version."),
"railpack_version": optionalComputedString("Railpack version."),
"clean_cache": optionalComputedBool("Discard the build cache on the next deployment."),
"env": optionalString("Runtime environment variables in `KEY=value` format, one per line."),
"build_args": optionalString("Docker build arguments in `KEY=value` format, one per line."),
"build_secrets": sensitiveString("Docker build secrets in `KEY=value` format, one per line.", false),
"create_env_file": optionalComputedBool("Write the environment variables to a `.env` file in the " +
"container."),
"command": optionalString("Override the container entrypoint command."),
"args": optionalComputedStringList("Arguments appended to the container command."),
"replicas": optionalComputedInt("Number of replicas to run."),
"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`."),
"title": optionalString("Display title shown in the Dokploy UI."),
"subtitle": optionalString("Display subtitle shown in the Dokploy UI."),
"enabled": optionalComputedBool("Whether the application is enabled."),
"registry_id": optionalString("Registry used to push the built image."),
"rollback_registry_id": optionalString("Registry used to store rollback images."),
"build_registry_id": optionalString("Registry used by a dedicated build server."),
"build_server_id": optionalString("Server that performs builds, when separate from the deploy server."),
"rollback_active": optionalComputedBool("Keep previous images so deployments can be rolled back."),
"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."),
"ulimits_swarm": optionalJSON("Docker Swarm ulimits, as a JSON object."),
"stop_grace_period_swarm": schema.Int64Attribute{Optional: true, MarkdownDescription: "Grace period in nanoseconds before a container is killed."},
"is_preview_deployments_active": optionalComputedBool("Build a preview deployment for each pull request."),
"preview_env": optionalString("Environment variables applied to preview deployments."),
"preview_build_args": optionalString("Build arguments applied to preview deployments."),
"preview_build_secrets": sensitiveString("Build secrets applied to preview deployments.", false),
"preview_wildcard": optionalString("Wildcard domain used to expose preview deployments."),
"preview_port": optionalComputedInt("Container port exposed by preview deployments."),
"preview_https": optionalComputedBool("Serve preview deployments over HTTPS."),
"preview_path": optionalComputedString("Base path for preview deployments."),
"preview_certificate_type": enumString("Certificate strategy for preview deployments.", certificateTypes, false),
"preview_custom_cert_resolver": optionalString("Traefik certificate resolver for preview deployments."),
"preview_limit": optionalComputedInt("Maximum number of concurrent preview deployments."),
"preview_labels": optionalComputedStringList("Pull request labels that opt into preview deployments."),
"application_status": computedString("Current status reported by Dokploy: `idle`, `running`, `done` or `error`."),
"created_at": computedString("RFC 3339 timestamp of when the application was created."),
},
},
}
}

View File

@@ -0,0 +1,144 @@
package provider
import (
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
)
type composeModel struct {
ID types.String `tfsdk:"id" dokploy:"composeId,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,create"`
ComposeType types.String `tfsdk:"compose_type" dokploy:"composeType"`
ComposeFile types.String `tfsdk:"compose_file" dokploy:"composeFile"`
ComposePath types.String `tfsdk:"compose_path" dokploy:"composePath"`
SourceType types.String `tfsdk:"source_type" dokploy:"sourceType"`
Command types.String `tfsdk:"command" dokploy:"command"`
Env types.String `tfsdk:"env" dokploy:"env,nullable"`
Repository types.String `tfsdk:"repository" dokploy:"repository,nullable"`
Owner types.String `tfsdk:"owner" dokploy:"owner,nullable"`
Branch types.String `tfsdk:"branch" dokploy:"branch,nullable"`
GithubID types.String `tfsdk:"github_id" dokploy:"githubId,nullable"`
GitlabID types.String `tfsdk:"gitlab_id" dokploy:"gitlabId,nullable"`
GitlabProjectID types.Int64 `tfsdk:"gitlab_project_id" dokploy:"gitlabProjectId,nullable"`
GitlabRepository types.String `tfsdk:"gitlab_repository" dokploy:"gitlabRepository,nullable"`
GitlabOwner types.String `tfsdk:"gitlab_owner" dokploy:"gitlabOwner,nullable"`
GitlabBranch types.String `tfsdk:"gitlab_branch" dokploy:"gitlabBranch,nullable"`
GitlabNamespace types.String `tfsdk:"gitlab_path_namespace" dokploy:"gitlabPathNamespace,nullable"`
GiteaID types.String `tfsdk:"gitea_id" dokploy:"giteaId,nullable"`
GiteaRepository types.String `tfsdk:"gitea_repository" dokploy:"giteaRepository,nullable"`
GiteaOwner types.String `tfsdk:"gitea_owner" dokploy:"giteaOwner,nullable"`
GiteaBranch types.String `tfsdk:"gitea_branch" dokploy:"giteaBranch,nullable"`
BitbucketID types.String `tfsdk:"bitbucket_id" dokploy:"bitbucketId,nullable"`
BitbucketRepo types.String `tfsdk:"bitbucket_repository" dokploy:"bitbucketRepository,nullable"`
BitbucketSlug types.String `tfsdk:"bitbucket_repository_slug" dokploy:"bitbucketRepositorySlug,nullable"`
BitbucketOwner types.String `tfsdk:"bitbucket_owner" dokploy:"bitbucketOwner,nullable"`
BitbucketBranch types.String `tfsdk:"bitbucket_branch" dokploy:"bitbucketBranch,nullable"`
CustomGitURL types.String `tfsdk:"custom_git_url" dokploy:"customGitUrl,nullable"`
CustomGitBranch types.String `tfsdk:"custom_git_branch" dokploy:"customGitBranch,nullable"`
CustomGitSSHKey types.String `tfsdk:"custom_git_ssh_key_id" dokploy:"customGitSSHKeyId,nullable"`
EnableSubmodules types.Bool `tfsdk:"enable_submodules" dokploy:"enableSubmodules"`
AutoDeploy types.Bool `tfsdk:"auto_deploy" dokploy:"autoDeploy,nullable"`
TriggerType types.String `tfsdk:"trigger_type" dokploy:"triggerType,nullable"`
WatchPaths types.List `tfsdk:"watch_paths" dokploy:"watchPaths,nullable"`
Suffix types.String `tfsdk:"suffix" dokploy:"suffix"`
Randomize types.Bool `tfsdk:"randomize" dokploy:"randomize"`
IsolatedDeployment types.Bool `tfsdk:"isolated_deployment" dokploy:"isolatedDeployment"`
IsolatedDeployVolue types.Bool `tfsdk:"isolated_deployments_volume" dokploy:"isolatedDeploymentsVolume"`
ComposeStatus types.String `tfsdk:"compose_status" dokploy:"composeStatus,ro"`
CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"`
// Terraform-only: controls the destroy call, never sent on create/update.
DeleteVolumes types.Bool `tfsdk:"delete_volumes"`
}
func composeResource() ResourceSpec {
return ResourceSpec{
Name: "compose",
CreateProc: "compose.create",
ReadProc: "compose.one",
UpdateProc: "compose.update",
DeleteProc: "compose.delete",
UpdateAfterCreate: true,
NewModel: func() any { return &composeModel{} },
DeleteBody: func(model any) map[string]any {
// Dokploy requires this field on every compose delete.
deleteVolumes := false
if compose, ok := model.(*composeModel); ok && compose.DeleteVolumes.ValueBool() {
deleteVolumes = true
}
return map[string]any{"deleteVolumes": deleteVolumes}
},
Schema: schema.Schema{
MarkdownDescription: "A Docker Compose or Docker Swarm stack managed by Dokploy.\n\n" +
"Set `compose_file` to manage the stack definition inline (with `source_type = \"raw\"`), or point " +
"the stack at a Git repository and set `compose_path`.\n\n" +
"~> Creating this resource does **not** deploy the stack.",
Attributes: map[string]schema.Attribute{
"id": computedID("Unique compose identifier."),
"name": requiredString("Display name of the stack."),
"app_name": optionalComputedReplaceString("Unique Docker stack name. Generated by Dokploy when " +
"omitted. Changing it forces a new stack."),
"description": optionalString("Free-form description."),
"environment_id": requiredReplaceString("Environment this stack belongs to."),
"server_id": optionalReplaceString("Remote server to deploy on. Omit to use the Dokploy host itself."),
"compose_type": enumString("Whether to run the stack with Docker Compose or Docker Swarm.", composeTypes, false),
"compose_file": optionalComputedString("Inline Compose file contents. Used when `source_type` is `raw`."),
"compose_path": optionalComputedString("Path to the Compose file within the repository."),
"source_type": enumString("Where the Compose file comes from.", composeSources, false),
"command": optionalComputedString("Custom `docker compose` command to run."),
"env": optionalString("Environment variables in `KEY=value` format, one per line."),
"repository": optionalString("GitHub repository name."),
"owner": optionalString("GitHub repository owner."),
"branch": optionalString("GitHub branch to deploy."),
"github_id": optionalString("ID of the configured GitHub provider connection."),
"gitlab_id": optionalString("ID of the configured GitLab provider connection."),
"gitlab_project_id": schema.Int64Attribute{Optional: true, MarkdownDescription: "Numeric GitLab project ID."},
"gitlab_repository": optionalString("GitLab repository name."),
"gitlab_owner": optionalString("GitLab repository owner."),
"gitlab_branch": optionalString("GitLab branch to deploy."),
"gitlab_path_namespace": optionalString("Full GitLab namespace path."),
"gitea_id": optionalString("ID of the configured Gitea provider connection."),
"gitea_repository": optionalString("Gitea repository name."),
"gitea_owner": optionalString("Gitea repository owner."),
"gitea_branch": optionalString("Gitea branch to deploy."),
"bitbucket_id": optionalString("ID of the configured Bitbucket provider connection."),
"bitbucket_repository": optionalString("Bitbucket repository name."),
"bitbucket_repository_slug": optionalString("Bitbucket repository slug."),
"bitbucket_owner": optionalString("Bitbucket repository owner."),
"bitbucket_branch": optionalString("Bitbucket branch to deploy."),
"custom_git_url": optionalString("Git remote URL, when `source_type` is `git`."),
"custom_git_branch": optionalString("Branch to deploy for a custom Git remote."),
"custom_git_ssh_key_id": optionalString("SSH key used to clone a private custom Git remote."),
"enable_submodules": optionalComputedBool("Clone Git submodules when checking out the repository."),
"auto_deploy": optionalComputedBool("Deploy automatically when the configured trigger fires."),
"trigger_type": enumString("What triggers an automatic deployment.", triggerTypes, false),
"watch_paths": optionalComputedStringList("Glob patterns that limit which changed paths trigger " +
"an automatic deployment."),
"suffix": optionalComputedString("Suffix appended to generated resource names."),
"randomize": optionalComputedBool("Append a random suffix to service and volume names."),
"isolated_deployment": optionalComputedBool("Run the stack on its own isolated Docker network."),
"isolated_deployments_volume": optionalComputedBool("Prefix volume names for isolated deployments. " +
"Retained for backwards compatibility."),
"compose_status": computedString("Current status reported by Dokploy: `idle`, `running`, `done` or `error`."),
"created_at": computedString("RFC 3339 timestamp of when the stack was created."),
"delete_volumes": optionalBool("Whether to delete the stack's Docker volumes when this resource is " +
"destroyed. Defaults to `false`, which preserves the data."),
},
},
}
}

View File

@@ -0,0 +1,406 @@
package provider
import (
"github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
)
// The five managed databases share almost their entire surface. The Terraform
// Plugin Framework cannot reflect into embedded structs, so each model is
// written out flat, but the schema attributes are assembled from one place.
// databaseCommonSchema returns the attributes every database resource exposes.
// engine is used to word the descriptions.
func databaseCommonSchema(engine string) map[string]schema.Attribute {
return map[string]schema.Attribute{
"id": computedID("Unique " + engine + " identifier."),
"name": requiredString("Display name of the database."),
"app_name": optionalComputedReplaceString("Unique Docker service name. Generated by Dokploy when omitted. " +
"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."),
"command": optionalString("Override the container entrypoint command."),
"args": optionalComputedStringList("Arguments appended to the container 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`."),
"external_port": schema.Int64Attribute{
Optional: true,
MarkdownDescription: "Host port to expose the database on. Leave unset to keep the database reachable " +
"only from inside the Docker network.",
},
"replicas": optionalComputedInt("Number of replicas to run."),
"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."),
"ulimits_swarm": optionalJSON("Docker Swarm ulimits, 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."),
}
}
func withAttributes(base map[string]schema.Attribute, extra map[string]schema.Attribute) map[string]schema.Attribute {
for name, attribute := range extra {
base[name] = attribute
}
return base
}
func databaseNote(engine string) string {
return "A managed " + engine + " instance running on Dokploy.\n\n" +
"~> Creating this resource provisions the service definition but does **not** start a deployment. " +
"Deploy it from the Dokploy UI or CLI.\n\n" +
"~> Credentials are stored in Terraform state. Use a state backend with encryption at rest."
}
// ---------------------------------------------------------------- PostgreSQL
type postgresModel struct {
ID types.String `tfsdk:"id" dokploy:"postgresId,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,create"`
DatabaseName types.String `tfsdk:"database_name" dokploy:"databaseName"`
DatabaseUser types.String `tfsdk:"database_user" dokploy:"databaseUser"`
DatabasePassword types.String `tfsdk:"database_password" dokploy:"databasePassword"`
DockerImage types.String `tfsdk:"docker_image" dokploy:"dockerImage"`
Command types.String `tfsdk:"command" dokploy:"command,nullable"`
Args types.List `tfsdk:"args" dokploy:"args,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"`
ExternalPort types.Int64 `tfsdk:"external_port" dokploy:"externalPort,nullable"`
Replicas types.Int64 `tfsdk:"replicas" dokploy:"replicas"`
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"`
UlimitsSwarm jsontypes.Normalized `tfsdk:"ulimits_swarm" dokploy:"ulimitsSwarm,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 postgresResource() ResourceSpec {
return ResourceSpec{
Name: "postgres",
// The create endpoint accepts only the core fields; everything else
// (limits, env, swarm settings) has to be written through update.
UpdateAfterCreate: true,
CreateProc: "postgres.create",
ReadProc: "postgres.one",
UpdateProc: "postgres.update",
DeleteProc: "postgres.remove",
NewModel: func() any { return &postgresModel{} },
Schema: schema.Schema{
MarkdownDescription: databaseNote("PostgreSQL"),
Attributes: withAttributes(databaseCommonSchema("postgres"), map[string]schema.Attribute{
"database_name": requiredString("Name of the database to create."),
"database_user": requiredString("Database user to create."),
"database_password": sensitiveString("Password for the database user.", true),
"docker_image": requiredString("PostgreSQL image to run, for example `postgres:16`."),
}),
},
}
}
// --------------------------------------------------------------------- MySQL
type mysqlModel struct {
ID types.String `tfsdk:"id" dokploy:"mysqlId,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,create"`
DatabaseName types.String `tfsdk:"database_name" dokploy:"databaseName"`
DatabaseUser types.String `tfsdk:"database_user" dokploy:"databaseUser"`
DatabasePassword types.String `tfsdk:"database_password" dokploy:"databasePassword"`
DatabaseRootPass types.String `tfsdk:"database_root_password" dokploy:"databaseRootPassword"`
DockerImage types.String `tfsdk:"docker_image" dokploy:"dockerImage"`
Command types.String `tfsdk:"command" dokploy:"command,nullable"`
Args types.List `tfsdk:"args" dokploy:"args,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"`
ExternalPort types.Int64 `tfsdk:"external_port" dokploy:"externalPort,nullable"`
Replicas types.Int64 `tfsdk:"replicas" dokploy:"replicas"`
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"`
UlimitsSwarm jsontypes.Normalized `tfsdk:"ulimits_swarm" dokploy:"ulimitsSwarm,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 mysqlResource() ResourceSpec {
return ResourceSpec{
Name: "mysql",
// The create endpoint accepts only the core fields; everything else
// (limits, env, swarm settings) has to be written through update.
UpdateAfterCreate: true,
CreateProc: "mysql.create",
ReadProc: "mysql.one",
UpdateProc: "mysql.update",
DeleteProc: "mysql.remove",
NewModel: func() any { return &mysqlModel{} },
Schema: schema.Schema{
MarkdownDescription: databaseNote("MySQL"),
Attributes: withAttributes(databaseCommonSchema("mysql"), map[string]schema.Attribute{
"database_name": requiredString("Name of the database to create."),
"database_user": requiredString("Database user to create."),
"database_password": sensitiveString("Password for the database user.", true),
"database_root_password": sensitiveString("Password for the MySQL `root` user.", true),
"docker_image": requiredString("MySQL image to run, for example `mysql:8`."),
}),
},
}
}
// ------------------------------------------------------------------- MariaDB
type mariadbModel struct {
ID types.String `tfsdk:"id" dokploy:"mariadbId,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,create"`
DatabaseName types.String `tfsdk:"database_name" dokploy:"databaseName"`
DatabaseUser types.String `tfsdk:"database_user" dokploy:"databaseUser"`
DatabasePassword types.String `tfsdk:"database_password" dokploy:"databasePassword"`
DatabaseRootPass types.String `tfsdk:"database_root_password" dokploy:"databaseRootPassword"`
DockerImage types.String `tfsdk:"docker_image" dokploy:"dockerImage"`
Command types.String `tfsdk:"command" dokploy:"command,nullable"`
Args types.List `tfsdk:"args" dokploy:"args,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"`
ExternalPort types.Int64 `tfsdk:"external_port" dokploy:"externalPort,nullable"`
Replicas types.Int64 `tfsdk:"replicas" dokploy:"replicas"`
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"`
UlimitsSwarm jsontypes.Normalized `tfsdk:"ulimits_swarm" dokploy:"ulimitsSwarm,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 mariadbResource() ResourceSpec {
return ResourceSpec{
Name: "mariadb",
// The create endpoint accepts only the core fields; everything else
// (limits, env, swarm settings) has to be written through update.
UpdateAfterCreate: true,
CreateProc: "mariadb.create",
ReadProc: "mariadb.one",
UpdateProc: "mariadb.update",
DeleteProc: "mariadb.remove",
NewModel: func() any { return &mariadbModel{} },
Schema: schema.Schema{
MarkdownDescription: databaseNote("MariaDB"),
Attributes: withAttributes(databaseCommonSchema("mariadb"), map[string]schema.Attribute{
"database_name": requiredString("Name of the database to create."),
"database_user": requiredString("Database user to create."),
"database_password": sensitiveString("Password for the database user.", true),
"database_root_password": sensitiveString("Password for the MariaDB `root` user.", true),
"docker_image": requiredString("MariaDB image to run, for example `mariadb:11`."),
}),
},
}
}
// ------------------------------------------------------------------- MongoDB
type mongoModel struct {
ID types.String `tfsdk:"id" dokploy:"mongoId,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,create"`
DatabaseUser types.String `tfsdk:"database_user" dokploy:"databaseUser"`
DatabasePassword types.String `tfsdk:"database_password" dokploy:"databasePassword"`
DockerImage types.String `tfsdk:"docker_image" dokploy:"dockerImage"`
ReplicaSets types.Bool `tfsdk:"replica_sets" dokploy:"replicaSets,nullable"`
Command types.String `tfsdk:"command" dokploy:"command,nullable"`
Args types.List `tfsdk:"args" dokploy:"args,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"`
ExternalPort types.Int64 `tfsdk:"external_port" dokploy:"externalPort,nullable"`
Replicas types.Int64 `tfsdk:"replicas" dokploy:"replicas"`
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"`
UlimitsSwarm jsontypes.Normalized `tfsdk:"ulimits_swarm" dokploy:"ulimitsSwarm,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 mongoResource() ResourceSpec {
return ResourceSpec{
Name: "mongo",
// The create endpoint accepts only the core fields; everything else
// (limits, env, swarm settings) has to be written through update.
UpdateAfterCreate: true,
CreateProc: "mongo.create",
ReadProc: "mongo.one",
UpdateProc: "mongo.update",
DeleteProc: "mongo.remove",
NewModel: func() any { return &mongoModel{} },
Schema: schema.Schema{
MarkdownDescription: databaseNote("MongoDB"),
Attributes: withAttributes(databaseCommonSchema("mongo"), map[string]schema.Attribute{
"database_user": requiredString("Root username to create."),
"database_password": sensitiveString("Password for the root user.", true),
"docker_image": optionalComputedString("MongoDB image to run. Defaults to `mongo:8`."),
"replica_sets": optionalComputedBool("Start the instance as a single-node replica set."),
}),
},
}
}
// --------------------------------------------------------------------- Redis
type redisModel struct {
ID types.String `tfsdk:"id" dokploy:"redisId,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,create"`
DatabasePassword types.String `tfsdk:"database_password" dokploy:"databasePassword"`
DockerImage types.String `tfsdk:"docker_image" dokploy:"dockerImage"`
Command types.String `tfsdk:"command" dokploy:"command,nullable"`
Args types.List `tfsdk:"args" dokploy:"args,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"`
ExternalPort types.Int64 `tfsdk:"external_port" dokploy:"externalPort,nullable"`
Replicas types.Int64 `tfsdk:"replicas" dokploy:"replicas"`
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"`
UlimitsSwarm jsontypes.Normalized `tfsdk:"ulimits_swarm" dokploy:"ulimitsSwarm,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 redisResource() ResourceSpec {
return ResourceSpec{
Name: "redis",
// The create endpoint accepts only the core fields; everything else
// (limits, env, swarm settings) has to be written through update.
UpdateAfterCreate: true,
CreateProc: "redis.create",
ReadProc: "redis.one",
UpdateProc: "redis.update",
DeleteProc: "redis.remove",
NewModel: func() any { return &redisModel{} },
Schema: schema.Schema{
MarkdownDescription: databaseNote("Redis"),
Attributes: withAttributes(databaseCommonSchema("redis"), map[string]schema.Attribute{
"database_password": sensitiveString("Password used to authenticate to Redis.", true),
"docker_image": requiredString("Redis image to run, for example `redis:7`."),
}),
},
}
}

View File

@@ -0,0 +1,49 @@
package provider
import (
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
)
type environmentModel struct {
ID types.String `tfsdk:"id" dokploy:"environmentId,id"`
Name types.String `tfsdk:"name" dokploy:"name"`
// Not `nullable`: environment.create and environment.update are hand-written
// Zod objects where description is `.optional()` but not `.nullable()`, so
// an explicit null is rejected and the key must simply be omitted.
Description types.String `tfsdk:"description" dokploy:"description"`
ProjectID types.String `tfsdk:"project_id" dokploy:"projectId"`
Env types.String `tfsdk:"env" dokploy:"env,update"`
IsDefault types.Bool `tfsdk:"is_default" dokploy:"isDefault,ro"`
CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"`
}
func environmentResource() ResourceSpec {
return ResourceSpec{
Name: "environment",
CreateProc: "environment.create",
ReadProc: "environment.one",
UpdateProc: "environment.update",
DeleteProc: "environment.remove",
NewModel: func() any { return &environmentModel{} },
// `environment.create` accepts only name, description and projectId,
// so `env` has to be written through a follow-up update.
UpdateAfterCreate: true,
Schema: schema.Schema{
MarkdownDescription: "An environment inside a Dokploy project, such as `staging` or `production`. " +
"Services belong to an environment rather than directly to a project.\n\n" +
"Dokploy creates a default `production` environment with every project; reference it via " +
"`dokploy_project.<name>.default_environment_id` instead of declaring it here.",
Attributes: map[string]schema.Attribute{
"id": computedID("Unique environment identifier."),
"name": requiredString("Environment name, for example `staging`."),
"description": optionalString("Free-form description."),
"project_id": requiredReplaceString("Project this environment belongs to."),
"env": optionalComputedString("Environment-wide variables in `KEY=value` format, one per line. " +
"These are merged into every service in this environment."),
"is_default": computedBool("Whether this is the project's default environment."),
"created_at": computedString("RFC 3339 timestamp of when the environment was created."),
},
},
}
}

View File

@@ -0,0 +1,254 @@
package provider
import (
"context"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/maxvojtkov/terraform-provider-dokploy/internal/client"
)
// -------------------------------------------------------------------- Domain
type domainModel struct {
ID types.String `tfsdk:"id" dokploy:"domainId,id"`
Host types.String `tfsdk:"host" dokploy:"host"`
Path types.String `tfsdk:"path" dokploy:"path,nullable"`
Port types.Int64 `tfsdk:"port" dokploy:"port,nullable"`
HTTPS types.Bool `tfsdk:"https" dokploy:"https"`
CertificateType types.String `tfsdk:"certificate_type" dokploy:"certificateType"`
CustomCertResolver types.String `tfsdk:"custom_cert_resolver" dokploy:"customCertResolver,nullable"`
CustomEntrypoint types.String `tfsdk:"custom_entrypoint" dokploy:"customEntrypoint,nullable"`
DomainType types.String `tfsdk:"domain_type" dokploy:"domainType,nullable"`
ServiceName types.String `tfsdk:"service_name" dokploy:"serviceName,nullable"`
InternalPath types.String `tfsdk:"internal_path" dokploy:"internalPath,nullable"`
StripPath types.Bool `tfsdk:"strip_path" dokploy:"stripPath"`
Middlewares types.List `tfsdk:"middlewares" dokploy:"middlewares"`
ForwardAuthEnabled types.Bool `tfsdk:"forward_auth_enabled" dokploy:"forwardAuthEnabled"`
ApplicationID types.String `tfsdk:"application_id" dokploy:"applicationId,create"`
ComposeID types.String `tfsdk:"compose_id" dokploy:"composeId,create"`
PreviewDeploymentID types.String `tfsdk:"preview_deployment_id" dokploy:"previewDeploymentId,create"`
CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"`
}
func domainResource() ResourceSpec {
return ResourceSpec{
Name: "domain",
CreateProc: "domain.create",
ReadProc: "domain.one",
UpdateProc: "domain.update",
DeleteProc: "domain.delete",
NewModel: func() any { return &domainModel{} },
Schema: schema.Schema{
MarkdownDescription: "A domain routed to an application or a Compose service through Dokploy's " +
"Traefik instance.\n\n" +
"Set exactly one of `application_id` or `compose_id`. When targeting a Compose stack, " +
"`service_name` selects which service in the stack receives the traffic.",
Attributes: map[string]schema.Attribute{
"id": computedID("Unique domain identifier."),
"host": requiredString("Fully-qualified hostname, for example `api.example.com`."),
"path": optionalComputedString("Path prefix this domain routes, defaults to `/`."),
"port": optionalComputedInt("Container port that receives the traffic, defaults to `3000`."),
"https": optionalComputedBool("Serve the domain over HTTPS and redirect HTTP traffic to it."),
"certificate_type": enumString(
"How TLS certificates are obtained. Use `letsencrypt` for automatic certificates.",
certificateTypes, false),
"custom_cert_resolver": optionalString("Traefik certificate resolver name, when " +
"`certificate_type` is `custom`."),
"custom_entrypoint": optionalString("Traefik entrypoint to bind, when not using the defaults."),
"domain_type": enumString("What kind of target this domain points at.", domainTypes, false),
"service_name": optionalString("Name of the service inside a Compose stack that receives the " +
"traffic. Required when `compose_id` is set."),
"internal_path": optionalComputedString("Path the request is rewritten to before it reaches the " +
"container, defaults to `/`."),
"strip_path": optionalComputedBool("Strip `path` from the request before forwarding it."),
"middlewares": optionalComputedStringList("Names of Traefik middlewares to apply."),
"forward_auth_enabled": optionalComputedBool("Protect this domain with Dokploy's forward auth."),
"application_id": optionalReplaceString("Application this domain routes to."),
"compose_id": optionalReplaceString("Compose stack this domain routes to."),
"preview_deployment_id": optionalReplaceString("Preview deployment this domain routes to."),
"created_at": computedString("RFC 3339 timestamp of when the domain was created."),
},
},
}
}
// --------------------------------------------------------------------- Mount
type mountModel struct {
ID types.String `tfsdk:"id" dokploy:"mountId,id"`
Type types.String `tfsdk:"type" dokploy:"type"`
MountPath types.String `tfsdk:"mount_path" dokploy:"mountPath"`
HostPath types.String `tfsdk:"host_path" dokploy:"hostPath,nullable"`
VolumeName types.String `tfsdk:"volume_name" dokploy:"volumeName,nullable"`
FilePath types.String `tfsdk:"file_path" dokploy:"filePath,nullable"`
Content types.String `tfsdk:"content" dokploy:"content,nullable"`
ServiceType types.String `tfsdk:"service_type" dokploy:"serviceType"`
// serviceId is only accepted on create; reads return the concrete
// applicationId/composeId/... column instead, so it is never refreshed.
ServiceID types.String `tfsdk:"service_id" dokploy:"serviceId,create"`
}
func mountResource() ResourceSpec {
return ResourceSpec{
Name: "mount",
CreateProc: "mounts.create",
ReadProc: "mounts.one",
UpdateProc: "mounts.update",
DeleteProc: "mounts.remove",
NewModel: func() any { return &mountModel{} },
Schema: schema.Schema{
MarkdownDescription: "A volume, bind mount, or config file attached to a Dokploy service.\n\n" +
"* `type = \"volume\"` — a named Docker volume; set `volume_name`.\n" +
"* `type = \"bind\"` — a path on the host; set `host_path`.\n" +
"* `type = \"file\"` — a file rendered from `content`; set `file_path`.",
Attributes: map[string]schema.Attribute{
"id": computedID("Unique mount identifier."),
"type": enumString("The kind of mount to create.", mountTypes, true),
"mount_path": requiredString("Path inside the container where the mount appears."),
"host_path": optionalString("Path on the host, when `type` is `bind`."),
"volume_name": optionalString("Name of the Docker volume, when `type` is `volume`."),
"file_path": optionalString("Path of the generated file, when `type` is `file`."),
"content": optionalString("Contents of the generated file, when `type` is `file`."),
"service_type": enumString("The kind of service this mount attaches to.", serviceTypes, true),
"service_id": requiredReplaceString("ID of the service this mount attaches to. Must match " +
"`service_type` — an application ID, a compose ID, a postgres ID, and so on."),
},
},
}
}
// ---------------------------------------------------------------------- Port
type portModel struct {
ID types.String `tfsdk:"id" dokploy:"portId,id"`
PublishedPort types.Int64 `tfsdk:"published_port" dokploy:"publishedPort"`
TargetPort types.Int64 `tfsdk:"target_port" dokploy:"targetPort"`
Protocol types.String `tfsdk:"protocol" dokploy:"protocol"`
PublishMode types.String `tfsdk:"publish_mode" dokploy:"publishMode"`
ApplicationID types.String `tfsdk:"application_id" dokploy:"applicationId,create"`
}
func portResource() ResourceSpec {
return ResourceSpec{
Name: "port",
CreateProc: "port.create",
ReadProc: "port.one",
UpdateProc: "port.update",
DeleteProc: "port.delete",
NewModel: func() any { return &portModel{} },
Schema: schema.Schema{
MarkdownDescription: "A published port that exposes an application directly on the host, " +
"bypassing Traefik.",
Attributes: map[string]schema.Attribute{
"id": computedID("Unique port identifier."),
"published_port": requiredInt("Port exposed on the host."),
"target_port": requiredInt("Port the container listens on."),
"protocol": enumString("Transport protocol.", protocolTypes, true),
"publish_mode": enumString("Docker Swarm publish mode. `host` binds directly to the node; "+
"`ingress` uses the swarm routing mesh.", publishModes, false),
"application_id": requiredReplaceString("Application this port belongs to."),
},
},
}
}
// ------------------------------------------------------------------ Redirect
type redirectModel struct {
ID types.String `tfsdk:"id" dokploy:"redirectId,id"`
Regex types.String `tfsdk:"regex" dokploy:"regex"`
Replacement types.String `tfsdk:"replacement" dokploy:"replacement"`
Permanent types.Bool `tfsdk:"permanent" dokploy:"permanent"`
ApplicationID types.String `tfsdk:"application_id" dokploy:"applicationId,create"`
CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"`
}
func redirectResource() ResourceSpec {
return ResourceSpec{
Name: "redirect",
CreateProc: "redirects.create",
ReadProc: "redirects.one",
UpdateProc: "redirects.update",
DeleteProc: "redirects.delete",
NewModel: func() any { return &redirectModel{} },
// `redirects.create` returns `true`, so the new ID is discovered by
// diffing the application's redirect list.
ListIDs: func(ctx context.Context, api *client.Client, model any) (map[string]struct{}, error) {
redirect, ok := model.(*redirectModel)
if !ok {
return nil, fmt.Errorf("expected *redirectModel, got %T", model)
}
raw, err := api.Query(ctx, "application.one", map[string]any{
"applicationId": redirect.ApplicationID.ValueString(),
})
if err != nil {
return nil, err
}
return collectNestedIDs(raw, "redirects", "redirectId")
},
Schema: schema.Schema{
MarkdownDescription: "A Traefik redirect rule attached to an application.",
Attributes: map[string]schema.Attribute{
"id": computedID("Unique redirect identifier."),
"regex": requiredString("Regular expression matched against the incoming URL."),
"replacement": requiredString("Replacement URL, which may reference capture groups such as `${1}`."),
"permanent": optionalComputedBool("Issue a permanent (301) redirect instead of a temporary " +
"(302) one."),
"application_id": requiredReplaceString("Application this redirect belongs to."),
"created_at": computedString("RFC 3339 timestamp of when the redirect was created."),
},
},
}
}
// ------------------------------------------------------------------ Security
type securityModel struct {
ID types.String `tfsdk:"id" dokploy:"securityId,id"`
Username types.String `tfsdk:"username" dokploy:"username"`
Password types.String `tfsdk:"password" dokploy:"password,noread"`
ApplicationID types.String `tfsdk:"application_id" dokploy:"applicationId,create"`
CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"`
}
func securityResource() ResourceSpec {
return ResourceSpec{
Name: "security",
CreateProc: "security.create",
ReadProc: "security.one",
UpdateProc: "security.update",
DeleteProc: "security.delete",
NewModel: func() any { return &securityModel{} },
// `security.create` returns `true`, so the new ID is discovered by
// diffing the application's basic-auth credential list.
ListIDs: func(ctx context.Context, api *client.Client, model any) (map[string]struct{}, error) {
security, ok := model.(*securityModel)
if !ok {
return nil, fmt.Errorf("expected *securityModel, got %T", model)
}
raw, err := api.Query(ctx, "application.one", map[string]any{
"applicationId": security.ApplicationID.ValueString(),
})
if err != nil {
return nil, err
}
return collectNestedIDs(raw, "security", "securityId")
},
Schema: schema.Schema{
MarkdownDescription: "HTTP basic authentication credentials protecting an application's domains.\n\n" +
"~> Dokploy stores the password hashed and does not return it. The value in Terraform state is " +
"the one you configured.",
Attributes: map[string]schema.Attribute{
"id": computedID("Unique credential identifier."),
"username": requiredString("Basic auth username."),
"password": sensitiveString("Basic auth password.", true),
"application_id": requiredReplaceString("Application these credentials protect."),
"created_at": computedString("RFC 3339 timestamp of when the credentials were created."),
},
},
}
}

View File

@@ -0,0 +1,87 @@
package provider
import (
"encoding/json"
"fmt"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/types"
)
type projectModel struct {
ID types.String `tfsdk:"id" dokploy:"projectId,id"`
Name types.String `tfsdk:"name" dokploy:"name"`
Description types.String `tfsdk:"description" dokploy:"description,nullable"`
Env types.String `tfsdk:"env" dokploy:"env"`
CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"`
OrgID types.String `tfsdk:"organization_id" dokploy:"organizationId,ro"`
// Derived in PostRead from the nested `environments` array.
DefaultEnvironmentID types.String `tfsdk:"default_environment_id"`
}
func projectResource() ResourceSpec {
return ResourceSpec{
Name: "project",
CreateProc: "project.create",
ReadProc: "project.one",
UpdateProc: "project.update",
DeleteProc: "project.remove",
CreateResponseKey: "project",
NewModel: func() any { return &projectModel{} },
PostRead: projectPostRead,
Schema: schema.Schema{
MarkdownDescription: "A Dokploy project: the top-level container for environments and services.\n\n" +
"Creating a project automatically creates a default `production` environment. Its ID is exposed as " +
"`default_environment_id`, so services can be attached without declaring a separate " +
"`dokploy_environment` resource.",
Attributes: map[string]schema.Attribute{
"id": computedID("Unique project identifier."),
"name": requiredString("Display name of the project."),
"description": optionalString("Free-form description."),
"env": optionalComputedString("Project-wide environment variables in `KEY=value` format, one per " +
"line. These are shared with every service in the project."),
"created_at": computedString("RFC 3339 timestamp of when the project was created."),
"organization_id": computedString("Organization that owns the project."),
"default_environment_id": computedString("ID of the `production` environment that Dokploy creates " +
"automatically with the project."),
},
},
}
}
// projectPostRead extracts the default environment from a `project.one`
// response so it can be referenced directly.
func projectPostRead(raw json.RawMessage, model any) error {
project, ok := model.(*projectModel)
if !ok {
return fmt.Errorf("expected *projectModel, got %T", model)
}
var payload struct {
Environments []struct {
EnvironmentID string `json:"environmentId"`
Name string `json:"name"`
IsDefault bool `json:"isDefault"`
} `json:"environments"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
return fmt.Errorf("decoding project environments: %w", err)
}
project.DefaultEnvironmentID = types.StringNull()
for _, env := range payload.Environments {
if env.IsDefault {
project.DefaultEnvironmentID = types.StringValue(env.EnvironmentID)
return nil
}
}
// Fall back to a conventionally named environment if none is flagged.
for _, env := range payload.Environments {
if env.Name == "production" {
project.DefaultEnvironmentID = types.StringValue(env.EnvironmentID)
return nil
}
}
return nil
}

View File

@@ -0,0 +1,223 @@
package provider
import (
"github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"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/int64planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
)
// Shorthand schema constructors. Dokploy assigns server-side defaults to most
// optional fields, so nearly everything optional is also Computed: that lets
// the API supply a value without Terraform reporting an inconsistent result.
func computedID(description string) schema.StringAttribute {
return schema.StringAttribute{
Computed: true,
MarkdownDescription: description,
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
}
}
func requiredString(description string) schema.StringAttribute {
return schema.StringAttribute{Required: true, MarkdownDescription: description}
}
// requiredReplaceString is a required attribute that cannot be changed in
// place; Dokploy has no API to move the resource, so Terraform recreates it.
func requiredReplaceString(description string) schema.StringAttribute {
return schema.StringAttribute{
Required: true,
MarkdownDescription: description,
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
}
}
func optionalReplaceString(description string) schema.StringAttribute {
return schema.StringAttribute{
Optional: true,
MarkdownDescription: description,
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
}
}
// requiresReplaceString is the plan-modifier list for an attribute that cannot
// be changed in place.
func requiresReplaceString() []planmodifier.String {
return []planmodifier.String{stringplanmodifier.RequiresReplace()}
}
func optionalString(description string) schema.StringAttribute {
return schema.StringAttribute{Optional: true, MarkdownDescription: description}
}
// computedString is a server-assigned value that Terraform never sends.
//
// UseStateForUnknown is essential here: without it every computed attribute is
// planned as unknown during an update, and any attribute referencing one --
// `environment_id = dokploy_project.x.default_environment_id`, say -- would
// then be unknown too, forcing a spurious replacement of the dependent
// resource. Read still refreshes these values, so genuine drift is detected.
func computedString(description string) schema.StringAttribute {
return schema.StringAttribute{
Computed: true,
MarkdownDescription: description,
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
}
}
// optionalComputedString is the workhorse: settable by the practitioner, and
// defaulted by Dokploy when omitted.
//
// UseStateForUnknown keeps plans readable. Without it the framework replans
// every unconfigured Optional+Computed attribute as unknown as soon as
// anything else on the resource changes, so a one-line edit renders as a dozen
// "(known after apply)" lines. Dokploy assigns these defaults once at create
// and then stores them, so reusing the prior value is accurate -- and Read
// still refreshes them, so real drift is still caught.
func optionalComputedString(description string) schema.StringAttribute {
return schema.StringAttribute{
Optional: true,
Computed: true,
MarkdownDescription: description,
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
}
}
// optionalComputedReplaceString is defaulted by Dokploy when omitted, but
// changing an explicitly configured value forces recreation.
func optionalComputedReplaceString(description string) schema.StringAttribute {
return schema.StringAttribute{
Optional: true,
Computed: true,
MarkdownDescription: description,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
stringplanmodifier.UseStateForUnknown(),
},
}
}
func sensitiveString(description string, required bool) schema.StringAttribute {
return schema.StringAttribute{
Required: required,
Optional: !required,
Computed: !required,
Sensitive: true,
MarkdownDescription: description,
}
}
func enumString(description string, values []string, required bool) schema.StringAttribute {
return schema.StringAttribute{
Required: required,
Optional: !required,
Computed: !required,
MarkdownDescription: description + " Valid values: `" + joinBackticked(values) + "`.",
Validators: []validator.String{stringvalidator.OneOf(values...)},
}
}
// enumStringWithDefault is an enum whose value Dokploy insists on receiving
// even though only one value is currently valid.
func enumStringWithDefault(description string, values []string, def string) schema.StringAttribute {
return schema.StringAttribute{
Optional: true,
Computed: true,
MarkdownDescription: description + " Valid values: `" + joinBackticked(values) + "`. Defaults to `" + def + "`.",
Validators: []validator.String{stringvalidator.OneOf(values...)},
Default: stringdefault.StaticString(def),
}
}
// computedBool mirrors computedString for boolean server-assigned values.
func computedBool(description string) schema.BoolAttribute {
return schema.BoolAttribute{
Computed: true,
MarkdownDescription: description,
PlanModifiers: []planmodifier.Bool{boolplanmodifier.UseStateForUnknown()},
}
}
func optionalComputedBool(description string) schema.BoolAttribute {
return schema.BoolAttribute{
Optional: true,
Computed: true,
MarkdownDescription: description,
PlanModifiers: []planmodifier.Bool{boolplanmodifier.UseStateForUnknown()},
}
}
// optionalBool has no server-side counterpart; it only steers provider
// behaviour. It is deliberately not Computed, so an unset value stays null and
// an imported resource shows no phantom diff.
func optionalBool(description string) schema.BoolAttribute {
return schema.BoolAttribute{Optional: true, MarkdownDescription: description}
}
func optionalComputedInt(description string) schema.Int64Attribute {
return schema.Int64Attribute{
Optional: true,
Computed: true,
MarkdownDescription: description,
PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()},
}
}
func requiredInt(description string) schema.Int64Attribute {
return schema.Int64Attribute{Required: true, MarkdownDescription: description}
}
func optionalComputedStringList(description string) schema.ListAttribute {
return schema.ListAttribute{
Optional: true,
Computed: true,
ElementType: types.StringType,
MarkdownDescription: description,
PlanModifiers: []planmodifier.List{listplanmodifier.UseStateForUnknown()},
}
}
// optionalJSON exposes one of Dokploy's free-form JSON columns (the Docker
// Swarm service settings) as a normalized JSON string, so semantically equal
// documents do not produce a diff.
func optionalJSON(description string) schema.StringAttribute {
return schema.StringAttribute{
Optional: true,
CustomType: jsontypes.NormalizedType{},
MarkdownDescription: description,
}
}
func joinBackticked(values []string) string {
out := ""
for i, v := range values {
if i > 0 {
out += "`, `"
}
out += v
}
return out
}
// Enum value sets mirrored from Dokploy's Postgres enums.
var (
certificateTypes = []string{"letsencrypt", "none", "custom"}
sourceTypes = []string{"docker", "git", "github", "gitlab", "bitbucket", "gitea", "drop"}
buildTypes = []string{"dockerfile", "heroku_buildpacks", "paketo_buildpacks", "nixpacks", "static", "railpack"}
triggerTypes = []string{"push", "tag"}
composeTypes = []string{"docker-compose", "stack"}
composeSources = []string{"git", "github", "gitlab", "bitbucket", "gitea", "raw"}
domainTypes = []string{"compose", "application", "preview"}
mountTypes = []string{"bind", "volume", "file"}
serviceTypes = []string{"application", "postgres", "mysql", "mariadb", "mongo", "redis", "compose"}
protocolTypes = []string{"tcp", "udp"}
publishModes = []string{"ingress", "host"}
)

555
internal/tfmap/tfmap.go Normal file
View File

@@ -0,0 +1,555 @@
// Package tfmap converts between Terraform Plugin Framework model structs and
// the flat JSON maps that the Dokploy API consumes and returns.
//
// Mapping is driven by a `dokploy:"..."` struct tag alongside the usual
// `tfsdk:"..."` tag:
//
// Name types.String `tfsdk:"name" dokploy:"name"`
// Description types.String `tfsdk:"description" dokploy:"description,nullable"`
// ID types.String `tfsdk:"id" dokploy:"projectId,id"`
// CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"`
//
// Options:
//
// id this field carries the resource's primary key
// ro read-only; never included in a request body
// create included only in create bodies
// update included only in update bodies
// nullable send an explicit JSON null when the value is null, instead of
// omitting the key (only valid for API fields that accept null)
//
// Fields with no create/update option are sent in both. Null values are
// omitted by default, because Dokploy's Zod schemas reject null for columns
// that are NOT NULL, but accept a missing key as "leave unchanged".
package tfmap
import (
"encoding/json"
"fmt"
"reflect"
"strings"
"github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
)
// Phase selects which subset of fields to serialize.
type Phase int
const (
// PhaseCreate builds a body for a `*.create` procedure.
PhaseCreate Phase = iota
// PhaseUpdate builds a body for an `*.update` procedure.
PhaseUpdate
)
type fieldSpec struct {
apiName string
index int
isID bool
readOnly bool
createOnly bool
updateOnly bool
nullable bool
noRead bool
}
func specsFor(model any) ([]fieldSpec, error) {
v := reflect.ValueOf(model)
if v.Kind() != reflect.Pointer || v.Elem().Kind() != reflect.Struct {
return nil, fmt.Errorf("tfmap: model must be a pointer to a struct, got %T", model)
}
t := v.Elem().Type()
specs := make([]fieldSpec, 0, t.NumField())
for i := 0; i < t.NumField(); i++ {
tag := t.Field(i).Tag.Get("dokploy")
if tag == "" || tag == "-" {
continue
}
parts := strings.Split(tag, ",")
spec := fieldSpec{apiName: parts[0], index: i}
for _, opt := range parts[1:] {
switch strings.TrimSpace(opt) {
case "id":
spec.isID = true
case "ro":
spec.readOnly = true
case "create":
spec.createOnly = true
case "update":
spec.updateOnly = true
case "nullable":
spec.nullable = true
case "noread":
spec.noRead = true
default:
return nil, fmt.Errorf("tfmap: unknown option %q on field %s", opt, t.Field(i).Name)
}
}
specs = append(specs, spec)
}
return specs, nil
}
// ToAPI serializes a model into a Dokploy request body for the given phase.
//
// The primary-key field is included for updates (the API needs it to address
// the row) and omitted for creates (the server generates it).
func ToAPI(model any, phase Phase) (map[string]any, error) {
specs, err := specsFor(model)
if err != nil {
return nil, err
}
elem := reflect.ValueOf(model).Elem()
body := map[string]any{}
for _, spec := range specs {
if spec.readOnly {
continue
}
if spec.isID {
if phase == PhaseUpdate {
if s, ok := elem.Field(spec.index).Interface().(types.String); ok && !s.IsNull() {
body[spec.apiName] = s.ValueString()
}
}
continue
}
if spec.createOnly && phase != PhaseCreate {
continue
}
if spec.updateOnly && phase != PhaseUpdate {
continue
}
value, known, isNull, err := attrToAny(elem.Field(spec.index))
if err != nil {
return nil, fmt.Errorf("field %q: %w", spec.apiName, err)
}
if !known {
// Unknown (computed at apply time) values must never be sent.
continue
}
if isNull {
if spec.nullable {
body[spec.apiName] = nil
}
continue
}
body[spec.apiName] = value
}
return body, nil
}
// IDValue returns the value of the model's `id`-tagged field.
func IDValue(model any) (string, error) {
specs, err := specsFor(model)
if err != nil {
return "", err
}
elem := reflect.ValueOf(model).Elem()
for _, spec := range specs {
if !spec.isID {
continue
}
s, ok := elem.Field(spec.index).Interface().(types.String)
if !ok {
return "", fmt.Errorf("tfmap: id field %q must be types.String", spec.apiName)
}
return s.ValueString(), nil
}
return "", fmt.Errorf("tfmap: model has no field tagged `id`")
}
// SetID writes id into the model's `id`-tagged field.
func SetID(model any, id string) error {
specs, err := specsFor(model)
if err != nil {
return err
}
elem := reflect.ValueOf(model).Elem()
for _, spec := range specs {
if !spec.isID {
continue
}
if _, ok := elem.Field(spec.index).Interface().(types.String); !ok {
return fmt.Errorf("tfmap: id field %q must be types.String", spec.apiName)
}
elem.Field(spec.index).Set(reflect.ValueOf(types.StringValue(id)))
return nil
}
return fmt.Errorf("tfmap: model has no field tagged `id`")
}
// IDAPIName returns the API field name of the model's primary key,
// e.g. "projectId".
func IDAPIName(model any) (string, error) {
specs, err := specsFor(model)
if err != nil {
return "", err
}
for _, spec := range specs {
if spec.isID {
return spec.apiName, nil
}
}
return "", fmt.Errorf("tfmap: model has no field tagged `id`")
}
// FromAPI populates a model from a Dokploy JSON response.
//
// Keys absent from the response leave the corresponding model field untouched,
// so partial responses do not clobber known state.
func FromAPI(raw json.RawMessage, model any) error {
var decoded map[string]json.RawMessage
if err := json.Unmarshal(raw, &decoded); err != nil {
return fmt.Errorf("tfmap: decoding response: %w", err)
}
specs, err := specsFor(model)
if err != nil {
return err
}
elem := reflect.ValueOf(model).Elem()
for _, spec := range specs {
if spec.noRead {
// The API returns a transformed value (a password hash, say) that
// must not overwrite what the practitioner configured.
continue
}
payload, present := decoded[spec.apiName]
if !present {
continue
}
if err := anyToAttr(payload, elem.Field(spec.index)); err != nil {
return fmt.Errorf("tfmap: field %q: %w", spec.apiName, err)
}
}
return nil
}
// NullifyUnknown replaces any attribute still marked unknown with a null of
// the same type.
//
// Terraform requires every value to be known once apply finishes. An attribute
// stays unknown when it was Optional+Computed in the plan and the API response
// simply omitted the corresponding key, which Dokploy does for several columns.
// Null is the honest representation: the server did not report a value.
func NullifyUnknown(model any) error {
specs, err := specsFor(model)
if err != nil {
return err
}
elem := reflect.ValueOf(model).Elem()
for _, spec := range specs {
field := elem.Field(spec.index)
value, ok := field.Interface().(attr.Value)
if !ok || !value.IsUnknown() {
continue
}
if err := setNull(field); err != nil {
return fmt.Errorf("tfmap: field %q: %w", spec.apiName, err)
}
}
return nil
}
func setNull(field reflect.Value) error {
switch field.Interface().(type) {
case jsontypes.Normalized:
field.Set(reflect.ValueOf(jsontypes.NewNormalizedNull()))
case types.String:
field.Set(reflect.ValueOf(types.StringNull()))
case types.Bool:
field.Set(reflect.ValueOf(types.BoolNull()))
case types.Int64:
field.Set(reflect.ValueOf(types.Int64Null()))
case types.Float64:
field.Set(reflect.ValueOf(types.Float64Null()))
case types.List:
field.Set(reflect.ValueOf(types.ListNull(types.StringType)))
case types.Set:
field.Set(reflect.ValueOf(types.SetNull(types.StringType)))
case types.Map:
field.Set(reflect.ValueOf(types.MapNull(types.StringType)))
default:
return fmt.Errorf("unsupported model field type %s", field.Type())
}
return nil
}
// attrToAny converts a framework value into a plain Go value suitable for JSON.
// It reports whether the value is known and whether it is null.
func attrToAny(field reflect.Value) (value any, known bool, isNull bool, err error) {
switch v := field.Interface().(type) {
case jsontypes.Normalized:
if v.IsUnknown() {
return nil, false, false, nil
}
if v.IsNull() {
return nil, true, true, nil
}
// Re-marshal so the API receives a real JSON object, not a string.
var parsed any
if err := json.Unmarshal([]byte(v.ValueString()), &parsed); err != nil {
return nil, false, false, fmt.Errorf("value is not valid JSON: %w", err)
}
return parsed, true, false, nil
case types.String:
if v.IsUnknown() {
return nil, false, false, nil
}
if v.IsNull() {
return nil, true, true, nil
}
return v.ValueString(), true, false, nil
case types.Bool:
if v.IsUnknown() {
return nil, false, false, nil
}
if v.IsNull() {
return nil, true, true, nil
}
return v.ValueBool(), true, false, nil
case types.Int64:
if v.IsUnknown() {
return nil, false, false, nil
}
if v.IsNull() {
return nil, true, true, nil
}
return v.ValueInt64(), true, false, nil
case types.Float64:
if v.IsUnknown() {
return nil, false, false, nil
}
if v.IsNull() {
return nil, true, true, nil
}
return v.ValueFloat64(), true, false, nil
case types.List:
return elementsToAny(v.IsUnknown(), v.IsNull(), v.Elements())
case types.Set:
return elementsToAny(v.IsUnknown(), v.IsNull(), v.Elements())
case types.Map:
if v.IsUnknown() {
return nil, false, false, nil
}
if v.IsNull() {
return nil, true, true, nil
}
out := map[string]any{}
for key, el := range v.Elements() {
s, ok := el.(types.String)
if !ok {
return nil, false, false, fmt.Errorf("map elements must be strings")
}
if s.IsNull() || s.IsUnknown() {
continue
}
out[key] = s.ValueString()
}
return out, true, false, nil
default:
return nil, false, false, fmt.Errorf("unsupported model field type %T", v)
}
}
func elementsToAny(unknown, null bool, elements []attr.Value) (any, bool, bool, error) {
if unknown {
return nil, false, false, nil
}
if null {
return nil, true, true, nil
}
out := make([]any, 0, len(elements))
for _, el := range elements {
switch e := el.(type) {
case types.String:
if e.IsNull() || e.IsUnknown() {
continue
}
out = append(out, e.ValueString())
case types.Int64:
if e.IsNull() || e.IsUnknown() {
continue
}
out = append(out, e.ValueInt64())
default:
return nil, false, false, fmt.Errorf("unsupported collection element type %T", el)
}
}
return out, true, false, nil
}
// anyToAttr decodes a raw JSON value into a framework model field.
func anyToAttr(payload json.RawMessage, field reflect.Value) error {
isNull := string(payload) == "null"
switch field.Interface().(type) {
case jsontypes.Normalized:
if isNull {
field.Set(reflect.ValueOf(jsontypes.NewNormalizedNull()))
return nil
}
field.Set(reflect.ValueOf(jsontypes.NewNormalizedValue(string(payload))))
return nil
case types.String:
if isNull {
field.Set(reflect.ValueOf(types.StringNull()))
return nil
}
var s string
if err := json.Unmarshal(payload, &s); err != nil {
// Tolerate scalars the API returns untyped (e.g. numeric strings).
var scalar any
if err2 := json.Unmarshal(payload, &scalar); err2 != nil {
return err
}
s = fmt.Sprintf("%v", scalar)
}
field.Set(reflect.ValueOf(types.StringValue(s)))
return nil
case types.Bool:
if isNull {
field.Set(reflect.ValueOf(types.BoolNull()))
return nil
}
var b bool
if err := json.Unmarshal(payload, &b); err != nil {
return err
}
field.Set(reflect.ValueOf(types.BoolValue(b)))
return nil
case types.Int64:
if isNull {
field.Set(reflect.ValueOf(types.Int64Null()))
return nil
}
var n json.Number
if err := json.Unmarshal(payload, &n); err != nil {
return err
}
i, err := n.Int64()
if err != nil {
f, ferr := n.Float64()
if ferr != nil {
return err
}
i = int64(f)
}
field.Set(reflect.ValueOf(types.Int64Value(i)))
return nil
case types.Float64:
if isNull {
field.Set(reflect.ValueOf(types.Float64Null()))
return nil
}
var f float64
if err := json.Unmarshal(payload, &f); err != nil {
return err
}
field.Set(reflect.ValueOf(types.Float64Value(f)))
return nil
case types.List:
if isNull {
field.Set(reflect.ValueOf(types.ListNull(types.StringType)))
return nil
}
var items []any
if err := json.Unmarshal(payload, &items); err != nil {
return err
}
values := make([]attr.Value, 0, len(items))
for _, item := range items {
values = append(values, types.StringValue(scalarToString(item)))
}
list, diags := types.ListValue(types.StringType, values)
if diags.HasError() {
return fmt.Errorf("building list: %v", diags.Errors())
}
field.Set(reflect.ValueOf(list))
return nil
case types.Set:
if isNull {
field.Set(reflect.ValueOf(types.SetNull(types.StringType)))
return nil
}
var items []any
if err := json.Unmarshal(payload, &items); err != nil {
return err
}
values := make([]attr.Value, 0, len(items))
for _, item := range items {
values = append(values, types.StringValue(scalarToString(item)))
}
set, diags := types.SetValue(types.StringType, values)
if diags.HasError() {
return fmt.Errorf("building set: %v", diags.Errors())
}
field.Set(reflect.ValueOf(set))
return nil
case types.Map:
if isNull {
field.Set(reflect.ValueOf(types.MapNull(types.StringType)))
return nil
}
var items map[string]any
if err := json.Unmarshal(payload, &items); err != nil {
return err
}
values := map[string]attr.Value{}
for key, item := range items {
values[key] = types.StringValue(scalarToString(item))
}
m, diags := types.MapValue(types.StringType, values)
if diags.HasError() {
return fmt.Errorf("building map: %v", diags.Errors())
}
field.Set(reflect.ValueOf(m))
return nil
default:
return fmt.Errorf("unsupported model field type %s", field.Type())
}
}
func scalarToString(v any) string {
switch t := v.(type) {
case string:
return t
case float64:
// JSON numbers decode as float64; render integers without a decimal.
if t == float64(int64(t)) {
return fmt.Sprintf("%d", int64(t))
}
return fmt.Sprintf("%v", t)
case nil:
return ""
default:
return fmt.Sprintf("%v", t)
}
}
// Ensure basetypes stays referenced for the type-switch cases above.
var _ = basetypes.NewStringNull

View File

@@ -0,0 +1,134 @@
package tfmap
import (
"encoding/json"
"testing"
"github.com/hashicorp/terraform-plugin-framework/types"
)
type sample struct {
ID types.String `tfsdk:"id" dokploy:"thingId,id"`
Name types.String `tfsdk:"name" dokploy:"name"`
Desc types.String `tfsdk:"description" dokploy:"description,nullable"`
Networks types.List `tfsdk:"network_ids" dokploy:"networkIds"`
Detach types.Bool `tfsdk:"detach" dokploy:"detach"`
Replicas types.Int64 `tfsdk:"replicas" dokploy:"replicas"`
Secret types.String `tfsdk:"secret" dokploy:"secret,noread"`
ServerID types.String `tfsdk:"server_id" dokploy:"serverId,create"`
Status types.String `tfsdk:"status" dokploy:"status,ro"`
}
// A JSON null in the response must produce a null attribute, never leave the
// attribute unknown: Terraform rejects unknown values after apply.
func TestFromAPIConvertsJSONNullToNullAttribute(t *testing.T) {
model := &sample{
Networks: types.ListUnknown(types.StringType),
Detach: types.BoolUnknown(),
Replicas: types.Int64Unknown(),
}
raw := json.RawMessage(`{"networkIds":null,"detach":null,"replicas":null}`)
if err := FromAPI(raw, model); err != nil {
t.Fatalf("FromAPI returned an error: %v", err)
}
if model.Networks.IsUnknown() {
t.Error("networkIds stayed unknown; want null")
}
if !model.Networks.IsNull() {
t.Errorf("networkIds = %v; want null", model.Networks)
}
if model.Detach.IsUnknown() || !model.Detach.IsNull() {
t.Errorf("detach = %v; want null", model.Detach)
}
if model.Replicas.IsUnknown() || !model.Replicas.IsNull() {
t.Errorf("replicas = %v; want null", model.Replicas)
}
}
// Keys absent from the response must not clobber existing values, but they
// must also not survive as unknown once NullifyUnknown runs.
func TestFromAPILeavesAbsentKeysUntouched(t *testing.T) {
model := &sample{
Name: types.StringValue("keep-me"),
Networks: types.ListUnknown(types.StringType),
}
if err := FromAPI(json.RawMessage(`{"description":"hi"}`), model); err != nil {
t.Fatalf("FromAPI returned an error: %v", err)
}
if got := model.Name.ValueString(); got != "keep-me" {
t.Errorf("name = %q; want %q", got, "keep-me")
}
if got := model.Desc.ValueString(); got != "hi" {
t.Errorf("description = %q; want %q", got, "hi")
}
if !model.Networks.IsUnknown() {
t.Error("networkIds should still be unknown before NullifyUnknown runs")
}
if err := NullifyUnknown(model); err != nil {
t.Fatalf("NullifyUnknown returned an error: %v", err)
}
if model.Networks.IsUnknown() {
t.Error("networkIds stayed unknown after NullifyUnknown")
}
}
// noread fields must never be overwritten by the API response.
func TestFromAPISkipsNoReadFields(t *testing.T) {
model := &sample{Secret: types.StringValue("plaintext")}
if err := FromAPI(json.RawMessage(`{"secret":"$2b$10$hashed"}`), model); err != nil {
t.Fatalf("FromAPI returned an error: %v", err)
}
if got := model.Secret.ValueString(); got != "plaintext" {
t.Errorf("secret = %q; want the configured value to survive", got)
}
}
func TestToAPIPhaseSelection(t *testing.T) {
model := &sample{
ID: types.StringValue("abc123"),
Name: types.StringValue("thing"),
Desc: types.StringNull(),
ServerID: types.StringValue("srv1"),
Status: types.StringValue("running"),
Networks: types.ListUnknown(types.StringType),
}
create, err := ToAPI(model, PhaseCreate)
if err != nil {
t.Fatalf("ToAPI(create) returned an error: %v", err)
}
if _, ok := create["thingId"]; ok {
t.Error("create body must not carry the server-generated ID")
}
if create["serverId"] != "srv1" {
t.Errorf("create body serverId = %v; want srv1", create["serverId"])
}
if _, ok := create["status"]; ok {
t.Error("read-only fields must never be sent")
}
if _, ok := create["networkIds"]; ok {
t.Error("unknown values must never be sent")
}
// description is null but tagged nullable, so it is sent explicitly.
value, ok := create["description"]
if !ok || value != nil {
t.Errorf("description = %v (present %v); want an explicit null", value, ok)
}
update, err := ToAPI(model, PhaseUpdate)
if err != nil {
t.Fatalf("ToAPI(update) returned an error: %v", err)
}
if update["thingId"] != "abc123" {
t.Errorf("update body thingId = %v; want abc123", update["thingId"])
}
if _, ok := update["serverId"]; ok {
t.Error("create-only fields must not be sent on update")
}
}