Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion docs/resources/storage_box.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,22 @@ resource "hcloud_storage_box" "ssh_key" {
prevent_destroy = true
}
}

# Keep the password out of the state: an ephemeral value can only be assigned to
# a write-only argument, and `password_wo_version` is what a change is read off.

ephemeral "random_password" "backup" {
length = 32
}

resource "hcloud_storage_box" "backup" {
name = "backup"
storage_box_type = "bx11"
location = "fsn1"

password_wo = ephemeral.random_password.backup.result
password_wo_version = 1
}
```

<!-- schema generated by tfplugindocs -->
Expand All @@ -78,14 +94,18 @@ resource "hcloud_storage_box" "ssh_key" {

- `location` (String) Name of the Location.
- `name` (String) Name of the Storage Box.
- `password` (String, Sensitive) Password of the Storage Box. For more details, see the [Storage Boxes password policy](https://docs.hetzner.cloud/reference/hetzner#storage-boxes-password-policy).
- `storage_box_type` (String) Name of the Storage Box Type.

### Optional

> **NOTE**: [Write-only arguments](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments) are supported in Terraform 1.11 and later.

- `access_settings` (Attributes) Access settings of the Storage Box. (see [below for nested schema](#nestedatt--access_settings))
- `delete_protection` (Boolean) Prevent the Storage Box from being accidentally deleted outside of Terraform.
- `labels` (Map of String) User-defined [labels](https://docs.hetzner.cloud/reference/cloud#labels) (key-value pairs) for the resource.
- `password` (String, Sensitive) Password of the Storage Box. Stored in the Terraform state; use `password_wo` to keep it out. Exactly one of `password` and `password_wo` must be set. For more details, see the [Storage Boxes password policy](https://docs.hetzner.cloud/reference/hetzner#storage-boxes-password-policy).
- `password_wo` (String, Sensitive, [Write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments)) Password of the Storage Box, as a [write-only argument](https://developer.hashicorp.com/terraform/language/resources/ephemeral/write-only): it is never written to the Terraform state. Requires `password_wo_version`. For more details, see the [Storage Boxes password policy](https://docs.hetzner.cloud/reference/hetzner#storage-boxes-password-policy).
- `password_wo_version` (Number) Version of `password_wo`. The value of `password_wo` cannot be compared against the API or the state, so a password change is triggered by incrementing this instead.
- `snapshot_plan` (Attributes) Details of the active snapshot plan. (see [below for nested schema](#nestedatt--snapshot_plan))
- `ssh_keys` (Set of String) SSH public keys in OpenSSH format to inject into the Storage Box. It is not possible to update the SSH Keys through the API, so changing this attribute forces a replace of the Storage Box.

Expand Down
23 changes: 22 additions & 1 deletion docs/resources/storage_box_subaccount.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@ resource "hcloud_storage_box_subaccount" "team_badger" {
team = "badger"
}
}

# Keep the password out of the state: an ephemeral value can only be assigned to
# a write-only argument, and `password_wo_version` is what a change is read off.

ephemeral "random_password" "team_beaver" {
length = 32
}

resource "hcloud_storage_box_subaccount" "team_beaver" {
storage_box_id = hcloud_storage_box.main.id

name = "beaver"
home_directory = "teams/beaver/"

password_wo = ephemeral.random_password.team_beaver.result
password_wo_version = 1
}
```

<!-- schema generated by tfplugindocs -->
Expand All @@ -53,15 +70,19 @@ resource "hcloud_storage_box_subaccount" "team_badger" {
### Required

- `home_directory` (String) Home directory of the Storage Box Subaccount. The directory will be created if it doesn't exist yet. Must not include a leading slash (`/`).
- `password` (String, Sensitive) Password of the Storage Box. For more details, see the [Storage Boxes password policy](https://docs.hetzner.cloud/reference/hetzner#storage-boxes-password-policy).
- `storage_box_id` (Number) ID of the Storage Box.

### Optional

> **NOTE**: [Write-only arguments](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments) are supported in Terraform 1.11 and later.

- `access_settings` (Attributes) Access settings for the Subaccount. (see [below for nested schema](#nestedatt--access_settings))
- `description` (String) A description of the Storage Box Subaccount.
- `labels` (Map of String) User-defined [labels](https://docs.hetzner.cloud/reference/cloud#labels) (key-value pairs) for the resource.
- `name` (String) Name of the Storage Box Subaccount.
- `password` (String, Sensitive) Password of the Storage Box Subaccount. Stored in the Terraform state; use `password_wo` to keep it out. Exactly one of `password` and `password_wo` must be set. For more details, see the [Storage Boxes password policy](https://docs.hetzner.cloud/reference/hetzner#storage-boxes-password-policy).
- `password_wo` (String, Sensitive, [Write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments)) Password of the Storage Box Subaccount, as a [write-only argument](https://developer.hashicorp.com/terraform/language/resources/ephemeral/write-only): it is never written to the Terraform state. Requires `password_wo_version`. For more details, see the [Storage Boxes password policy](https://docs.hetzner.cloud/reference/hetzner#storage-boxes-password-policy).
- `password_wo_version` (Number) Version of `password_wo`. The value of `password_wo` cannot be compared against the API or the state, so a password change is triggered by incrementing this instead.

### Read-Only

Expand Down
16 changes: 16 additions & 0 deletions examples/resources/hcloud_storage_box/resource.tf
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,19 @@ resource "hcloud_storage_box" "ssh_key" {
prevent_destroy = true
}
}

# Keep the password out of the state: an ephemeral value can only be assigned to
# a write-only argument, and `password_wo_version` is what a change is read off.

ephemeral "random_password" "backup" {
length = 32
}

resource "hcloud_storage_box" "backup" {
name = "backup"
storage_box_type = "bx11"
location = "fsn1"

password_wo = ephemeral.random_password.backup.result
password_wo_version = 1
}
17 changes: 17 additions & 0 deletions examples/resources/hcloud_storage_box_subaccount/resource.tf
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,20 @@ resource "hcloud_storage_box_subaccount" "team_badger" {
team = "badger"
}
}

# Keep the password out of the state: an ephemeral value can only be assigned to
# a write-only argument, and `password_wo_version` is what a change is read off.

ephemeral "random_password" "team_beaver" {
length = 32
}

resource "hcloud_storage_box_subaccount" "team_beaver" {
storage_box_id = hcloud_storage_box.main.id

name = "beaver"
home_directory = "teams/beaver/"

password_wo = ephemeral.random_password.team_beaver.result
password_wo_version = 1
}
96 changes: 84 additions & 12 deletions internal/storagebox/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"strconv"

"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
Expand All @@ -16,6 +18,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/resource/schema/setdefault"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"

"github.com/hetznercloud/hcloud-go/v2/hcloud"
Expand Down Expand Up @@ -106,9 +109,28 @@ See the [Storage Box API documentation](https://docs.hetzner.cloud/reference/het
},
},
"password": schema.StringAttribute{
MarkdownDescription: "Password of the Storage Box. For more details, see the [Storage Boxes password policy](https://docs.hetzner.cloud/reference/hetzner#storage-boxes-password-policy).",
Required: true,
MarkdownDescription: "Password of the Storage Box. Stored in the Terraform state; use `password_wo` to keep it out. Exactly one of `password` and `password_wo` must be set. For more details, see the [Storage Boxes password policy](https://docs.hetzner.cloud/reference/hetzner#storage-boxes-password-policy).",
Optional: true,
Sensitive: true,
Validators: []validator.String{
stringvalidator.ExactlyOneOf(path.MatchRoot("password_wo")),
},
},
"password_wo": schema.StringAttribute{
MarkdownDescription: "Password of the Storage Box, as a [write-only argument](https://developer.hashicorp.com/terraform/language/resources/ephemeral/write-only): it is never written to the Terraform state. Requires `password_wo_version`. For more details, see the [Storage Boxes password policy](https://docs.hetzner.cloud/reference/hetzner#storage-boxes-password-policy).",
Optional: true,
Sensitive: true,
WriteOnly: true,
Validators: []validator.String{
stringvalidator.AlsoRequires(path.MatchRoot("password_wo_version")),
},
},
"password_wo_version": schema.Int64Attribute{
MarkdownDescription: "Version of `password_wo`. The value of `password_wo` cannot be compared against the API or the state, so a password change is triggered by incrementing this instead.",
Optional: true,
Validators: []validator.Int64{
int64validator.AlsoRequires(path.MatchRoot("password_wo")),
},
},
"labels": resourceutil.LabelsSchema(),
"ssh_keys": schema.SetAttribute{
Expand Down Expand Up @@ -212,7 +234,23 @@ type resourceModel struct {
commonModel

Password types.String `tfsdk:"password"`
SSHKeys types.Set `tfsdk:"ssh_keys"`
// Write-only attributes are null in plan and state; their value is only
// readable from the configuration.
PasswordWO types.String `tfsdk:"password_wo"`
PasswordWOVersion types.Int64 `tfsdk:"password_wo_version"`
SSHKeys types.Set `tfsdk:"ssh_keys"`
}

// password returns the password to send to the API, from whichever of the two
// attributes carries it, and whether one is set at all.
func (m *resourceModel) password(config *resourceModel) (string, bool) {
if !m.Password.IsNull() && !m.Password.IsUnknown() {
return m.Password.ValueString(), true
}
if config != nil && !config.PasswordWO.IsNull() && !config.PasswordWO.IsUnknown() {
return config.PasswordWO.ValueString(), true
}
return "", false
}

var _ util.ModelFromAPI[*hcloud.StorageBox] = &resourceModel{} // reuse commonModel, as the fields from resourceModel are not readable anyway
Expand All @@ -222,8 +260,10 @@ func (m *resourceModel) tfAttributesTypes() map[string]attr.Type {
return merge.Maps(
(&commonModel{}).tfAttributesTypes(),
map[string]attr.Type{
"password": types.StringType,
"ssh_keys": types.SetType{ElemType: types.StringType},
"password": types.StringType,
"password_wo": types.StringType,
"password_wo_version": types.Int64Type,
"ssh_keys": types.SetType{ElemType: types.StringType},
},
)
}
Expand All @@ -233,18 +273,30 @@ func (m *resourceModel) ToTerraform(ctx context.Context) (types.Object, diag.Dia
}

func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data resourceModel
var data, config resourceModel

resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
// A write-only attribute is null in the plan and in the state; the configuration
// is the only place its value can be read from.
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}

password, ok := data.password(&config)
if !ok {
resp.Diagnostics.AddError(
"Missing password",
"Exactly one of `password` and `password_wo` must be set, and neither carries a value.",
)
return
}

opts := hcloud.StorageBoxCreateOpts{
Name: data.Name.ValueString(),
StorageBoxType: &hcloud.StorageBoxType{Name: data.StorageBoxType.ValueString()},
Location: &hcloud.Location{Name: data.Location.ValueString()},
Password: data.Password.ValueString(),
Password: password,
}

resp.Diagnostics.Append(hcloudutil.TerraformLabelsToHCloud(ctx, data.Labels, &opts.Labels)...)
Expand Down Expand Up @@ -381,14 +433,22 @@ func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *res
}

func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var data, plan resourceModel
var data, plan, config resourceModel

resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}

// A stored password is compared; a write-only one cannot be, so its version is
// what a change is read off.
resetPassword := !plan.Password.IsUnknown() && !plan.Password.IsNull() && !plan.Password.Equal(data.Password)
if !config.PasswordWO.IsNull() && !plan.PasswordWOVersion.Equal(data.PasswordWOVersion) {
resetPassword = true
}

storageBox := &hcloud.StorageBox{ID: data.ID.ValueInt64()}

// Run Actions
Expand Down Expand Up @@ -474,9 +534,18 @@ func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp
}

// Action: Reset Password
if !plan.Password.IsUnknown() && !plan.Password.Equal(data.Password) {
if resetPassword {
password, ok := plan.password(&config)
if !ok {
resp.Diagnostics.AddError(
"Missing password",
"Exactly one of `password` and `password_wo` must be set, and neither carries a value.",
)
return
}

opts := hcloud.StorageBoxResetPasswordOpts{
Password: plan.Password.ValueString(),
Password: password,
}

action, _, err := r.client.StorageBox.ResetPassword(ctx, storageBox, opts)
Expand Down Expand Up @@ -546,10 +615,13 @@ func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp
}

// At this point the change password action was successful.
// We have to update the value saved in the state, this does not happen in `data.FromAPI()`.
if !plan.Password.IsUnknown() && !plan.Password.Equal(data.Password) {
// We have to update the values saved in the state, this does not happen in `data.FromAPI()`.
// A null `password` is written through as well: that is what drops a stored
// password from the state when a configuration moves over to `password_wo`.
if !plan.Password.IsUnknown() {
data.Password = plan.Password
}
data.PasswordWOVersion = plan.PasswordWOVersion

resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
Expand Down
76 changes: 76 additions & 0 deletions internal/storagebox/resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/hashicorp/terraform-plugin-testing/plancheck"
"github.com/hashicorp/terraform-plugin-testing/statecheck"
"github.com/hashicorp/terraform-plugin-testing/tfjsonpath"
"github.com/hashicorp/terraform-plugin-testing/tfversion"

"github.com/hetznercloud/hcloud-go/v2/hcloud"
"github.com/hetznercloud/hcloud-go/v2/hcloud/exp/kit/randutil"
Expand Down Expand Up @@ -194,3 +195,78 @@ func TestAccStorageBoxResource(t *testing.T) {
},
})
}

func TestAccStorageBoxResource_WriteOnlyPassword(t *testing.T) {
tmplMan := testtemplate.Manager{}

storageBox := &hcloud.StorageBox{}

res := &storagebox.RData{
StorageBox: schema.StorageBox{
Name: fmt.Sprintf("storage-box-wo-%s", randutil.GenerateID()),
StorageBoxType: schema.StorageBoxType{Name: teste2e.TestStorageBoxType},
Location: schema.Location{Name: teste2e.TestLocationName},
},
PasswordWO: storagebox.GeneratePassword(t),
PasswordWOVersion: 1,
}
res.SetRName("storage_box_wo")

// A new value under the same version: the write-only value is not in the state,
// so nothing can compare it, and the version is what says whether it changed.
resSameVersion := testtemplate.DeepCopy(t, res)
resSameVersion.PasswordWO = storagebox.GeneratePassword(t)

resNextVersion := testtemplate.DeepCopy(t, resSameVersion)
resNextVersion.PasswordWOVersion = 2

resource.ParallelTest(t, resource.TestCase{
PreCheck: teste2e.PreCheck(t),
// Write-only arguments need Terraform 1.11 / OpenTofu 1.11 or later.
TerraformVersionChecks: []tfversion.TerraformVersionCheck{
tfversion.SkipBelow(tfversion.Version1_11_0),
},
ProtoV6ProviderFactories: testmux.ProtoV6ProviderFactories(),
CheckDestroy: testsupport.CheckAPIResourceAllAbsent(storagebox.ResourceType, storagebox.GetAPIResource()),
Steps: []resource.TestStep{
{
// Create with a write-only password
Config: tmplMan.Render(t, "testdata/r/hcloud_storage_box", res),
Check: resource.ComposeTestCheckFunc(
testsupport.CheckAPIResourcePresent(res.TFID(), testsupport.CopyAPIResource(storageBox, storagebox.GetAPIResource())),
),
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue(res.TFID(), tfjsonpath.New("username"), testsupport.StringExactFromFunc(func() string { return storageBox.Username })),
// The point of the whole attribute: neither the value nor a copy
// of it under `password` is written to the state.
statecheck.ExpectKnownValue(res.TFID(), tfjsonpath.New("password"), knownvalue.Null()),
statecheck.ExpectKnownValue(res.TFID(), tfjsonpath.New("password_wo"), knownvalue.Null()),
statecheck.ExpectKnownValue(res.TFID(), tfjsonpath.New("password_wo_version"), knownvalue.Int64Exact(1)),
},
},
{
// A different write-only value under the same version changes nothing
Config: tmplMan.Render(t, "testdata/r/hcloud_storage_box", resSameVersion),
ConfigPlanChecks: resource.ConfigPlanChecks{
PreApply: []plancheck.PlanCheck{
plancheck.ExpectEmptyPlan(),
},
},
},
{
// Raising the version resets the password, and updates rather than replaces
Config: tmplMan.Render(t, "testdata/r/hcloud_storage_box", resNextVersion),
ConfigPlanChecks: resource.ConfigPlanChecks{
PreApply: []plancheck.PlanCheck{
plancheck.ExpectResourceAction(resNextVersion.TFID(), plancheck.ResourceActionUpdate),
},
},
ConfigStateChecks: []statecheck.StateCheck{
statecheck.ExpectKnownValue(resNextVersion.TFID(), tfjsonpath.New("password"), knownvalue.Null()),
statecheck.ExpectKnownValue(resNextVersion.TFID(), tfjsonpath.New("password_wo"), knownvalue.Null()),
statecheck.ExpectKnownValue(resNextVersion.TFID(), tfjsonpath.New("password_wo_version"), knownvalue.Int64Exact(2)),
},
},
},
})
}
Loading