A Terraform provider for Dokploy

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

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

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

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