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.
This commit is contained in:
@@ -4,6 +4,8 @@ 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"
|
||||
|
||||
@@ -92,6 +94,43 @@ type mountModel struct {
|
||||
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",
|
||||
@@ -100,11 +139,18 @@ func mountResource() ResourceSpec {
|
||||
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`.",
|
||||
"* `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),
|
||||
|
||||
Reference in New Issue
Block a user