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.
509 lines
16 KiB
Go
509 lines
16 KiB
Go
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] + "..."
|
|
}
|