Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(workspace): add auto destroy activity duration #1377

Merged
merged 8 commits into from
Jun 25, 2024
Merged
Show file tree
Hide file tree
Changes from 6 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

FEATURES:
* `r/tfe_team`: Add attribute `manage_agent_pools` to `organization_access` on `tfe_team` by @emlanctot [#1358](https://github.com/hashicorp/terraform-provider-tfe/pull/1358)
* `r/tfe_workspace`: Add an `auto_destroy_activity_duration` attribute for automatic scheduling of auto-destroy runs based off of workspace activity, by @notchairmk [#1377](https://github.com/hashicorp/terraform-provider-tfe/pull/1377)
* `d/tfe_workspace`: Add an `auto_destroy_activity_duration`, by @notchairmk [#1377](https://github.com/hashicorp/terraform-provider-tfe/pull/1377)

## v0.56.0

Expand Down
14 changes: 14 additions & 0 deletions internal/provider/data_source_workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ func dataSourceTFEWorkspace() *schema.Resource {
Computed: true,
},

"auto_destroy_activity_duration": {
Type: schema.TypeString,
Computed: true,
},

"file_triggers_enabled": {
Type: schema.TypeBool,
Computed: true,
Expand Down Expand Up @@ -251,6 +256,15 @@ func dataSourceTFEWorkspaceRead(d *schema.ResourceData, meta interface{}) error
}
d.Set("auto_destroy_at", autoDestroyAt)

var autoDestroyDuration string
if workspace.AutoDestroyActivityDuration.IsSpecified() {
autoDestroyDuration, err = workspace.AutoDestroyActivityDuration.Get()
if err != nil {
return fmt.Errorf("Error reading auto destroy activity duration: %w", err)
}
}
d.Set("auto_destroy_activity_duration", autoDestroyDuration)

// If target tfe instance predates projects, then workspace.Project will be nil
if workspace.Project != nil {
d.Set("project_id", workspace.Project.ID)
Expand Down
40 changes: 40 additions & 0 deletions internal/provider/data_source_workspace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,25 @@ func TestAccTFEWorkspaceDataSource_readAutoDestroyAt(t *testing.T) {
})
}

func TestAccTFEWorkspaceDataSource_readAutoDestroyDuration(t *testing.T) {
rInt := rand.New(rand.NewSource(time.Now().UnixNano())).Int()

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
Providers: testAccProviders,
Steps: []resource.TestStep{
{
Config: testAccTFEWorkspaceDataSourceConfig_basic(rInt),
Check: resource.TestCheckResourceAttr("data.tfe_workspace.foobar", "auto_destroy_activity_duration", ""),
},
{
Config: testAccTFEWorkspaceDataSourceConfig_basicWithAutoDestroyDuration(rInt),
Check: resource.TestCheckResourceAttr("data.tfe_workspace.foobar", "auto_destroy_activity_duration", "1d"),
},
},
})
}

func TestAccTFEWorkspaceDataSource_readProjectIDDefault(t *testing.T) {
rInt := rand.New(rand.NewSource(time.Now().UnixNano())).Int()

Expand Down Expand Up @@ -300,6 +319,27 @@ data "tfe_workspace" "foobar" {
organization = tfe_workspace.foobar.organization
}`, rInt, rInt)
}

func testAccTFEWorkspaceDataSourceConfig_basicWithAutoDestroyDuration(rInt int) string {
return fmt.Sprintf(`
resource "tfe_organization" "foobar" {
name = "tst-terraform-%d"
email = "[email protected]"
}

resource "tfe_workspace" "foobar" {
name = "workspace-test-%d"
organization = tfe_organization.foobar.id
description = "provider-testing"
auto_destroy_activity_duration = "1d"
}

data "tfe_workspace" "foobar" {
name = tfe_workspace.foobar.name
organization = tfe_workspace.foobar.organization
}`, rInt, rInt)
}

func testAccTFEWorkspaceDataSourceConfigWithTriggerPatterns(workspaceName string, organizationName string) string {
return fmt.Sprintf(`
data "tfe_workspace" "foobar" {
Expand Down
129 changes: 98 additions & 31 deletions internal/provider/resource_tfe_workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ func resourceTFEWorkspace() *schema.Resource {
return err
}

if err := customizeDiffAutoDestroyAt(c, d); err != nil {
return err
}

return nil
},

Expand Down Expand Up @@ -112,9 +116,17 @@ func resourceTFEWorkspace() *schema.Resource {

"auto_destroy_at": {
Type: schema.TypeString,
Computed: true,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately most of the raw config/state checking in this PR is due to this value being changed to computed. The main problem is insuring that unsetting auto_destroy_at (when auto_destroy_activity_duration is also unset) sends a null value to the TFC API.

Optional: true,
},

"auto_destroy_activity_duration": {
Type: schema.TypeString,
Optional: true,
ConflictsWith: []string{"auto_destroy_at"},
ValidateFunc: validation.StringMatch(regexp.MustCompile(`^\d{1,5}[dh]$`), "must be 1-5 digits followed by d or h"),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be

Suggested change
ValidateFunc: validation.StringMatch(regexp.MustCompile(`^\d{1,5}[dh]$`), "must be 1-5 digits followed by d or h"),
ValidateFunc: validation.StringMatch(regexp.MustCompile(`^\d{1,4}[dh]$`), "must be 1-4 digits followed by d or h"),

Copy link

@emailnitram emailnitram Jun 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

edge case but 0d or 0h seems to be a valid value but fails when trying to apply

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

edge case but 0d or 0h seems to be a valid value but fails when trying to apply

validation is mostly an additional nice-to-have to give the user early feedback. I'm fine with the logic here not needing to 100% match the business logic in the API, since it's an edge case

},

"execution_mode": {
Type: schema.TypeString,
Optional: true,
Expand Down Expand Up @@ -354,6 +366,10 @@ func resourceTFEWorkspaceCreate(d *schema.ResourceData, meta interface{}) error
options.AutoDestroyAt = autoDestroyAt
}

if v, ok := d.GetOk("auto_destroy_activity_duration"); ok {
options.AutoDestroyActivityDuration = jsonapi.NewNullableAttrWithValue(v.(string))
}

if v, ok := d.GetOk("execution_mode"); ok {
executionMode := tfe.String(v.(string))
options.SettingOverwrites = &tfe.WorkspaceSettingOverwritesOptions{
Expand Down Expand Up @@ -553,6 +569,15 @@ func resourceTFEWorkspaceRead(d *schema.ResourceData, meta interface{}) error {
}
d.Set("auto_destroy_at", autoDestroyAt)

if workspace.AutoDestroyActivityDuration.IsSpecified() {
v, err := workspace.AutoDestroyActivityDuration.Get()
if err != nil {
return fmt.Errorf("Error reading auto destroy activity duration: %w", err)
}

d.Set("auto_destroy_activity_duration", v)
}

var tagNames []interface{}
managedTags := d.Get("tag_names").(*schema.Set)
for _, tagName := range workspace.TagNames {
Expand Down Expand Up @@ -605,7 +630,8 @@ func resourceTFEWorkspaceUpdate(d *schema.ResourceData, meta interface{}) error
d.HasChange("operations") || d.HasChange("execution_mode") ||
d.HasChange("description") || d.HasChange("agent_pool_id") ||
d.HasChange("global_remote_state") || d.HasChange("structured_run_output_enabled") ||
d.HasChange("assessments_enabled") || d.HasChange("project_id") || d.HasChange("auto_destroy_at") {
d.HasChange("assessments_enabled") || d.HasChange("project_id") ||
hasAutoDestroyAtChange(d) || d.HasChange("auto_destroy_activity_duration") {
// Create a new options struct.
options := tfe.WorkspaceUpdateOptions{
Name: tfe.String(d.Get("name").(string)),
Expand Down Expand Up @@ -658,14 +684,23 @@ func resourceTFEWorkspaceUpdate(d *schema.ResourceData, meta interface{}) error
}
}

if d.HasChange("auto_destroy_at") {
if hasAutoDestroyAtChange(d) {
autoDestroyAt, err := expandAutoDestroyAt(d)
if err != nil {
return fmt.Errorf("Error expanding auto destroy during update: %w", err)
}
options.AutoDestroyAt = autoDestroyAt
}

if d.HasChange("auto_destroy_activity_duration") {
duration, ok := d.GetOk("auto_destroy_activity_duration")
if !ok {
options.AutoDestroyActivityDuration = jsonapi.NewNullNullableAttr[string]()
} else {
options.AutoDestroyActivityDuration = jsonapi.NewNullableAttrWithValue(duration.(string))
}
}

if d.HasChange("execution_mode") {
if v, ok := d.GetOk("execution_mode"); ok {
options.ExecutionMode = tfe.String(v.(string))
Expand Down Expand Up @@ -961,35 +996,6 @@ func validateAgentExecution(_ context.Context, d *schema.ResourceDiff) error {
return nil
}

func expandAutoDestroyAt(d *schema.ResourceData) (jsonapi.NullableAttr[time.Time], error) {
v, ok := d.GetOk("auto_destroy_at")

if !ok {
return jsonapi.NewNullNullableAttr[time.Time](), nil
}

autoDestroyAt, err := time.Parse(time.RFC3339, v.(string))
if err != nil {
return nil, err
}

return jsonapi.NewNullableAttrWithValue(autoDestroyAt), nil
}

func flattenAutoDestroyAt(a jsonapi.NullableAttr[time.Time]) (*string, error) {
if !a.IsSpecified() {
return nil, nil
}

autoDestroyTime, err := a.Get()
if err != nil {
return nil, err
}

autoDestroyAt := autoDestroyTime.Format(time.RFC3339)
return &autoDestroyAt, nil
}

func validTagName(tag string) bool {
// Tags are re-validated here because the API will accept uppercase letters and automatically
// downcase them, causing resource drift. It's better to catch this issue during the plan phase
Expand Down Expand Up @@ -1076,3 +1082,64 @@ func errWorkspaceResourceCountCheck(workspaceID string, resourceCount int) error
}
return nil
}

func customizeDiffAutoDestroyAt(_ context.Context, d *schema.ResourceDiff) error {
config := d.GetRawConfig()

// check if auto_destroy_activity_duration is set in config
if !config.GetAttr("auto_destroy_activity_duration").IsNull() {
return nil
}

// if config auto_destroy_at is unset but it exists in state, clear it out
// required because auto_destroy_at is computed and we want to set it to null
if _, ok := d.GetOk("auto_destroy_at"); ok && config.GetAttr("auto_destroy_at").IsNull() {
return d.SetNew("auto_destroy_at", nil)
}

return nil
}

func expandAutoDestroyAt(d *schema.ResourceData) (jsonapi.NullableAttr[time.Time], error) {
v := d.GetRawConfig().GetAttr("auto_destroy_at")

if v.IsNull() {
return jsonapi.NewNullNullableAttr[time.Time](), nil
}

autoDestroyAt, err := time.Parse(time.RFC3339, v.AsString())
if err != nil {
return nil, err
}

return jsonapi.NewNullableAttrWithValue(autoDestroyAt), nil
}

func flattenAutoDestroyAt(a jsonapi.NullableAttr[time.Time]) (*string, error) {
if !a.IsSpecified() {
return nil, nil
}

autoDestroyTime, err := a.Get()
if err != nil {
return nil, err
}

autoDestroyAt := autoDestroyTime.Format(time.RFC3339)
return &autoDestroyAt, nil
}

func hasAutoDestroyAtChange(d *schema.ResourceData) bool {
state := d.GetRawState()
if state.IsNull() {
return d.HasChange("auto_destroy_at")
}

config := d.GetRawConfig()
autoDestroyAt := config.GetAttr("auto_destroy_at")
if !autoDestroyAt.IsNull() {
return d.HasChange("auto_destroy_at")
}

return config.GetAttr("auto_destroy_at") != state.GetAttr("auto_destroy_at")
}
Loading
Loading