Six new resources, all backed by endpoints that did not exist before v0.30.0
and verified end-to-end against a live v0.30.2 instance:
dokploy_network Docker networks, now first-class. Services attach
through network_ids, which is what deprecates
Compose's isolated_deployment upstream.
dokploy_dns_provider Cloudflare or Route53, so adding a domain creates
its DNS record.
dokploy_vault_provider Env values resolved from HashiCorp Vault, Infisical,
AWS, Doppler, Azure or Scaleway at deploy time, so
the secret never lands in Dokploy or in state.
dokploy_schedule Cron jobs in a container, a stack, or on a server.
dokploy_volume_backup Scheduled backups of a named volume — the companion
to a mount that persists.
dokploy_libsql The sixth managed database engine.
libsql.create is the strictest endpoint in the API: eleven keys required to
be present, several only meaningfully null, no generated service name, and it
returns `true` rather than the row. CreateDefaults and ListIDs absorb all
three so the resource behaves like every other database.
Also filled the gaps a field-by-field diff against the live schema turned up:
domain gains `enabled` (the v0.30.0 park-a-domain toggle), compose gains
create_env_file, icon and service_networks, application gains icon and
preview_require_collaborator_permissions, and mounts accept libsql.
DNS and vault credentials are masked by Dokploy on read, so `config` is
tagged noread and keeps the configured value, as the basic-auth password
already does.
305 lines
14 KiB
Go
305 lines
14 KiB
Go
package provider
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"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-framework/types"
|
|
|
|
"github.com/maxvojtkov/terraform-provider-dokploy/internal/client"
|
|
)
|
|
|
|
// -------------------------------------------------------------------- Domain
|
|
|
|
type domainModel struct {
|
|
ID types.String `tfsdk:"id" dokploy:"domainId,id"`
|
|
Host types.String `tfsdk:"host" dokploy:"host"`
|
|
Path types.String `tfsdk:"path" dokploy:"path,nullable"`
|
|
Port types.Int64 `tfsdk:"port" dokploy:"port,nullable"`
|
|
HTTPS types.Bool `tfsdk:"https" dokploy:"https"`
|
|
CertificateType types.String `tfsdk:"certificate_type" dokploy:"certificateType"`
|
|
CustomCertResolver types.String `tfsdk:"custom_cert_resolver" dokploy:"customCertResolver,nullable"`
|
|
CustomEntrypoint types.String `tfsdk:"custom_entrypoint" dokploy:"customEntrypoint,nullable"`
|
|
DomainType types.String `tfsdk:"domain_type" dokploy:"domainType,nullable"`
|
|
ServiceName types.String `tfsdk:"service_name" dokploy:"serviceName,nullable"`
|
|
InternalPath types.String `tfsdk:"internal_path" dokploy:"internalPath,nullable"`
|
|
StripPath types.Bool `tfsdk:"strip_path" dokploy:"stripPath"`
|
|
Middlewares types.List `tfsdk:"middlewares" dokploy:"middlewares"`
|
|
ForwardAuthEnabled types.Bool `tfsdk:"forward_auth_enabled" dokploy:"forwardAuthEnabled"`
|
|
Enabled types.Bool `tfsdk:"enabled" dokploy:"enabled,nullable"`
|
|
ApplicationID types.String `tfsdk:"application_id" dokploy:"applicationId,create"`
|
|
ComposeID types.String `tfsdk:"compose_id" dokploy:"composeId,create"`
|
|
PreviewDeploymentID types.String `tfsdk:"preview_deployment_id" dokploy:"previewDeploymentId,create"`
|
|
CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"`
|
|
}
|
|
|
|
func domainResource() ResourceSpec {
|
|
return ResourceSpec{
|
|
Name: "domain",
|
|
CreateProc: "domain.create",
|
|
ReadProc: "domain.one",
|
|
UpdateProc: "domain.update",
|
|
DeleteProc: "domain.delete",
|
|
NewModel: func() any { return &domainModel{} },
|
|
Schema: schema.Schema{
|
|
MarkdownDescription: "A domain routed to an application or a Compose service through Dokploy's " +
|
|
"Traefik instance.\n\n" +
|
|
"Set exactly one of `application_id` or `compose_id`. When targeting a Compose stack, " +
|
|
"`service_name` selects which service in the stack receives the traffic.",
|
|
Attributes: map[string]schema.Attribute{
|
|
"id": computedID("Unique domain identifier."),
|
|
"host": requiredString("Fully-qualified hostname, for example `api.example.com`."),
|
|
"path": optionalComputedString("Path prefix this domain routes, defaults to `/`."),
|
|
"port": optionalComputedInt("Container port that receives the traffic, defaults to `3000`."),
|
|
"https": optionalComputedBool("Serve the domain over HTTPS and redirect HTTP traffic to it."),
|
|
"certificate_type": enumString(
|
|
"How TLS certificates are obtained. Use `letsencrypt` for automatic certificates.",
|
|
certificateTypes, false),
|
|
"custom_cert_resolver": optionalString("Traefik certificate resolver name, when " +
|
|
"`certificate_type` is `custom`."),
|
|
"custom_entrypoint": optionalString("Traefik entrypoint to bind, when not using the defaults."),
|
|
"domain_type": enumString("What kind of target this domain points at.", domainTypes, false),
|
|
"service_name": optionalString("Name of the service inside a Compose stack that receives the " +
|
|
"traffic. Required when `compose_id` is set."),
|
|
"internal_path": optionalComputedString("Path the request is rewritten to before it reaches the " +
|
|
"container, defaults to `/`."),
|
|
"strip_path": optionalComputedBool("Strip `path` from the request before forwarding it."),
|
|
"middlewares": optionalComputedStringList("Names of Traefik middlewares to apply."),
|
|
"forward_auth_enabled": optionalComputedBool("Protect this domain with Dokploy's forward auth."),
|
|
"enabled": optionalComputedBool("Whether the domain is served. Setting this to `false` removes " +
|
|
"the route from Traefik but keeps the certificate, path and middleware configuration intact, " +
|
|
"so the domain can be brought back without reconfiguring it."),
|
|
"application_id": optionalReplaceString("Application this domain routes to."),
|
|
"compose_id": optionalReplaceString("Compose stack this domain routes to."),
|
|
"preview_deployment_id": optionalReplaceString("Preview deployment this domain routes to."),
|
|
"created_at": computedString("RFC 3339 timestamp of when the domain was created."),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------------------- Mount
|
|
|
|
type mountModel struct {
|
|
ID types.String `tfsdk:"id" dokploy:"mountId,id"`
|
|
Type types.String `tfsdk:"type" dokploy:"type"`
|
|
MountPath types.String `tfsdk:"mount_path" dokploy:"mountPath"`
|
|
HostPath types.String `tfsdk:"host_path" dokploy:"hostPath,nullable"`
|
|
VolumeName types.String `tfsdk:"volume_name" dokploy:"volumeName,nullable"`
|
|
FilePath types.String `tfsdk:"file_path" dokploy:"filePath,nullable"`
|
|
Content types.String `tfsdk:"content" dokploy:"content,nullable"`
|
|
ServiceType types.String `tfsdk:"service_type" dokploy:"serviceType"`
|
|
|
|
// serviceId is only accepted on create; reads return the concrete
|
|
// applicationId/composeId/... column instead, so it is never refreshed.
|
|
ServiceID types.String `tfsdk:"service_id" dokploy:"serviceId,create"`
|
|
}
|
|
|
|
// mountConfigValidators enforce the type/field pairing that Dokploy itself
|
|
// does not.
|
|
//
|
|
// Dokploy's `generateVolumeMounts` renders a mount as
|
|
// `{Source: mount.volumeName || "", Target: mount.mountPath}`. A `volume`
|
|
// mount whose volumeName is null therefore reaches Docker with an empty
|
|
// source, which Docker treats as an *anonymous* volume: a fresh one is created
|
|
// on every deploy and the previous one is left orphaned, so the data silently
|
|
// never survives a redeploy. `mounts.create` accepts the mount regardless, so
|
|
// nothing surfaces until the data is already gone.
|
|
//
|
|
// The same shape applies to `bind` (hostPath) and `file` (filePath).
|
|
func mountConfigValidators() []resource.ConfigValidator {
|
|
return []resource.ConfigValidator{
|
|
&requiredWhen{
|
|
discriminator: path.Root("type"),
|
|
value: "volume",
|
|
attribute: path.Root("volume_name"),
|
|
rationale: "Dokploy passes an unset `volume_name` to Docker as an empty source, which creates " +
|
|
"a new anonymous volume on every deploy. The data written to the previous volume is " +
|
|
"orphaned and never reused, so the mount silently does not persist anything.",
|
|
},
|
|
&requiredWhen{
|
|
discriminator: path.Root("type"),
|
|
value: "bind",
|
|
attribute: path.Root("host_path"),
|
|
rationale: "A bind mount with no host path has nothing to bind to.",
|
|
},
|
|
&requiredWhen{
|
|
discriminator: path.Root("type"),
|
|
value: "file",
|
|
attribute: path.Root("file_path"),
|
|
rationale: "Dokploy writes `content` to `file_path` inside the service's files directory.",
|
|
},
|
|
}
|
|
}
|
|
|
|
func mountResource() ResourceSpec {
|
|
return ResourceSpec{
|
|
Name: "mount",
|
|
CreateProc: "mounts.create",
|
|
ReadProc: "mounts.one",
|
|
UpdateProc: "mounts.update",
|
|
DeleteProc: "mounts.remove",
|
|
NewModel: func() any { return &mountModel{} },
|
|
|
|
ConfigValidators: mountConfigValidators(),
|
|
|
|
Schema: schema.Schema{
|
|
MarkdownDescription: "A volume, bind mount, or config file attached to a Dokploy service.\n\n" +
|
|
"* `type = \"volume\"` — a named Docker volume; set `volume_name`.\n" +
|
|
"* `type = \"bind\"` — a path on the host; set `host_path`.\n" +
|
|
"* `type = \"file\"` — a file rendered from `content`; set `file_path`.\n\n" +
|
|
"~> **A `volume` mount must set `volume_name`.** Dokploy hands an unset name to Docker as an " +
|
|
"empty source, which creates a fresh anonymous volume on every deploy and orphans the " +
|
|
"previous one — the data never survives a redeploy. The provider rejects that combination " +
|
|
"at plan time.",
|
|
Attributes: map[string]schema.Attribute{
|
|
"id": computedID("Unique mount identifier."),
|
|
"type": enumString("The kind of mount to create.", mountTypes, true),
|
|
"mount_path": requiredString("Path inside the container where the mount appears."),
|
|
"host_path": optionalString("Path on the host, when `type` is `bind`."),
|
|
"volume_name": optionalString("Name of the Docker volume, when `type` is `volume`."),
|
|
"file_path": optionalString("Path of the generated file, when `type` is `file`."),
|
|
"content": optionalString("Contents of the generated file, when `type` is `file`."),
|
|
"service_type": enumString("The kind of service this mount attaches to.", serviceTypes, true),
|
|
"service_id": requiredReplaceString("ID of the service this mount attaches to. Must match " +
|
|
"`service_type` — an application ID, a compose ID, a postgres ID, and so on."),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------- Port
|
|
|
|
type portModel struct {
|
|
ID types.String `tfsdk:"id" dokploy:"portId,id"`
|
|
PublishedPort types.Int64 `tfsdk:"published_port" dokploy:"publishedPort"`
|
|
TargetPort types.Int64 `tfsdk:"target_port" dokploy:"targetPort"`
|
|
Protocol types.String `tfsdk:"protocol" dokploy:"protocol"`
|
|
PublishMode types.String `tfsdk:"publish_mode" dokploy:"publishMode"`
|
|
ApplicationID types.String `tfsdk:"application_id" dokploy:"applicationId,create"`
|
|
}
|
|
|
|
func portResource() ResourceSpec {
|
|
return ResourceSpec{
|
|
Name: "port",
|
|
CreateProc: "port.create",
|
|
ReadProc: "port.one",
|
|
UpdateProc: "port.update",
|
|
DeleteProc: "port.delete",
|
|
NewModel: func() any { return &portModel{} },
|
|
Schema: schema.Schema{
|
|
MarkdownDescription: "A published port that exposes an application directly on the host, " +
|
|
"bypassing Traefik.",
|
|
Attributes: map[string]schema.Attribute{
|
|
"id": computedID("Unique port identifier."),
|
|
"published_port": requiredInt("Port exposed on the host."),
|
|
"target_port": requiredInt("Port the container listens on."),
|
|
"protocol": enumString("Transport protocol.", protocolTypes, true),
|
|
"publish_mode": enumString("Docker Swarm publish mode. `host` binds directly to the node; "+
|
|
"`ingress` uses the swarm routing mesh.", publishModes, false),
|
|
"application_id": requiredReplaceString("Application this port belongs to."),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ Redirect
|
|
|
|
type redirectModel struct {
|
|
ID types.String `tfsdk:"id" dokploy:"redirectId,id"`
|
|
Regex types.String `tfsdk:"regex" dokploy:"regex"`
|
|
Replacement types.String `tfsdk:"replacement" dokploy:"replacement"`
|
|
Permanent types.Bool `tfsdk:"permanent" dokploy:"permanent"`
|
|
ApplicationID types.String `tfsdk:"application_id" dokploy:"applicationId,create"`
|
|
CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"`
|
|
}
|
|
|
|
func redirectResource() ResourceSpec {
|
|
return ResourceSpec{
|
|
Name: "redirect",
|
|
CreateProc: "redirects.create",
|
|
ReadProc: "redirects.one",
|
|
UpdateProc: "redirects.update",
|
|
DeleteProc: "redirects.delete",
|
|
NewModel: func() any { return &redirectModel{} },
|
|
// `redirects.create` returns `true`, so the new ID is discovered by
|
|
// diffing the application's redirect list.
|
|
ListIDs: func(ctx context.Context, api *client.Client, model any) (map[string]struct{}, error) {
|
|
redirect, ok := model.(*redirectModel)
|
|
if !ok {
|
|
return nil, fmt.Errorf("expected *redirectModel, got %T", model)
|
|
}
|
|
raw, err := api.Query(ctx, "application.one", map[string]any{
|
|
"applicationId": redirect.ApplicationID.ValueString(),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return collectNestedIDs(raw, "redirects", "redirectId")
|
|
},
|
|
Schema: schema.Schema{
|
|
MarkdownDescription: "A Traefik redirect rule attached to an application.",
|
|
Attributes: map[string]schema.Attribute{
|
|
"id": computedID("Unique redirect identifier."),
|
|
"regex": requiredString("Regular expression matched against the incoming URL."),
|
|
"replacement": requiredString("Replacement URL, which may reference capture groups such as `${1}`."),
|
|
"permanent": optionalComputedBool("Issue a permanent (301) redirect instead of a temporary " +
|
|
"(302) one."),
|
|
"application_id": requiredReplaceString("Application this redirect belongs to."),
|
|
"created_at": computedString("RFC 3339 timestamp of when the redirect was created."),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ Security
|
|
|
|
type securityModel struct {
|
|
ID types.String `tfsdk:"id" dokploy:"securityId,id"`
|
|
Username types.String `tfsdk:"username" dokploy:"username"`
|
|
Password types.String `tfsdk:"password" dokploy:"password,noread"`
|
|
ApplicationID types.String `tfsdk:"application_id" dokploy:"applicationId,create"`
|
|
CreatedAt types.String `tfsdk:"created_at" dokploy:"createdAt,ro"`
|
|
}
|
|
|
|
func securityResource() ResourceSpec {
|
|
return ResourceSpec{
|
|
Name: "security",
|
|
CreateProc: "security.create",
|
|
ReadProc: "security.one",
|
|
UpdateProc: "security.update",
|
|
DeleteProc: "security.delete",
|
|
NewModel: func() any { return &securityModel{} },
|
|
// `security.create` returns `true`, so the new ID is discovered by
|
|
// diffing the application's basic-auth credential list.
|
|
ListIDs: func(ctx context.Context, api *client.Client, model any) (map[string]struct{}, error) {
|
|
security, ok := model.(*securityModel)
|
|
if !ok {
|
|
return nil, fmt.Errorf("expected *securityModel, got %T", model)
|
|
}
|
|
raw, err := api.Query(ctx, "application.one", map[string]any{
|
|
"applicationId": security.ApplicationID.ValueString(),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return collectNestedIDs(raw, "security", "securityId")
|
|
},
|
|
Schema: schema.Schema{
|
|
MarkdownDescription: "HTTP basic authentication credentials protecting an application's domains.\n\n" +
|
|
"~> Dokploy stores the password hashed and does not return it. The value in Terraform state is " +
|
|
"the one you configured.",
|
|
Attributes: map[string]schema.Attribute{
|
|
"id": computedID("Unique credential identifier."),
|
|
"username": requiredString("Basic auth username."),
|
|
"password": sensitiveString("Basic auth password.", true),
|
|
"application_id": requiredReplaceString("Application these credentials protect."),
|
|
"created_at": computedString("RFC 3339 timestamp of when the credentials were created."),
|
|
},
|
|
},
|
|
}
|
|
}
|