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:
280
internal/client/client.go
Normal file
280
internal/client/client.go
Normal 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] + "..."
|
||||
}
|
||||
467
internal/provider/data_sources.go
Normal file
467
internal/provider/data_sources.go
Normal 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)
|
||||
}
|
||||
508
internal/provider/generic.go
Normal file
508
internal/provider/generic.go
Normal 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] + "..."
|
||||
}
|
||||
171
internal/provider/provider.go
Normal file
171
internal/provider/provider.go
Normal 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)
|
||||
}
|
||||
293
internal/provider/provider_test.go
Normal file
293
internal/provider/provider_test.go
Normal 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"),
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
190
internal/provider/resource_account.go
Normal file
190
internal/provider/resource_account.go
Normal 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 ®istryModel{} },
|
||||
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."),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
257
internal/provider/resource_application.go
Normal file
257
internal/provider/resource_application.go
Normal 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."),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
144
internal/provider/resource_compose.go
Normal file
144
internal/provider/resource_compose.go
Normal 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."),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
406
internal/provider/resource_databases.go
Normal file
406
internal/provider/resource_databases.go
Normal 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`."),
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
49
internal/provider/resource_environment.go
Normal file
49
internal/provider/resource_environment.go
Normal 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."),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
254
internal/provider/resource_networking.go
Normal file
254
internal/provider/resource_networking.go
Normal 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."),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
87
internal/provider/resource_project.go
Normal file
87
internal/provider/resource_project.go
Normal 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
|
||||
}
|
||||
223
internal/provider/schema_helpers.go
Normal file
223
internal/provider/schema_helpers.go
Normal 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
555
internal/tfmap/tfmap.go
Normal 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
|
||||
134
internal/tfmap/tfmap_test.go
Normal file
134
internal/tfmap/tfmap_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user