Files
terraform-provider-dokploy/internal/provider/resource_networking.go
T
max-voitcov 3ddce62647 Reject volume mounts that silently never persist
A `dokploy_mount` with `type = "volume"` and no `volume_name` was accepted
by both this provider and Dokploy. Dokploy renders the mount as
`{Source: volumeName || "", Target: mountPath}`, and Docker reads an empty
source as an anonymous volume: every deploy created a fresh one and orphaned
the last, so the data never survived a redeploy while disk usage climbed.
Nothing errored at any point, which is what made it worth catching here.

The pairing is now checked at plan time, before anything is created, and the
error explains the consequence rather than only the rule. The same validator
covers `bind` without `host_path` and `file` without `file_path`, and rejects
a field set against the wrong type, which Dokploy would otherwise ignore.

Verified against a live v0.30.2 instance: the offending config plans cleanly
before the change and is refused after it.
2026-08-26 00:40:03 +03:00

301 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"`
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."),
"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."),
},
},
}
}