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] + "..." }