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] + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user