diff --git a/docs/resources/telemetrylink.md b/docs/resources/telemetrylink.md index b18679309..fbab0898f 100644 --- a/docs/resources/telemetrylink.md +++ b/docs/resources/telemetrylink.md @@ -14,14 +14,17 @@ TelemetryLink instance resource schema. Uses the `default_region` specified in t ```terraform resource "stackit_telemetrylink" "link" { - resource_type = "project" - resource_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - region = "eu01" - display_name = "telemetrylink-example" - access_token = "eyJxxx" - telemetry_router_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + resource_type = "project" + resource_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + region = "eu01" + display_name = "telemetrylink-example" + access_token_wo = "eyJxxx" + access_token_wo_version = 1 + telemetry_router_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" } +# access_token is kept for backwards compatibility, but access_token_wo (see above) should be preferred +# since it is never persisted to the Terraform state. resource "stackit_telemetrylink" "link2" { resource_type = "project" resource_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" @@ -45,7 +48,11 @@ resource "stackit_telemetrylink" "link2" { ### Optional -- `access_token` (String, Sensitive) The access token of the Telemetry Router instance. +> **NOTE**: [Write-only arguments](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments) are supported in Terraform 1.11 and later. + +- `access_token` (String, Sensitive) The access token of the Telemetry Router instance. Write-only argument `access_token_wo` should be preferred. +- `access_token_wo` (String, Sensitive, [Write-only](https://developer.hashicorp.com/terraform/language/resources/ephemeral#write-only-arguments)) The access token of the Telemetry Router instance. Write-only - never stored in state and never returned by the API. To rotate the token, update this value AND increment `access_token_wo_version`. Changing this field alone will NOT trigger an update. +- `access_token_wo_version` (Number) User-managed rotation counter for `access_token_wo`. Must be incremented every time `access_token_wo` is changed. Terraform diffs this field to detect token rotations - changing `access_token_wo` alone will NOT trigger an update because it is write-only and never stored in state. - `description` (String) The description of the Telemetry Link resource. - `region` (String) STACKIT region name the resource is located in. If not defined, the provider region is used. diff --git a/examples/resources/stackit_telemetrylink/resource.tf b/examples/resources/stackit_telemetrylink/resource.tf index 56de5c17d..8dd3055e2 100644 --- a/examples/resources/stackit_telemetrylink/resource.tf +++ b/examples/resources/stackit_telemetrylink/resource.tf @@ -1,12 +1,15 @@ resource "stackit_telemetrylink" "link" { - resource_type = "project" - resource_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - region = "eu01" - display_name = "telemetrylink-example" - access_token = "eyJxxx" - telemetry_router_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + resource_type = "project" + resource_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + region = "eu01" + display_name = "telemetrylink-example" + access_token_wo = "eyJxxx" + access_token_wo_version = 1 + telemetry_router_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" } +# access_token is kept for backwards compatibility, but access_token_wo (see above) should be preferred +# since it is never persisted to the Terraform state. resource "stackit_telemetrylink" "link2" { resource_type = "project" resource_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" diff --git a/stackit/internal/services/telemetrylink/link/resource.go b/stackit/internal/services/telemetrylink/link/resource.go index 4716b4b08..a833ec116 100644 --- a/stackit/internal/services/telemetrylink/link/resource.go +++ b/stackit/internal/services/telemetrylink/link/resource.go @@ -8,8 +8,9 @@ import ( "strings" "time" + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" - "github.com/hashicorp/terraform-plugin-framework/diag" + "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/resource/schema/planmodifier" @@ -56,8 +57,12 @@ var schemaDescriptions = map[string]string{ "display_name": "The displayed name of the Telemetry Link resource.", "description": "The description of the Telemetry Link resource.", "telemetry_router_id": "The Telemetry Router ID.", - "access_token": "The access token of the Telemetry Router instance.", - "create_time": "The time the Telemetry Link was created.", + "access_token": "The access token of the Telemetry Router instance. Write-only argument `access_token_wo` should be preferred.", + "access_token_wo": "The access token of the Telemetry Router instance. Write-only - never stored in state and never returned by the API. " + + "To rotate the token, update this value AND increment `access_token_wo_version`. Changing this field alone will NOT trigger an update.", + "access_token_wo_version": "User-managed rotation counter for `access_token_wo`. Must be incremented every time `access_token_wo` is changed. " + + "Terraform diffs this field to detect token rotations - changing `access_token_wo` alone will NOT trigger an update because it is write-only and never stored in state.", + "create_time": "The time the Telemetry Link was created.", "status": fmt.Sprintf( "The status of the TelemetryLink, possible values: %s", tfutils.FormatPossibleValues(sdkUtils.EnumSliceToStringSlice(telemetrylink.AllowedTelemetryLinkResponseStatusEnumValues)...), @@ -65,16 +70,18 @@ var schemaDescriptions = map[string]string{ } type Model struct { - ID types.String `tfsdk:"id"` // Required by Terraform - Region types.String `tfsdk:"region"` - ResourceType types.String `tfsdk:"resource_type"` - ResourceID types.String `tfsdk:"resource_id"` - DisplayName types.String `tfsdk:"display_name"` - Description types.String `tfsdk:"description"` - TelemetryRouterID types.String `tfsdk:"telemetry_router_id"` - AccessToken types.String `tfsdk:"access_token"` - CreateTime types.String `tfsdk:"create_time"` - Status types.String `tfsdk:"status"` + ID types.String `tfsdk:"id"` // Required by Terraform + Region types.String `tfsdk:"region"` + ResourceType types.String `tfsdk:"resource_type"` + ResourceID types.String `tfsdk:"resource_id"` + DisplayName types.String `tfsdk:"display_name"` + Description types.String `tfsdk:"description"` + TelemetryRouterID types.String `tfsdk:"telemetry_router_id"` + AccessToken types.String `tfsdk:"access_token"` + AccessTokenWo types.String `tfsdk:"access_token_wo"` + AccessTokenWoVersion types.Int64 `tfsdk:"access_token_wo_version"` + CreateTime types.String `tfsdk:"create_time"` + Status types.String `tfsdk:"status"` } type telemetryLinkResource struct { @@ -193,6 +200,31 @@ func (r *telemetryLinkResource) Schema(_ context.Context, _ resource.SchemaReque PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, + Validators: []validator.String{ + stringvalidator.ConflictsWith( + path.MatchRoot("access_token_wo"), + path.MatchRoot("access_token_wo_version"), + ), + stringvalidator.PreferWriteOnlyAttribute(path.MatchRoot("access_token_wo")), + }, + }, + "access_token_wo": schema.StringAttribute{ + Description: schemaDescriptions["access_token_wo"], + Optional: true, + WriteOnly: true, + Sensitive: true, + Validators: []validator.String{ + stringvalidator.ConflictsWith(path.MatchRoot("access_token")), + stringvalidator.AlsoRequires(path.MatchRoot("access_token_wo_version")), + }, + }, + "access_token_wo_version": schema.Int64Attribute{ + Description: schemaDescriptions["access_token_wo_version"], + Optional: true, + Validators: []validator.Int64{ + int64validator.AlsoRequires(path.MatchRoot("access_token_wo")), + int64validator.ConflictsWith(path.MatchRoot("access_token")), + }, }, "create_time": schema.StringAttribute{ Description: schemaDescriptions["create_time"], @@ -208,8 +240,18 @@ func (r *telemetryLinkResource) Schema(_ context.Context, _ resource.SchemaReque } func (r *telemetryLinkResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform - var model Model - diags := req.Plan.Get(ctx, &model) + var planModel Model + diags := req.Plan.Get(ctx, &planModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // The config model has to be used because Terraform doesn't include write-only field values in the + // plan and state models - for security measures. Write-only values should only be kept in the config + // model so that they never end up in the state (or plan). + var configModel Model + diags = req.Config.Get(ctx, &configModel) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return @@ -217,17 +259,17 @@ func (r *telemetryLinkResource) Create(ctx context.Context, req resource.CreateR ctx = core.InitProviderContext(ctx) - resourceType := model.ResourceType.ValueString() - resourceID := model.ResourceID.ValueString() - region := r.providerData.GetRegionWithOverride(model.Region) + resourceType := planModel.ResourceType.ValueString() + resourceID := planModel.ResourceID.ValueString() + region := r.providerData.GetRegionWithOverride(planModel.Region) ctx = tflog.SetField(ctx, "resource_type", resourceType) ctx = tflog.SetField(ctx, "resource_id", resourceID) ctx = tflog.SetField(ctx, "region", region) var response *telemetrylink.TelemetryLinkResponse - switch model.ResourceType.ValueString() { + switch planModel.ResourceType.ValueString() { case resourceTypeOrganization: - payload, err := toCreateOrUpdateOrganizationTelemetryLinkPayload(ctx, resp.Diagnostics, &model) + payload, err := toCreateOrUpdateOrganizationTelemetryLinkPayload(&planModel, &configModel) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating TelemetryLink", fmt.Sprintf("Creating API payload: %v", err)) return @@ -266,7 +308,7 @@ func (r *telemetryLinkResource) Create(ctx context.Context, req resource.CreateR } case resourceTypeFolder: - payload, err := toCreateOrUpdateFolderTelemetryLinkPayload(ctx, resp.Diagnostics, &model) + payload, err := toCreateOrUpdateFolderTelemetryLinkPayload(&planModel, &configModel) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating TelemetryLink", fmt.Sprintf("Creating API payload: %v", err)) return @@ -304,7 +346,7 @@ func (r *telemetryLinkResource) Create(ctx context.Context, req resource.CreateR return } case resourceTypeProject: - payload, err := toCreateOrUpdateProjectTelemetryLinkPayload(ctx, resp.Diagnostics, &model) + payload, err := toCreateOrUpdateProjectTelemetryLinkPayload(&planModel, &configModel) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating TelemetryLink", fmt.Sprintf("Creating API payload: %v", err)) return @@ -342,16 +384,16 @@ func (r *telemetryLinkResource) Create(ctx context.Context, req resource.CreateR return } default: - core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating TelemetryLink", fmt.Sprintf("Unsupported resource type: %s", model.ResourceType.ValueString())) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating TelemetryLink", fmt.Sprintf("Unsupported resource type: %s", planModel.ResourceType.ValueString())) return } - err := mapFields(ctx, response, &model, region) + err := mapFields(ctx, response, &planModel, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating TelemetryLink", fmt.Sprintf("Processing response: %v", err)) return } - diags = resp.State.Set(ctx, model) + diags = resp.State.Set(ctx, planModel) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return @@ -419,8 +461,28 @@ func (r *telemetryLinkResource) Read(ctx context.Context, req resource.ReadReque } func (r *telemetryLinkResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform - var model Model - diags := req.Plan.Get(ctx, &model) + // the regular plan model one always uses in the update implementation + var planModel Model + diags := req.Plan.Get(ctx, &planModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // the state model - contains the "previous" values and is needed to compare the old write-only version field + // values to the new ones + var stateModel Model + diags = req.State.Get(ctx, &stateModel) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // The config model - this has to be used because Terraform doesn't include write-only field values in the + // plan and state models - for security measures. Write-only values should be only kept in the config model + // so that they never end up in the state (or plan). + var configModel Model + diags = req.Config.Get(ctx, &configModel) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return @@ -428,24 +490,27 @@ func (r *telemetryLinkResource) Update(ctx context.Context, req resource.UpdateR ctx = core.InitProviderContext(ctx) - resourceType := model.ResourceType.ValueString() - resourceID := model.ResourceID.ValueString() - region := r.providerData.GetRegionWithOverride(model.Region) + resourceType := planModel.ResourceType.ValueString() + resourceID := planModel.ResourceID.ValueString() + region := r.providerData.GetRegionWithOverride(planModel.Region) ctx = tflog.SetField(ctx, "resource_type", resourceType) ctx = tflog.SetField(ctx, "resource_id", resourceID) ctx = tflog.SetField(ctx, "region", region) + // Uses PartialUpdate* to omit AccessToken, preserving backend tokens for write-only (access_token_wo) support. var response *telemetrylink.TelemetryLinkResponse - switch model.ResourceType.ValueString() { + switch resourceType { case resourceTypeOrganization: - payload, err := toCreateOrUpdateOrganizationTelemetryLinkPayload(ctx, resp.Diagnostics, &model) + payload, err := toPartialUpdateOrganizationTelemetryLinkPayload(&planModel, &stateModel, &configModel) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating TelemetryLink", fmt.Sprintf("Creating API payload: %v", err)) return } - _, err = r.client.DefaultAPI.CreateOrUpdateOrganizationTelemetryLink(ctx, resourceID, region).CreateOrUpdateOrganizationTelemetryLinkPayload(*payload).Execute() + _, err = r.client.DefaultAPI.PartialUpdateOrganizationTelemetryLink(ctx, resourceID, region). + PartialUpdateOrganizationTelemetryLinkPayload(*payload). + Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating TelemetryLink", fmt.Sprintf("Calling API: %v", err)) return @@ -453,19 +518,21 @@ func (r *telemetryLinkResource) Update(ctx context.Context, req resource.UpdateR ctx = core.LogResponse(ctx) - response, err = wait.UpdateOrganizationTelemetryLinkWaitHandler(ctx, r.client.DefaultAPI, resourceID, region).WaitWithContext(ctx) + response, err = wait.PartialUpdateOrganizationTelemetryLinkWaitHandler(ctx, r.client.DefaultAPI, resourceID, region).WaitWithContext(ctx) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating TelemetryLink", fmt.Sprintf("Waiting for TelemetryLink to become active: %v", err)) return } case resourceTypeFolder: - payload, err := toCreateOrUpdateFolderTelemetryLinkPayload(ctx, resp.Diagnostics, &model) + payload, err := toPartialUpdateFolderTelemetryLinkPayload(&planModel, &stateModel, &configModel) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating TelemetryLink", fmt.Sprintf("Creating API payload: %v", err)) return } - _, err = r.client.DefaultAPI.CreateOrUpdateFolderTelemetryLink(ctx, resourceID, region).CreateOrUpdateFolderTelemetryLinkPayload(*payload).Execute() + _, err = r.client.DefaultAPI.PartialUpdateFolderTelemetryLink(ctx, resourceID, region). + PartialUpdateFolderTelemetryLinkPayload(*payload). + Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating TelemetryLink", fmt.Sprintf("Calling API: %v", err)) return @@ -473,19 +540,21 @@ func (r *telemetryLinkResource) Update(ctx context.Context, req resource.UpdateR ctx = core.LogResponse(ctx) - response, err = wait.UpdateFolderTelemetryLinkWaitHandler(ctx, r.client.DefaultAPI, resourceID, region).WaitWithContext(ctx) + response, err = wait.PartialUpdateFolderTelemetryLinkWaitHandler(ctx, r.client.DefaultAPI, resourceID, region).WaitWithContext(ctx) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating TelemetryLink", fmt.Sprintf("Waiting for TelemetryLink to become active: %v", err)) return } case resourceTypeProject: - payload, err := toCreateOrUpdateProjectTelemetryLinkPayload(ctx, resp.Diagnostics, &model) + payload, err := toPartialUpdateProjectTelemetryLinkPayload(&planModel, &stateModel, &configModel) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating TelemetryLink", fmt.Sprintf("Creating API payload: %v", err)) return } - _, err = r.client.DefaultAPI.CreateOrUpdateProjectTelemetryLink(ctx, resourceID, region).CreateOrUpdateProjectTelemetryLinkPayload(*payload).Execute() + _, err = r.client.DefaultAPI.PartialUpdateProjectTelemetryLink(ctx, resourceID, region). + PartialUpdateProjectTelemetryLinkPayload(*payload). + Execute() if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating TelemetryLink", fmt.Sprintf("Calling API: %v", err)) return @@ -493,23 +562,23 @@ func (r *telemetryLinkResource) Update(ctx context.Context, req resource.UpdateR ctx = core.LogResponse(ctx) - response, err = wait.UpdateProjectTelemetryLinkWaitHandler(ctx, r.client.DefaultAPI, resourceID, region).WaitWithContext(ctx) + response, err = wait.PartialUpdateProjectTelemetryLinkWaitHandler(ctx, r.client.DefaultAPI, resourceID, region).WaitWithContext(ctx) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating TelemetryLink", fmt.Sprintf("Waiting for TelemetryLink to become active: %v", err)) return } default: - core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating TelemetryLink", fmt.Sprintf("Unsupported resource type: %s", model.ResourceType.ValueString())) + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating TelemetryLink", fmt.Sprintf("Unsupported resource type: %s", resourceType)) return } - err := mapFields(ctx, response, &model, region) + err := mapFields(ctx, response, &planModel, region) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating TelemetryLink", fmt.Sprintf("Processing response: %v", err)) return } - diags = resp.State.Set(ctx, model) + diags = resp.State.Set(ctx, planModel) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return @@ -605,42 +674,149 @@ func (r *telemetryLinkResource) ImportState(ctx context.Context, req resource.Im tflog.Info(ctx, "TelemetryLink state imported") } -func toCreateOrUpdateOrganizationTelemetryLinkPayload(_ context.Context, _ diag.Diagnostics, model *Model) (*telemetrylink.CreateOrUpdateOrganizationTelemetryLinkPayload, error) { - if model == nil { - return nil, fmt.Errorf("missing model") +// getAccessTokenForCreate resolves the access token to send on create. Terraform keeps write-only +// field values in the config model - and they shouldn't leave this config model to make sure they +// don't end up being stored in the state. In the plan model the write-only field value is just empty. +// That's why everything is read from the plan model, except for the write-only field value, which is +// read from the config model. +func getAccessTokenForCreate(planModel, configModel *Model) string { + if !tfutils.IsUndefined(planModel.AccessToken) { + // handle the legacy fallback logic + return planModel.AccessToken.ValueString() + } else if !tfutils.IsUndefined(configModel.AccessTokenWo) && + !tfutils.IsUndefined(planModel.AccessTokenWoVersion) { + // the user is using the write-only field + return configModel.AccessTokenWo.ValueString() + } + + return "" +} + +// getAccessTokenForUpdate resolves the access token to send on update. Returns nil if the token +// should not be touched, which the PartialUpdate* API interprets as "keep the currently stored +// token". This is only reached when access_token_wo_version didn't change between state (old) and +// plan (new) - i.e. the customer didn't intend to rotate the token. +func getAccessTokenForUpdate(planModel, stateModel, configModel *Model) *string { + if !tfutils.IsUndefined(planModel.AccessToken) { + // handle the legacy fallback logic + return planModel.AccessToken.ValueStringPointer() + } else if !tfutils.IsUndefined(configModel.AccessTokenWo) { + // write-only field is set, handle the write-only and version logic + + // check if the version changed between state (old) and plan (new) + if !planModel.AccessTokenWoVersion.Equal(stateModel.AccessTokenWoVersion) { + // the user bumped the version meaning we need to send the write-only field value to the API + return configModel.AccessTokenWo.ValueStringPointer() + } + } + + return nil +} + +func toCreateOrUpdateOrganizationTelemetryLinkPayload(planModel, configModel *Model) (*telemetrylink.CreateOrUpdateOrganizationTelemetryLinkPayload, error) { + if planModel == nil { + return nil, fmt.Errorf("missing plan model") + } + if configModel == nil { + return nil, fmt.Errorf("missing config model") } return &telemetrylink.CreateOrUpdateOrganizationTelemetryLinkPayload{ - DisplayName: model.DisplayName.ValueString(), - Description: model.Description.ValueStringPointer(), - TelemetryRouterId: model.TelemetryRouterID.ValueString(), - AccessToken: model.AccessToken.ValueString(), + DisplayName: planModel.DisplayName.ValueString(), + Description: planModel.Description.ValueStringPointer(), + TelemetryRouterId: planModel.TelemetryRouterID.ValueString(), + AccessToken: getAccessTokenForCreate(planModel, configModel), }, nil } -func toCreateOrUpdateFolderTelemetryLinkPayload(_ context.Context, _ diag.Diagnostics, model *Model) (*telemetrylink.CreateOrUpdateFolderTelemetryLinkPayload, error) { - if model == nil { - return nil, fmt.Errorf("missing model") +func toCreateOrUpdateFolderTelemetryLinkPayload(planModel, configModel *Model) (*telemetrylink.CreateOrUpdateFolderTelemetryLinkPayload, error) { + if planModel == nil { + return nil, fmt.Errorf("missing plan model") + } + if configModel == nil { + return nil, fmt.Errorf("missing config model") } return &telemetrylink.CreateOrUpdateFolderTelemetryLinkPayload{ - DisplayName: model.DisplayName.ValueString(), - Description: model.Description.ValueStringPointer(), - TelemetryRouterId: model.TelemetryRouterID.ValueString(), - AccessToken: model.AccessToken.ValueString(), + DisplayName: planModel.DisplayName.ValueString(), + Description: planModel.Description.ValueStringPointer(), + TelemetryRouterId: planModel.TelemetryRouterID.ValueString(), + AccessToken: getAccessTokenForCreate(planModel, configModel), }, nil } -func toCreateOrUpdateProjectTelemetryLinkPayload(_ context.Context, _ diag.Diagnostics, model *Model) (*telemetrylink.CreateOrUpdateProjectTelemetryLinkPayload, error) { - if model == nil { - return nil, fmt.Errorf("missing model") +func toCreateOrUpdateProjectTelemetryLinkPayload(planModel, configModel *Model) (*telemetrylink.CreateOrUpdateProjectTelemetryLinkPayload, error) { + if planModel == nil { + return nil, fmt.Errorf("missing plan model") + } + if configModel == nil { + return nil, fmt.Errorf("missing config model") } return &telemetrylink.CreateOrUpdateProjectTelemetryLinkPayload{ - DisplayName: model.DisplayName.ValueString(), - Description: model.Description.ValueStringPointer(), - TelemetryRouterId: model.TelemetryRouterID.ValueString(), - AccessToken: model.AccessToken.ValueString(), + DisplayName: planModel.DisplayName.ValueString(), + Description: planModel.Description.ValueStringPointer(), + TelemetryRouterId: planModel.TelemetryRouterID.ValueString(), + AccessToken: getAccessTokenForCreate(planModel, configModel), + }, nil +} + +func toPartialUpdateOrganizationTelemetryLinkPayload(planModel, stateModel, configModel *Model) (*telemetrylink.PartialUpdateOrganizationTelemetryLinkPayload, error) { + if planModel == nil { + return nil, fmt.Errorf("missing plan model") + } + if stateModel == nil { + return nil, fmt.Errorf("missing state model") + } + if configModel == nil { + return nil, fmt.Errorf("missing config model") + } + + return &telemetrylink.PartialUpdateOrganizationTelemetryLinkPayload{ + DisplayName: planModel.DisplayName.ValueStringPointer(), + // description is Optional, so it must be sent as an explicit "" instead of + // being omitted when cleared + Description: tfutils.StringPointerOrEmpty(planModel.Description), + TelemetryRouterId: planModel.TelemetryRouterID.ValueStringPointer(), + AccessToken: getAccessTokenForUpdate(planModel, stateModel, configModel), + }, nil +} + +func toPartialUpdateFolderTelemetryLinkPayload(planModel, stateModel, configModel *Model) (*telemetrylink.PartialUpdateFolderTelemetryLinkPayload, error) { + if planModel == nil { + return nil, fmt.Errorf("missing plan model") + } + if stateModel == nil { + return nil, fmt.Errorf("missing state model") + } + if configModel == nil { + return nil, fmt.Errorf("missing config model") + } + + return &telemetrylink.PartialUpdateFolderTelemetryLinkPayload{ + DisplayName: planModel.DisplayName.ValueStringPointer(), + Description: tfutils.StringPointerOrEmpty(planModel.Description), + TelemetryRouterId: planModel.TelemetryRouterID.ValueStringPointer(), + AccessToken: getAccessTokenForUpdate(planModel, stateModel, configModel), + }, nil +} + +func toPartialUpdateProjectTelemetryLinkPayload(planModel, stateModel, configModel *Model) (*telemetrylink.PartialUpdateProjectTelemetryLinkPayload, error) { + if planModel == nil { + return nil, fmt.Errorf("missing plan model") + } + if stateModel == nil { + return nil, fmt.Errorf("missing state model") + } + if configModel == nil { + return nil, fmt.Errorf("missing config model") + } + + return &telemetrylink.PartialUpdateProjectTelemetryLinkPayload{ + DisplayName: planModel.DisplayName.ValueStringPointer(), + Description: tfutils.StringPointerOrEmpty(planModel.Description), + TelemetryRouterId: planModel.TelemetryRouterID.ValueStringPointer(), + AccessToken: getAccessTokenForUpdate(planModel, stateModel, configModel), }, nil } diff --git a/stackit/internal/services/telemetrylink/link/resource_test.go b/stackit/internal/services/telemetrylink/link/resource_test.go index 6d139080a..fd94cc79b 100644 --- a/stackit/internal/services/telemetrylink/link/resource_test.go +++ b/stackit/internal/services/telemetrylink/link/resource_test.go @@ -6,7 +6,6 @@ import ( "time" "github.com/google/go-cmp/cmp" - "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types" telemetrylink "github.com/stackitcloud/stackit-sdk-go/services/telemetrylink/v1api" ) @@ -29,16 +28,18 @@ func fixtureLink(mods ...func(link *telemetrylink.TelemetryLinkResponse)) *telem func fixtureModel(mods ...func(model *Model)) *Model { model := &Model{ - ID: types.StringValue("rtp,rid,reg"), - Region: types.StringValue("reg"), - ResourceType: types.StringValue("rtp"), - ResourceID: types.StringValue("rid"), - DisplayName: types.StringValue("name"), - Description: types.String{}, - TelemetryRouterID: types.StringValue("tlmrid"), - AccessToken: types.String{}, - CreateTime: types.StringValue(testTime.Format(time.RFC3339)), - Status: types.StringValue("active"), + ID: types.StringValue("rtp,rid,reg"), + Region: types.StringValue("reg"), + ResourceType: types.StringValue("rtp"), + ResourceID: types.StringValue("rid"), + DisplayName: types.StringValue("name"), + Description: types.String{}, + TelemetryRouterID: types.StringValue("tlmrid"), + AccessToken: types.String{}, + AccessTokenWo: types.String{}, + AccessTokenWoVersion: types.Int64{}, + CreateTime: types.StringValue(testTime.Format(time.RFC3339)), + Status: types.StringValue("active"), } for _, mod := range mods { mod(model) @@ -106,12 +107,14 @@ func TestToCreateOrUpdateOrganizationTelemetryLinkPayload(t *testing.T) { tests := []struct { description string model *Model + configModel *Model expected *telemetrylink.CreateOrUpdateOrganizationTelemetryLinkPayload wantErrMessage string }{ { description: "min values", model: fixtureModel(), + configModel: fixtureModel(), expected: &telemetrylink.CreateOrUpdateOrganizationTelemetryLinkPayload{ DisplayName: "name", AccessToken: "", @@ -119,13 +122,14 @@ func TestToCreateOrUpdateOrganizationTelemetryLinkPayload(t *testing.T) { }, }, { - description: "max values", + description: "max values, legacy access_token", model: fixtureModel(func(model *Model) { model.Description = types.StringValue("description") model.DisplayName = types.StringValue("display-name") model.AccessToken = types.StringValue("access-token") model.TelemetryRouterID = types.StringValue("tlmr_id") }), + configModel: fixtureModel(), expected: &telemetrylink.CreateOrUpdateOrganizationTelemetryLinkPayload{ Description: new("description"), DisplayName: "display-name", @@ -133,14 +137,28 @@ func TestToCreateOrUpdateOrganizationTelemetryLinkPayload(t *testing.T) { TelemetryRouterId: "tlmr_id", }, }, + { + description: "write-only access_token_wo", + model: fixtureModel(func(model *Model) { + model.AccessTokenWoVersion = types.Int64Value(1) + }), + configModel: fixtureModel(func(model *Model) { + model.AccessTokenWo = types.StringValue("wo-access-token") + }), + expected: &telemetrylink.CreateOrUpdateOrganizationTelemetryLinkPayload{ + DisplayName: "name", + AccessToken: "wo-access-token", + TelemetryRouterId: "tlmrid", + }, + }, { description: "nil model", - wantErrMessage: "missing model", + wantErrMessage: "missing plan model", }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - got, err := toCreateOrUpdateOrganizationTelemetryLinkPayload(t.Context(), diag.Diagnostics{}, tt.model) + got, err := toCreateOrUpdateOrganizationTelemetryLinkPayload(tt.model, tt.configModel) if tt.wantErrMessage != "" && (err == nil || err.Error() != tt.wantErrMessage) { t.Fatalf("Expected error: %v, got: %v", tt.wantErrMessage, err) } @@ -159,12 +177,14 @@ func TestToCreateOrUpdateFolderTelemetryLinkPayload(t *testing.T) { tests := []struct { description string model *Model + configModel *Model expected *telemetrylink.CreateOrUpdateFolderTelemetryLinkPayload wantErrMessage string }{ { description: "min values", model: fixtureModel(), + configModel: fixtureModel(), expected: &telemetrylink.CreateOrUpdateFolderTelemetryLinkPayload{ DisplayName: "name", AccessToken: "", @@ -172,13 +192,14 @@ func TestToCreateOrUpdateFolderTelemetryLinkPayload(t *testing.T) { }, }, { - description: "max values", + description: "max values, legacy access_token", model: fixtureModel(func(model *Model) { model.Description = types.StringValue("description") model.DisplayName = types.StringValue("display-name") model.AccessToken = types.StringValue("access-token") model.TelemetryRouterID = types.StringValue("tlmr_id") }), + configModel: fixtureModel(), expected: &telemetrylink.CreateOrUpdateFolderTelemetryLinkPayload{ Description: new("description"), DisplayName: "display-name", @@ -186,14 +207,28 @@ func TestToCreateOrUpdateFolderTelemetryLinkPayload(t *testing.T) { TelemetryRouterId: "tlmr_id", }, }, + { + description: "write-only access_token_wo", + model: fixtureModel(func(model *Model) { + model.AccessTokenWoVersion = types.Int64Value(1) + }), + configModel: fixtureModel(func(model *Model) { + model.AccessTokenWo = types.StringValue("wo-access-token") + }), + expected: &telemetrylink.CreateOrUpdateFolderTelemetryLinkPayload{ + DisplayName: "name", + AccessToken: "wo-access-token", + TelemetryRouterId: "tlmrid", + }, + }, { description: "nil model", - wantErrMessage: "missing model", + wantErrMessage: "missing plan model", }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - got, err := toCreateOrUpdateFolderTelemetryLinkPayload(t.Context(), diag.Diagnostics{}, tt.model) + got, err := toCreateOrUpdateFolderTelemetryLinkPayload(tt.model, tt.configModel) if tt.wantErrMessage != "" && (err == nil || err.Error() != tt.wantErrMessage) { t.Fatalf("Expected error: %v, got: %v", tt.wantErrMessage, err) } @@ -212,12 +247,14 @@ func TestToCreateOrUpdateProjectTelemetryLinkPayload(t *testing.T) { tests := []struct { description string model *Model + configModel *Model expected *telemetrylink.CreateOrUpdateProjectTelemetryLinkPayload wantErrMessage string }{ { description: "min values", model: fixtureModel(), + configModel: fixtureModel(), expected: &telemetrylink.CreateOrUpdateProjectTelemetryLinkPayload{ DisplayName: "name", AccessToken: "", @@ -225,13 +262,14 @@ func TestToCreateOrUpdateProjectTelemetryLinkPayload(t *testing.T) { }, }, { - description: "max values", + description: "max values, legacy access_token", model: fixtureModel(func(model *Model) { model.Description = types.StringValue("description") model.DisplayName = types.StringValue("display-name") model.AccessToken = types.StringValue("access-token") model.TelemetryRouterID = types.StringValue("tlmr_id") }), + configModel: fixtureModel(), expected: &telemetrylink.CreateOrUpdateProjectTelemetryLinkPayload{ Description: new("description"), DisplayName: "display-name", @@ -239,14 +277,318 @@ func TestToCreateOrUpdateProjectTelemetryLinkPayload(t *testing.T) { TelemetryRouterId: "tlmr_id", }, }, + { + description: "write-only access_token_wo", + model: fixtureModel(func(model *Model) { + model.AccessTokenWoVersion = types.Int64Value(1) + }), + configModel: fixtureModel(func(model *Model) { + model.AccessTokenWo = types.StringValue("wo-access-token") + }), + expected: &telemetrylink.CreateOrUpdateProjectTelemetryLinkPayload{ + DisplayName: "name", + AccessToken: "wo-access-token", + TelemetryRouterId: "tlmrid", + }, + }, + { + description: "nil model", + wantErrMessage: "missing plan model", + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got, err := toCreateOrUpdateProjectTelemetryLinkPayload(tt.model, tt.configModel) + if tt.wantErrMessage != "" && (err == nil || err.Error() != tt.wantErrMessage) { + t.Fatalf("Expected error: %v, got: %v", tt.wantErrMessage, err) + } + if tt.wantErrMessage == "" && err != nil { + t.Fatalf("Unexpected error: %v", err) + } + diff := cmp.Diff(got, tt.expected) + if diff != "" { + t.Fatalf("Payload does not match: %s", diff) + } + }) + } +} + +func TestToPartialUpdateOrganizationTelemetryLinkPayload(t *testing.T) { + tests := []struct { + description string + model *Model + stateModel *Model + configModel *Model + expected *telemetrylink.PartialUpdateOrganizationTelemetryLinkPayload + wantErrMessage string + }{ + { + description: "legacy access_token is always resent", + model: fixtureModel(func(model *Model) { + model.AccessToken = types.StringValue("access-token") + }), + stateModel: fixtureModel(), + configModel: fixtureModel(), + expected: &telemetrylink.PartialUpdateOrganizationTelemetryLinkPayload{ + DisplayName: new("name"), + Description: new(""), + TelemetryRouterId: new("tlmrid"), + AccessToken: new("access-token"), + }, + }, + { + description: "write-only access_token_wo, version unchanged - token untouched", + model: fixtureModel(func(model *Model) { + model.AccessTokenWoVersion = types.Int64Value(1) + }), + stateModel: fixtureModel(func(model *Model) { + model.AccessTokenWoVersion = types.Int64Value(1) + }), + configModel: fixtureModel(func(model *Model) { + model.AccessTokenWo = types.StringValue("wo-access-token") + }), + expected: &telemetrylink.PartialUpdateOrganizationTelemetryLinkPayload{ + DisplayName: new("name"), + Description: new(""), + TelemetryRouterId: new("tlmrid"), + AccessToken: nil, + }, + }, + { + description: "write-only access_token_wo, version bumped - token rotated", + model: fixtureModel(func(model *Model) { + model.AccessTokenWoVersion = types.Int64Value(2) + }), + stateModel: fixtureModel(func(model *Model) { + model.AccessTokenWoVersion = types.Int64Value(1) + }), + configModel: fixtureModel(func(model *Model) { + model.AccessTokenWo = types.StringValue("new-wo-access-token") + }), + expected: &telemetrylink.PartialUpdateOrganizationTelemetryLinkPayload{ + DisplayName: new("name"), + Description: new(""), + TelemetryRouterId: new("tlmrid"), + AccessToken: new("new-wo-access-token"), + }, + }, + { + // Regression test: removing `description` from the config plans it as null. The + // PartialUpdate* API uses merge-patch semantics, where an omitted field means "leave + // untouched" - so the payload must carry an explicit empty string here, or the API + // would silently keep the old description and Terraform would fail with "Provider + // produced inconsistent result after apply". + description: "description removed from config - must be cleared, not left untouched", + model: fixtureModel(), + stateModel: fixtureModel(func(model *Model) { + model.Description = types.StringValue("old description") + }), + configModel: fixtureModel(), + expected: &telemetrylink.PartialUpdateOrganizationTelemetryLinkPayload{ + DisplayName: new("name"), + Description: new(""), + TelemetryRouterId: new("tlmrid"), + AccessToken: nil, + }, + }, + { + description: "nil model", + wantErrMessage: "missing plan model", + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got, err := toPartialUpdateOrganizationTelemetryLinkPayload(tt.model, tt.stateModel, tt.configModel) + if tt.wantErrMessage != "" && (err == nil || err.Error() != tt.wantErrMessage) { + t.Fatalf("Expected error: %v, got: %v", tt.wantErrMessage, err) + } + if tt.wantErrMessage == "" && err != nil { + t.Fatalf("Unexpected error: %v", err) + } + diff := cmp.Diff(got, tt.expected) + if diff != "" { + t.Fatalf("Payload does not match: %s", diff) + } + }) + } +} + +func TestToPartialUpdateFolderTelemetryLinkPayload(t *testing.T) { + tests := []struct { + description string + model *Model + stateModel *Model + configModel *Model + expected *telemetrylink.PartialUpdateFolderTelemetryLinkPayload + wantErrMessage string + }{ + { + description: "legacy access_token is always resent", + model: fixtureModel(func(model *Model) { + model.AccessToken = types.StringValue("access-token") + }), + stateModel: fixtureModel(), + configModel: fixtureModel(), + expected: &telemetrylink.PartialUpdateFolderTelemetryLinkPayload{ + DisplayName: new("name"), + Description: new(""), + TelemetryRouterId: new("tlmrid"), + AccessToken: new("access-token"), + }, + }, + { + description: "write-only access_token_wo, version unchanged - token untouched", + model: fixtureModel(func(model *Model) { + model.AccessTokenWoVersion = types.Int64Value(1) + }), + stateModel: fixtureModel(func(model *Model) { + model.AccessTokenWoVersion = types.Int64Value(1) + }), + configModel: fixtureModel(func(model *Model) { + model.AccessTokenWo = types.StringValue("wo-access-token") + }), + expected: &telemetrylink.PartialUpdateFolderTelemetryLinkPayload{ + DisplayName: new("name"), + Description: new(""), + TelemetryRouterId: new("tlmrid"), + AccessToken: nil, + }, + }, + { + description: "write-only access_token_wo, version bumped - token rotated", + model: fixtureModel(func(model *Model) { + model.AccessTokenWoVersion = types.Int64Value(2) + }), + stateModel: fixtureModel(func(model *Model) { + model.AccessTokenWoVersion = types.Int64Value(1) + }), + configModel: fixtureModel(func(model *Model) { + model.AccessTokenWo = types.StringValue("new-wo-access-token") + }), + expected: &telemetrylink.PartialUpdateFolderTelemetryLinkPayload{ + DisplayName: new("name"), + Description: new(""), + TelemetryRouterId: new("tlmrid"), + AccessToken: new("new-wo-access-token"), + }, + }, + { + description: "description removed from config - must be cleared, not left untouched", + model: fixtureModel(), + stateModel: fixtureModel(func(model *Model) { + model.Description = types.StringValue("old description") + }), + configModel: fixtureModel(), + expected: &telemetrylink.PartialUpdateFolderTelemetryLinkPayload{ + DisplayName: new("name"), + Description: new(""), + TelemetryRouterId: new("tlmrid"), + AccessToken: nil, + }, + }, + { + description: "nil model", + wantErrMessage: "missing plan model", + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got, err := toPartialUpdateFolderTelemetryLinkPayload(tt.model, tt.stateModel, tt.configModel) + if tt.wantErrMessage != "" && (err == nil || err.Error() != tt.wantErrMessage) { + t.Fatalf("Expected error: %v, got: %v", tt.wantErrMessage, err) + } + if tt.wantErrMessage == "" && err != nil { + t.Fatalf("Unexpected error: %v", err) + } + diff := cmp.Diff(got, tt.expected) + if diff != "" { + t.Fatalf("Payload does not match: %s", diff) + } + }) + } +} + +func TestToPartialUpdateProjectTelemetryLinkPayload(t *testing.T) { + tests := []struct { + description string + model *Model + stateModel *Model + configModel *Model + expected *telemetrylink.PartialUpdateProjectTelemetryLinkPayload + wantErrMessage string + }{ + { + description: "legacy access_token is always resent", + model: fixtureModel(func(model *Model) { + model.AccessToken = types.StringValue("access-token") + }), + stateModel: fixtureModel(), + configModel: fixtureModel(), + expected: &telemetrylink.PartialUpdateProjectTelemetryLinkPayload{ + DisplayName: new("name"), + Description: new(""), + TelemetryRouterId: new("tlmrid"), + AccessToken: new("access-token"), + }, + }, + { + description: "write-only access_token_wo, version unchanged - token untouched", + model: fixtureModel(func(model *Model) { + model.AccessTokenWoVersion = types.Int64Value(1) + }), + stateModel: fixtureModel(func(model *Model) { + model.AccessTokenWoVersion = types.Int64Value(1) + }), + configModel: fixtureModel(func(model *Model) { + model.AccessTokenWo = types.StringValue("wo-access-token") + }), + expected: &telemetrylink.PartialUpdateProjectTelemetryLinkPayload{ + DisplayName: new("name"), + Description: new(""), + TelemetryRouterId: new("tlmrid"), + AccessToken: nil, + }, + }, + { + description: "write-only access_token_wo, version bumped - token rotated", + model: fixtureModel(func(model *Model) { + model.AccessTokenWoVersion = types.Int64Value(2) + }), + stateModel: fixtureModel(func(model *Model) { + model.AccessTokenWoVersion = types.Int64Value(1) + }), + configModel: fixtureModel(func(model *Model) { + model.AccessTokenWo = types.StringValue("new-wo-access-token") + }), + expected: &telemetrylink.PartialUpdateProjectTelemetryLinkPayload{ + DisplayName: new("name"), + Description: new(""), + TelemetryRouterId: new("tlmrid"), + AccessToken: new("new-wo-access-token"), + }, + }, + { + description: "description removed from config - must be cleared, not left untouched", + model: fixtureModel(), + stateModel: fixtureModel(func(model *Model) { + model.Description = types.StringValue("old description") + }), + configModel: fixtureModel(), + expected: &telemetrylink.PartialUpdateProjectTelemetryLinkPayload{ + DisplayName: new("name"), + Description: new(""), + TelemetryRouterId: new("tlmrid"), + AccessToken: nil, + }, + }, { description: "nil model", - wantErrMessage: "missing model", + wantErrMessage: "missing plan model", }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - got, err := toCreateOrUpdateProjectTelemetryLinkPayload(t.Context(), diag.Diagnostics{}, tt.model) + got, err := toPartialUpdateProjectTelemetryLinkPayload(tt.model, tt.stateModel, tt.configModel) if tt.wantErrMessage != "" && (err == nil || err.Error() != tt.wantErrMessage) { t.Fatalf("Expected error: %v, got: %v", tt.wantErrMessage, err) } diff --git a/stackit/internal/services/telemetrylink/telemetrylink_acc_test.go b/stackit/internal/services/telemetrylink/telemetrylink_acc_test.go index cd84d49b1..d155f098a 100644 --- a/stackit/internal/services/telemetrylink/telemetrylink_acc_test.go +++ b/stackit/internal/services/telemetrylink/telemetrylink_acc_test.go @@ -46,12 +46,13 @@ func testConfigVarsMinUpdated() config.Variables { } var testConfigVarsMax = config.Variables{ - "project_id": config.StringVariable(testutil.ProjectId), - "resource_type": config.StringVariable("project"), - "resource_id": config.StringVariable(testutil.ProjectId), - "region": config.StringVariable(testutil.Region), - "display_name": config.StringVariable("tf-acc-test-link-max"), - "description": config.StringVariable("tf-acc-test-link-description"), + "project_id": config.StringVariable(testutil.ProjectId), + "resource_type": config.StringVariable("project"), + "resource_id": config.StringVariable(testutil.ProjectId), + "region": config.StringVariable(testutil.Region), + "display_name": config.StringVariable("tf-acc-test-link-max"), + "description": config.StringVariable("tf-acc-test-link-description"), + "access_token_wo_version": config.IntegerVariable(1), } func testConfigVarsMaxUpdated() config.Variables { @@ -59,6 +60,17 @@ func testConfigVarsMaxUpdated() config.Variables { maps.Copy(newVars, testConfigVarsMin) newVars["display_name"] = config.StringVariable("tf-acc-test-link-updated") newVars["description"] = config.StringVariable("Terraform Acceptance Test TelemetryLink Updated") + newVars["access_token_wo_version"] = config.IntegerVariable(1) + return newVars +} + +// testConfigVarsMaxRotated exercises the write-only access_token_wo rotation workflow: bumping +// access_token_wo_version signals the provider to re-send the write-only token value to the API, +// instead of leaving the previously stored token untouched. +func testConfigVarsMaxRotated() config.Variables { + newVars := make(config.Variables, len(testConfigVarsMin)) + maps.Copy(newVars, testConfigVarsMaxUpdated()) + newVars["access_token_wo_version"] = config.IntegerVariable(2) return newVars } @@ -177,7 +189,9 @@ func TestAccTelemetryLinkMax(t *testing.T) { resource.TestCheckResourceAttr("stackit_telemetrylink.link", "region", testutil.ConvertConfigVariable(testConfigVarsMax["region"])), resource.TestCheckResourceAttr("stackit_telemetrylink.link", "display_name", testutil.ConvertConfigVariable(testConfigVarsMax["display_name"])), resource.TestCheckResourceAttr("stackit_telemetrylink.link", "description", testutil.ConvertConfigVariable(testConfigVarsMax["description"])), - resource.TestCheckResourceAttrSet("stackit_telemetrylink.link", "access_token"), + resource.TestCheckResourceAttr("stackit_telemetrylink.link", "access_token_wo_version", testutil.ConvertConfigVariable(testConfigVarsMax["access_token_wo_version"])), + // access_token_wo is write-only and must never be persisted to state + resource.TestCheckNoResourceAttr("stackit_telemetrylink.link", "access_token_wo"), resource.TestCheckResourceAttrSet("stackit_telemetrylink.link", "telemetry_router_id"), resource.TestCheckResourceAttrSet("stackit_telemetrylink.link", "id"), resource.TestCheckResourceAttrSet("stackit_telemetrylink.link", "create_time"), @@ -244,9 +258,10 @@ func TestAccTelemetryLinkMax(t *testing.T) { }, ImportState: true, ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"access_token"}, + ImportStateVerifyIgnore: []string{"access_token_wo", "access_token_wo_version"}, }, - // Update + // Update - change display name and description; access_token_wo_version is unchanged, + // so the provider must NOT re-send the write-only token to the API. { ConfigVariables: testConfigVarsMaxUpdated(), Config: testutil.NewConfigBuilder().EnableBetaResources(true).BuildProviderConfig() + resourceMax, @@ -256,7 +271,26 @@ func TestAccTelemetryLinkMax(t *testing.T) { resource.TestCheckResourceAttr("stackit_telemetrylink.link", "region", testutil.ConvertConfigVariable(testConfigVarsMaxUpdated()["region"])), resource.TestCheckResourceAttr("stackit_telemetrylink.link", "display_name", testutil.ConvertConfigVariable(testConfigVarsMaxUpdated()["display_name"])), resource.TestCheckResourceAttr("stackit_telemetrylink.link", "description", testutil.ConvertConfigVariable(testConfigVarsMaxUpdated()["description"])), - resource.TestCheckResourceAttrSet("stackit_telemetrylink.link", "access_token"), + resource.TestCheckResourceAttr("stackit_telemetrylink.link", "access_token_wo_version", testutil.ConvertConfigVariable(testConfigVarsMaxUpdated()["access_token_wo_version"])), + // access_token_wo is write-only and must never be persisted to state + resource.TestCheckNoResourceAttr("stackit_telemetrylink.link", "access_token_wo"), + resource.TestCheckResourceAttrSet("stackit_telemetrylink.link", "telemetry_router_id"), + resource.TestCheckResourceAttrSet("stackit_telemetrylink.link", "id"), + resource.TestCheckResourceAttrSet("stackit_telemetrylink.link", "create_time"), + resource.TestCheckResourceAttrSet("stackit_telemetrylink.link", "status"), + ), + }, + // Token rotation - increment access_token_wo_version 1 -> 2. The write-only + // access_token_wo value is re-sent to the API; all other fields must be unchanged. + { + ConfigVariables: testConfigVarsMaxRotated(), + Config: testutil.NewConfigBuilder().EnableBetaResources(true).BuildProviderConfig() + resourceMax, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_telemetrylink.link", "access_token_wo_version", testutil.ConvertConfigVariable(testConfigVarsMaxRotated()["access_token_wo_version"])), + resource.TestCheckResourceAttr("stackit_telemetrylink.link", "display_name", testutil.ConvertConfigVariable(testConfigVarsMaxRotated()["display_name"])), + resource.TestCheckResourceAttr("stackit_telemetrylink.link", "description", testutil.ConvertConfigVariable(testConfigVarsMaxRotated()["description"])), + // access_token_wo is write-only and must never be persisted to state + resource.TestCheckNoResourceAttr("stackit_telemetrylink.link", "access_token_wo"), resource.TestCheckResourceAttrSet("stackit_telemetrylink.link", "telemetry_router_id"), resource.TestCheckResourceAttrSet("stackit_telemetrylink.link", "id"), resource.TestCheckResourceAttrSet("stackit_telemetrylink.link", "create_time"), diff --git a/stackit/internal/services/telemetrylink/testdata/resource-max.tf b/stackit/internal/services/telemetrylink/testdata/resource-max.tf index f9215520b..a7a0ae9c6 100644 --- a/stackit/internal/services/telemetrylink/testdata/resource-max.tf +++ b/stackit/internal/services/telemetrylink/testdata/resource-max.tf @@ -5,6 +5,7 @@ variable "resource_id" {} variable "region" {} variable "display_name" {} variable "description" {} +variable "access_token_wo_version" {} resource "stackit_telemetryrouter_instance" "router" { project_id = var.project_id @@ -20,11 +21,13 @@ resource "stackit_telemetryrouter_access_token" "accessToken" { } resource "stackit_telemetrylink" "link" { - resource_type = var.resource_type - resource_id = var.resource_id - region = var.region - display_name = var.display_name - description = var.description - access_token = stackit_telemetryrouter_access_token.accessToken.access_token - telemetry_router_id = stackit_telemetryrouter_instance.router.instance_id + resource_type = var.resource_type + resource_id = var.resource_id + region = var.region + display_name = var.display_name + description = var.description + # in the MIN test we use the legacy field, in the MAX test the write-only field + access_token_wo = stackit_telemetryrouter_access_token.accessToken.access_token + access_token_wo_version = var.access_token_wo_version + telemetry_router_id = stackit_telemetryrouter_instance.router.instance_id } diff --git a/stackit/internal/services/telemetrylink/testdata/resource-min.tf b/stackit/internal/services/telemetrylink/testdata/resource-min.tf index 2e7c0355d..09b3b52df 100644 --- a/stackit/internal/services/telemetrylink/testdata/resource-min.tf +++ b/stackit/internal/services/telemetrylink/testdata/resource-min.tf @@ -19,10 +19,11 @@ resource "stackit_telemetryrouter_access_token" "accessToken" { } resource "stackit_telemetrylink" "link" { - resource_type = var.resource_type - resource_id = var.resource_id - region = var.region - display_name = var.display_name + resource_type = var.resource_type + resource_id = var.resource_id + region = var.region + display_name = var.display_name + # in the MIN test we use the legacy field, in the MAX test the write-only field access_token = stackit_telemetryrouter_access_token.accessToken.access_token telemetry_router_id = stackit_telemetryrouter_instance.router.instance_id } diff --git a/stackit/internal/utils/utils.go b/stackit/internal/utils/utils.go index 2bfd323a8..90913efd1 100644 --- a/stackit/internal/utils/utils.go +++ b/stackit/internal/utils/utils.go @@ -113,6 +113,13 @@ func IsUndefined(val value) bool { return val.IsUnknown() || val.IsNull() } +// StringPointerOrEmpty returns a pointer to s's string value, defaulting to a pointer to "" for +// null/unknown - unlike ValueStringPointer, it never returns nil. +func StringPointerOrEmpty(s types.String) *string { + v := s.ValueString() + return &v +} + // LogError logs errors. In descriptions different messages for http status codes can be passed. When no one matches the defaultDescription will be used func LogError(ctx context.Context, inputDiags *diag.Diagnostics, err error, summary, defaultDescription string, descriptions map[int]string) { if err == nil { diff --git a/stackit/internal/utils/utils_test.go b/stackit/internal/utils/utils_test.go index d70a1eaea..1ac540ce3 100644 --- a/stackit/internal/utils/utils_test.go +++ b/stackit/internal/utils/utils_test.go @@ -307,6 +307,41 @@ func TestIsUndefined(t *testing.T) { } } +func TestStringPointerOrEmpty(t *testing.T) { + tests := []struct { + name string + val types.String + want string + }{ + { + name: "null value", + val: types.StringNull(), + want: "", + }, + { + name: "unknown value", + val: types.StringUnknown(), + want: "", + }, + { + name: "string value", + val: types.StringValue("foo"), + want: "foo", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := StringPointerOrEmpty(tt.val) + if got == nil { + t.Fatal("StringPointerOrEmpty() = nil, want non-nil pointer") + } + if *got != tt.want { + t.Errorf("StringPointerOrEmpty() = %v, want %v", *got, tt.want) + } + }) + } +} + func TestBuildInternalTerraformId(t *testing.T) { type args struct { idParts []string