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

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

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