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 all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,4}[dh]$`), "must be 1-4 digits followed by d or h"),
},

"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")
}
98 changes: 95 additions & 3 deletions internal/provider/resource_tfe_workspace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2694,6 +2694,82 @@ func TestAccTFEWorkspace_updateWithAutoDestroyAt(t *testing.T) {
})
}

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

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
Providers: testAccProviders,
CheckDestroy: testAccCheckTFEWorkspaceDestroy,
Steps: []resource.TestStep{
{
Config: testAccTFEWorkspace_basicWithAutoDestroyDuration(rInt, "1d"),
Check: resource.ComposeTestCheckFunc(
testAccCheckTFEWorkspaceExists("tfe_workspace.foobar", &tfe.Workspace{}, testAccProvider),
resource.TestCheckResourceAttr("tfe_workspace.foobar", "auto_destroy_activity_duration", "1d"),
),
},
},
})
}

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

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
Providers: testAccProviders,
CheckDestroy: testAccCheckTFEWorkspaceDestroy,
Steps: []resource.TestStep{
{
Config: testAccTFEWorkspace_basicWithAutoDestroyDuration(rInt, "1d"),
Check: resource.ComposeTestCheckFunc(
testAccCheckTFEWorkspaceExists("tfe_workspace.foobar", &tfe.Workspace{}, testAccProvider),
resource.TestCheckResourceAttr("tfe_workspace.foobar", "auto_destroy_activity_duration", "1d"),
),
},
{
Config: testAccTFEWorkspace_basicWithAutoDestroyAt(rInt),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("tfe_workspace.foobar", "auto_destroy_activity_duration", ""),
resource.TestCheckResourceAttr("tfe_workspace.foobar", "auto_destroy_at", "2100-01-01T00:00:00Z"),
),
},
{
Config: testAccTFEWorkspace_basicWithAutoDestroyDuration(rInt, "1d"),
Check: resource.TestCheckResourceAttr("tfe_workspace.foobar", "auto_destroy_activity_duration", "1d"),
},
{
Config: testAccTFEWorkspace_basic(rInt),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("tfe_workspace.foobar", "auto_destroy_at", ""),
resource.TestCheckResourceAttr("tfe_workspace.foobar", "auto_destroy_activity_duration", ""),
),
},
},
})
}

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

values := []string{"d", "1w", "1d1", "123456h"}
steps := []resource.TestStep{}
for _, value := range values {
steps = append(steps, resource.TestStep{
Config: testAccTFEWorkspace_basicWithAutoDestroyDuration(rInt, value),
ExpectError: regexp.MustCompile("must be 1-4 digits followed by d or h"),
})
}

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
Providers: testAccProviders,
CheckDestroy: testAccCheckTFEWorkspaceDestroy,
Steps: steps,
})
}

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

Expand Down Expand Up @@ -2978,14 +3054,30 @@ resource "tfe_organization" "foobar" {
}

resource "tfe_workspace" "foobar" {
name = "workspace-test"
organization = tfe_organization.foobar.id
auto_apply = true
name = "workspace-test"
organization = tfe_organization.foobar.id
auto_apply = true
file_triggers_enabled = false
auto_destroy_at = "2100-01-01T00:00:00Z"
}`, rInt)
}

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

resource "tfe_workspace" "foobar" {
name = "workspace-test"
organization = tfe_organization.foobar.id
auto_apply = true
file_triggers_enabled = false
auto_destroy_activity_duration = "%s"
}`, rInt, value)
}

func testAccTFEWorkspace_operationsTrue(organization string) string {
return fmt.Sprintf(`
resource "tfe_workspace" "foobar" {
Expand Down
Loading
Loading