Files
terraform-provider-dokploy/internal/provider/resource_networking.go
T
max-voitcov 2d1caf6e73
build / build (push) Successful in 3m56s
release / release (push) Successful in 15m2s
Cover what Dokploy v0.30 added
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.
2026-08-26 00:40:27 +03:00

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