diff --git a/docs/resources/storage_box.md b/docs/resources/storage_box.md index 997414626..546b58d44 100644 --- a/docs/resources/storage_box.md +++ b/docs/resources/storage_box.md @@ -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 +} ``` @@ -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. diff --git a/docs/resources/storage_box_subaccount.md b/docs/resources/storage_box_subaccount.md index 86a05cd9d..159ed8ba8 100644 --- a/docs/resources/storage_box_subaccount.md +++ b/docs/resources/storage_box_subaccount.md @@ -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 +} ``` @@ -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 diff --git a/examples/resources/hcloud_storage_box/resource.tf b/examples/resources/hcloud_storage_box/resource.tf index fbba06b81..14942b4f9 100644 --- a/examples/resources/hcloud_storage_box/resource.tf +++ b/examples/resources/hcloud_storage_box/resource.tf @@ -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 +} diff --git a/examples/resources/hcloud_storage_box_subaccount/resource.tf b/examples/resources/hcloud_storage_box_subaccount/resource.tf index 399d856a5..3c91bbaa9 100644 --- a/examples/resources/hcloud_storage_box_subaccount/resource.tf +++ b/examples/resources/hcloud_storage_box_subaccount/resource.tf @@ -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 +} diff --git a/internal/storagebox/resource.go b/internal/storagebox/resource.go index 6eca763ac..f6863e283 100644 --- a/internal/storagebox/resource.go +++ b/internal/storagebox/resource.go @@ -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" @@ -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" @@ -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{ @@ -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 @@ -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}, }, ) } @@ -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)...) @@ -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 @@ -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) @@ -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)...) } diff --git a/internal/storagebox/resource_test.go b/internal/storagebox/resource_test.go index b4cc67716..60e5a7c25 100644 --- a/internal/storagebox/resource_test.go +++ b/internal/storagebox/resource_test.go @@ -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" @@ -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)), + }, + }, + }, + }) +} diff --git a/internal/storagebox/testing.go b/internal/storagebox/testing.go index a358caac8..f1aab3a05 100644 --- a/internal/storagebox/testing.go +++ b/internal/storagebox/testing.go @@ -51,9 +51,11 @@ func (d *DDataList) TFID() string { type RData struct { testtemplate.DataCommon schema.StorageBox - Password string // nolint: gosec - SSHKeys []string - Raw string + Password string // nolint: gosec + PasswordWO string // nolint: gosec + PasswordWOVersion int + SSHKeys []string + Raw string } // TFID returns the resource identifier. diff --git a/internal/storageboxsubaccount/resource.go b/internal/storageboxsubaccount/resource.go index 8d43c6c91..f31cb557e 100644 --- a/internal/storageboxsubaccount/resource.go +++ b/internal/storageboxsubaccount/resource.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" + "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" @@ -16,6 +18,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" "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" @@ -115,9 +118,28 @@ See the [Storage Box Subaccounts API documentation](https://docs.hetzner.cloud/r Required: true, }, "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 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).", + Optional: true, + Sensitive: true, + Validators: []validator.String{ + stringvalidator.ExactlyOneOf(path.MatchRoot("password_wo")), + }, + }, + "password_wo": schema.StringAttribute{ + MarkdownDescription: "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).", + 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")), + }, }, "server": schema.StringAttribute{ MarkdownDescription: "FQDN of the Storage Box Subaccount.", @@ -172,6 +194,22 @@ type resourceModel struct { model Password types.String `tfsdk:"password"` + // 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"` +} + +// 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.StorageBoxSubaccount] = &resourceModel{} // reuse model, as the fields from resourceModel are not readable anyway @@ -181,7 +219,9 @@ func (m *resourceModel) tfAttributesTypes() map[string]attr.Type { return merge.Maps( (&model{}).tfAttributesTypes(), map[string]attr.Type{ - "password": types.StringType, + "password": types.StringType, + "password_wo": types.StringType, + "password_wo_version": types.Int64Type, }, ) } @@ -191,13 +231,25 @@ 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 + } + storageBox := &hcloud.StorageBox{ ID: data.StorageBoxID.ValueInt64(), } @@ -205,7 +257,7 @@ func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp opts := hcloud.StorageBoxSubaccountCreateOpts{ Name: data.Name.ValueString(), HomeDirectory: data.HomeDirectory.ValueString(), - Password: data.Password.ValueString(), + Password: password, Description: data.Description.ValueString(), } @@ -307,14 +359,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 + } + subaccount := &hcloud.StorageBoxSubaccount{ StorageBox: &hcloud.StorageBox{ID: data.StorageBoxID.ValueInt64()}, ID: data.ID.ValueInt64(), @@ -340,9 +400,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 + } + action, _, err := r.client.StorageBox.ResetSubaccountPassword(ctx, subaccount, hcloud.StorageBoxSubaccountResetPasswordOpts{ - Password: plan.Password.ValueString(), + Password: password, }) if err != nil { @@ -415,10 +484,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)...) } diff --git a/internal/storageboxsubaccount/resource_test.go b/internal/storageboxsubaccount/resource_test.go index c0a50740e..8d68de0ac 100644 --- a/internal/storageboxsubaccount/resource_test.go +++ b/internal/storageboxsubaccount/resource_test.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/statecheck" "github.com/hashicorp/terraform-plugin-testing/terraform" "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" @@ -177,3 +178,97 @@ func TestAccStorageBoxSubaccountResource(t *testing.T) { }, }) } + +func TestAccStorageBoxSubaccountResource_WriteOnlyPassword(t *testing.T) { + tmplMan := testtemplate.Manager{} + + subaccount := &hcloud.StorageBoxSubaccount{} + + resStorageBox := &storagebox.RData{ + StorageBox: schema.StorageBox{ + Name: fmt.Sprintf("storage-box-subaccount-wo-%s", randutil.GenerateID()), + StorageBoxType: schema.StorageBoxType{Name: teste2e.TestStorageBoxType}, + Location: schema.Location{Name: teste2e.TestLocationName}, + }, + Password: storagebox.GeneratePassword(t), + } + resStorageBox.SetRName("default") + + res := &storageboxsubaccount.RData{ + StorageBox: resStorageBox.TFID() + ".id", + HomeDirectory: "test", + PasswordWO: storagebox.GeneratePassword(t), + PasswordWOVersion: 1, + } + res.SetRName("subaccount") + + // 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(storageboxsubaccount.ResourceType, storageboxsubaccount.GetAPIResource()), + Steps: []resource.TestStep{ + { + // Create with a write-only password + Config: tmplMan.Render(t, + "testdata/r/hcloud_storage_box", resStorageBox, + "testdata/r/hcloud_storage_box_subaccount", res, + ), + Check: resource.ComposeTestCheckFunc( + testsupport.CheckAPIResourcePresent(res.TFID(), testsupport.CopyAPIResource(subaccount, storageboxsubaccount.GetAPIResource())), + ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(res.TFID(), tfjsonpath.New("username"), testsupport.StringExactFromFunc(func() string { return subaccount.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", resStorageBox, + "testdata/r/hcloud_storage_box_subaccount", 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", resStorageBox, + "testdata/r/hcloud_storage_box_subaccount", resNextVersion, + ), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction(resNextVersion.TFID(), plancheck.ResourceActionUpdate), + }, + }, + Check: resource.ComposeTestCheckFunc( + testsupport.CheckAPIResourcePresent(resNextVersion.TFID(), testsupport.CopyAPIResource(subaccount, storageboxsubaccount.GetAPIResource())), + ), + 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)), + }, + }, + }, + }) +} diff --git a/internal/storageboxsubaccount/testing.go b/internal/storageboxsubaccount/testing.go index 4653b3fb2..cee0242dd 100644 --- a/internal/storageboxsubaccount/testing.go +++ b/internal/storageboxsubaccount/testing.go @@ -59,12 +59,14 @@ func (d *DDataList) TFID() string { type RData struct { testtemplate.DataCommon - StorageBox string - HomeDirectory string - Name string - Password string // nolint: gosec - Description string - Labels map[string]string + StorageBox string + HomeDirectory string + Name string + Password string // nolint: gosec + PasswordWO string // nolint: gosec + PasswordWOVersion int + Description string + Labels map[string]string Raw string } diff --git a/internal/testdata/r/hcloud_storage_box.tf.tmpl b/internal/testdata/r/hcloud_storage_box.tf.tmpl index beab5187e..67e081896 100644 --- a/internal/testdata/r/hcloud_storage_box.tf.tmpl +++ b/internal/testdata/r/hcloud_storage_box.tf.tmpl @@ -4,7 +4,15 @@ resource "hcloud_storage_box" "{{ .RName }}" { name = "{{ .Name }}" storage_box_type = "{{ .StorageBoxType.Name }}" location = "{{ .Location.Name }}" - password = "{{ .Password }}" + + {{ if .Password -}} + password = "{{ .Password }}" + {{- end }} + + {{ if .PasswordWO -}} + password_wo = "{{ .PasswordWO }}" + password_wo_version = {{ .PasswordWOVersion }} + {{- end }} {{- if .Labels }} labels = {{ .Labels | toPrettyJson }} diff --git a/internal/testdata/r/hcloud_storage_box_subaccount.tf.tmpl b/internal/testdata/r/hcloud_storage_box_subaccount.tf.tmpl index 8380b19e5..77345fd47 100644 --- a/internal/testdata/r/hcloud_storage_box_subaccount.tf.tmpl +++ b/internal/testdata/r/hcloud_storage_box_subaccount.tf.tmpl @@ -4,7 +4,15 @@ resource "hcloud_storage_box_subaccount" "{{ .RName }}" { storage_box_id = {{ .StorageBox }} home_directory = "{{ .HomeDirectory }}" - password = "{{ .Password }}" + + {{ if .Password -}} + password = "{{ .Password }}" + {{- end }} + + {{ if .PasswordWO -}} + password_wo = "{{ .PasswordWO }}" + password_wo_version = {{ .PasswordWOVersion }} + {{- end }} {{ if .Name -}} name = "{{ .Name }}"