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

View File

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