Repository: coder/terraform-provider-coder
Pull request #404: feat: add expiration_policy parameter to prebuild resource
Author: login: ssncferreira
Created: 2025-05-22T16:24:23Z | Merged: 2025-05-23T18:45:31Z

## Summary

This PR adds Terraform support for configuring a `expiration_policy` field in a template preset. This allows users to specify a TTL (`ttl`) for prebuild workspaces, after which they are considered expired and eligible for deletion and replacement.

## Changes

* Updated the Terraform template preset resource to include the new `expiration_policy` block.
* Added validation and tests to ensure correct TTL handling.

## Example Usage

```
prebuilds = {
	  instances = 2
	  expiration_policy {
		  ttl = 86400
	  }
  }
```

Related:
* Required for https://github.com/coder/coder/pull/17996
* Related to https://github.com/coder/coder/issues/17916




Diff:
diff --git a/docs/data-sources/workspace_preset.md b/docs/data-sources/workspace_preset.md
index 14e235f9..cd4908c2 100644
--- a/docs/data-sources/workspace_preset.md
+++ b/docs/data-sources/workspace_preset.md
@@ -51,3 +51,14 @@ data "coder_workspace_preset" "example" {
 Required:
 
 - `instances` (Number) The number of workspaces to keep in reserve for this preset.
+
+Optional:
+
+- `expiration_policy` (Block Set, Max: 1) Configuration block that defines TTL (time-to-live) behavior for prebuilds. Use this to automatically invalidate and delete prebuilds after a certain period, ensuring they stay up-to-date. (see [below for nested schema](#nestedblock--prebuilds--expiration_policy))
+
+<a id="nestedblock--prebuilds--expiration_policy"></a>
+### Nested Schema for `prebuilds.expiration_policy`
+
+Required:
+
+- `ttl` (Number) Time in seconds after which an unclaimed prebuild is considered expired and eligible for cleanup.
diff --git a/integration/integration_test.go b/integration/integration_test.go
index a5019635..36612904 100644
--- a/integration/integration_test.go
+++ b/integration/integration_test.go
@@ -90,11 +90,12 @@ func TestIntegration(t *testing.T) {
 				// TODO (sasswart): the cli doesn't support presets yet.
 				// once it does, the value for workspace_parameter.value
 				// will be the preset value.
-				"workspace_parameter.value":            `param value`,
-				"workspace_parameter.icon":             `param icon`,
-				"workspace_preset.name":                `preset`,
-				"workspace_preset.parameters.param":    `preset param value`,
-				"workspace_preset.prebuilds.instances": `1`,
+				"workspace_parameter.value":                        `param value`,
+				"workspace_parameter.icon":                         `param icon`,
+				"workspace_preset.name":                            `preset`,
+				"workspace_preset.parameters.param":                `preset param value`,
+				"workspace_preset.prebuilds.instances":             `1`,
+				"workspace_preset.prebuilds.expiration_policy.ttl": `86400`,
 			},
 		},
 		{
diff --git a/integration/test-data-source/main.tf b/integration/test-data-source/main.tf
index f18fa347..50274fff 100644
--- a/integration/test-data-source/main.tf
+++ b/integration/test-data-source/main.tf
@@ -27,6 +27,9 @@ data "coder_workspace_preset" "preset" {
 
   prebuilds {
     instances = 1
+    expiration_policy {
+      ttl = 86400
+    }
   }
 }
 
@@ -52,6 +55,7 @@ locals {
     "workspace_preset.name" : data.coder_workspace_preset.preset.name,
     "workspace_preset.parameters.param" : data.coder_workspace_preset.preset.parameters.param,
     "workspace_preset.prebuilds.instances" : tostring(one(data.coder_workspace_preset.preset.prebuilds).instances),
+    "workspace_preset.prebuilds.expiration_policy.ttl" : tostring(one(one(data.coder_workspace_preset.preset.prebuilds).expiration_policy).ttl),
   }
 }
 
diff --git a/provider/workspace_preset.go b/provider/workspace_preset.go
index 78466c10..e9c697c7 100644
--- a/provider/workspace_preset.go
+++ b/provider/workspace_preset.go
@@ -22,6 +22,16 @@ type WorkspacePreset struct {
 
 type WorkspacePrebuild struct {
 	Instances int `mapstructure:"instances"`
+	// There should always be only one expiration_policy block, but Terraform's type system
+	// still parses them as a slice, so we need to handle it as such. We could use
+	// an anonymous type and rd.Get to avoid a slice here, but that would not be possible
+	// for utilities that parse our terraform output using this type. To remain compatible
+	// with those cases, we use a slice here.
+	ExpirationPolicy []ExpirationPolicy `mapstructure:"expiration_policy"`
+}
+
+type ExpirationPolicy struct {
+	TTL int `mapstructure:"ttl"`
 }
 
 func workspacePresetDataSource() *schema.Resource {
@@ -81,6 +91,24 @@ func workspacePresetDataSource() *schema.Resource {
 							ForceNew:     true,
 							ValidateFunc: validation.IntAtLeast(0),
 						},
+						"expiration_policy": {
+							Type:        schema.TypeSet,
+							Description: "Configuration block that defines TTL (time-to-live) behavior for prebuilds. Use this to automatically invalidate and delete prebuilds after a certain period, ensuring they stay up-to-date.",
+							Optional:    true,
+							MaxItems:    1,
+							Elem: &schema.Resource{
+								Schema: map[string]*schema.Schema{
+									"ttl": {
+										Type:        schema.TypeInt,
+										Description: "Time in seconds after which an unclaimed prebuild is considered expired and eligible for cleanup.",
+										Required:    true,
+										ForceNew:    true,
+										// Ensure TTL is between 0 and 31536000 seconds (1 year) to prevent stale prebuilds
+										ValidateFunc: validation.IntBetween(0, 31536000),
+									},
+								},
+							},
+						},
 					},
 				},
 			},
diff --git a/provider/workspace_preset_test.go b/provider/workspace_preset_test.go
index aa1ca0ce..d9f7da46 100644
--- a/provider/workspace_preset_test.go
+++ b/provider/workspace_preset_test.go
@@ -144,6 +144,83 @@ func TestWorkspacePreset(t *testing.T) {
 				return nil
 			},
 		},
+		{
+			Name: "Prebuilds is set with a expiration_policy field without its required fields",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				parameters = {
+					"region" = "us-east1-a"
+				}
+				prebuilds {
+					instances = 1
+					expiration_policy {}
+				}
+			}`,
+			ExpectError: regexp.MustCompile("The argument \"ttl\" is required, but no definition was found."),
+		},
+		{
+			Name: "Prebuilds is set with a expiration_policy field with its required fields",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				parameters = {
+					"region" = "us-east1-a"
+				}
+				prebuilds {
+					instances = 1
+					expiration_policy {
+						ttl = 86400
+					}
+				}
+			}`,
+			ExpectError: nil,
+			Check: func(state *terraform.State) error {
+				require.Len(t, state.Modules, 1)
+				require.Len(t, state.Modules[0].Resources, 1)
+				resource := state.Modules[0].Resources["data.coder_workspace_preset.preset_1"]
+				require.NotNil(t, resource)
+				attrs := resource.Primary.Attributes
+				require.Equal(t, attrs["name"], "preset_1")
+				require.Equal(t, attrs["prebuilds.0.expiration_policy.0.ttl"], "86400")
+				return nil
+			},
+		},
+		{
+			Name: "Prebuilds block with expiration_policy.ttl set to 2 years (exceeds 1 year limit)",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				parameters = {
+					"region" = "us-east1-a"
+				}
+				prebuilds {
+					instances = 1
+					expiration_policy {
+						ttl = 63072000
+					}
+				}
+			}`,
+			ExpectError: regexp.MustCompile(`expected prebuilds.0.expiration_policy.0.ttl to be in the range \(0 - 31536000\), got 63072000`),
+		},
+		{
+			Name: "Prebuilds is set with a expiration_policy field with its required fields and an unexpected argument",
+			Config: `
+			data "coder_workspace_preset" "preset_1" {
+				name = "preset_1"
+				parameters = {
+					"region" = "us-east1-a"
+				}
+				prebuilds {
+					instances = 1
+					expiration_policy {
+						ttl = 86400
+						invalid_argument = "test"
+					}
+				}
+			}`,
+			ExpectError: regexp.MustCompile("An argument named \"invalid_argument\" is not expected here."),
+		},
 	}
 
 	for _, testcase := range testcases {


Reviews:
user: login: mtojek
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-23T11:01:38Z
user: login: ssncferreira
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T11:29:08Z
user: login: mtojek
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-23T11:31:36Z
user: login: ssncferreira
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T11:32:39Z
user: login: mafredri
body: Other than the naming/use of block, this seems good to me 👍🏻 
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-23T11:39:13Z
user: login: ssncferreira
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T11:46:04Z
user: login: ssncferreira
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T17:00:52Z
user: login: ssncferreira
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T17:04:46Z
user: login: mafredri
body: Looks good and docs and test coverage seems on point, nice work! 👍🏻 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-23T18:17:45Z

Review comments:
diff_hunk: @@ -81,6 +86,23 @@ func workspacePresetDataSource() *schema.Resource {
 							ForceNew:     true,
 							ValidateFunc: validation.IntAtLeast(0),
 						},
+						"cache_invalidation": {
+							Type:        schema.TypeSet,
+							Description: "Configuration block that defines TTL (time-to-live) behavior for prebuilds. Use this to automatically invalidate and delete prebuilds after a certain period, ensuring they stay up-to-date.",
+							Optional:    true,
+							MaxItems:    1,
+							Elem: &schema.Resource{
+								Schema: map[string]*schema.Schema{
+									"invalidate_after_secs": {
+										Type:         schema.TypeInt,
+										Description:  "Time in seconds after which an unclaimed prebuild is considered expired and eligible for cleanup.",
+										Required:     true,
+										ForceNew:     true,
+										ValidateFunc: validation.IntAtLeast(0),
path: provider/workspace_preset.go
user: login: mtojek
body: nit: is there any max value above which the platform misbehaves?
created_at: 2025-05-23T10:58:05Z
author_association: MEMBER
diff_hunk: @@ -21,7 +21,12 @@ type WorkspacePreset struct {
 }
 
 type WorkspacePrebuild struct {
-	Instances int `mapstructure:"instances"`
+	Instances         int                 `mapstructure:"instances"`
+	CacheInvalidation []CacheInvalidation `mapstructure:"cache_invalidation"`
path: provider/workspace_preset.go
user: login: mtojek
body: nit: it might be convenient to leave a note like above (why there is an array)
created_at: 2025-05-23T10:59:08Z
author_association: MEMBER
diff_hunk: @@ -51,3 +51,14 @@ data "coder_workspace_preset" "example" {
 Required:
 
 - `instances` (Number) The number of workspaces to keep in reserve for this preset.
+
+Optional:
+
+- `cache_invalidation` (Block Set, Max: 1) Configuration block that defines TTL (time-to-live) behavior for prebuilds. Use this to automatically invalidate and delete prebuilds after a certain period, ensuring they stay up-to-date. (see [below for nested schema](#nestedblock--prebuilds--cache_invalidation))
+
+<a id="nestedblock--prebuilds--cache_invalidation"></a>
+### Nested Schema for `prebuilds.cache_invalidation`
+
+Required:
+
+- `invalidate_after_secs` (Number) Time in seconds after which an unclaimed prebuild is considered expired and eligible for cleanup.
path: docs/data-sources/workspace_preset.md
user: login: mafredri
body: Quick thoughts:

- `ttl` instead of long name? (project convention also suggests not to denote the unit `_secs`)
- Could `ttl` go directly on the prebuild, removing the block?
created_at: 2025-05-23T11:27:58Z
author_association: MEMBER
diff_hunk: @@ -81,6 +86,23 @@ func workspacePresetDataSource() *schema.Resource {
 							ForceNew:     true,
 							ValidateFunc: validation.IntAtLeast(0),
 						},
+						"cache_invalidation": {
+							Type:        schema.TypeSet,
+							Description: "Configuration block that defines TTL (time-to-live) behavior for prebuilds. Use this to automatically invalidate and delete prebuilds after a certain period, ensuring they stay up-to-date.",
+							Optional:    true,
+							MaxItems:    1,
+							Elem: &schema.Resource{
+								Schema: map[string]*schema.Schema{
+									"invalidate_after_secs": {
+										Type:         schema.TypeInt,
+										Description:  "Time in seconds after which an unclaimed prebuild is considered expired and eligible for cleanup.",
+										Required:     true,
+										ForceNew:     true,
+										ValidateFunc: validation.IntAtLeast(0),
path: provider/workspace_preset.go
user: login: ssncferreira
body: That’s a good observation. I don’t think there’s a specific max value where the platform misbehaves, it just compares the current time with the prebuild’s creation time. But I agree it might still be a good idea to enforce a reasonable upper limit, just to avoid prebuilds lingering indefinitely and getting stale. Maybe 7 days (604800 seconds) would be a sensible max? wdyt?
created_at: 2025-05-23T11:29:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -81,6 +86,23 @@ func workspacePresetDataSource() *schema.Resource {
 							ForceNew:     true,
 							ValidateFunc: validation.IntAtLeast(0),
 						},
+						"cache_invalidation": {
+							Type:        schema.TypeSet,
+							Description: "Configuration block that defines TTL (time-to-live) behavior for prebuilds. Use this to automatically invalidate and delete prebuilds after a certain period, ensuring they stay up-to-date.",
+							Optional:    true,
+							MaxItems:    1,
+							Elem: &schema.Resource{
+								Schema: map[string]*schema.Schema{
+									"invalidate_after_secs": {
+										Type:         schema.TypeInt,
+										Description:  "Time in seconds after which an unclaimed prebuild is considered expired and eligible for cleanup.",
+										Required:     true,
+										ForceNew:     true,
+										ValidateFunc: validation.IntAtLeast(0),
path: provider/workspace_preset.go
user: login: mtojek
body: Yes, this is what I have in mind. In case of any problems, eventually the prebuild workspace will be recycled.
created_at: 2025-05-23T11:31:36Z
author_association: MEMBER
diff_hunk: @@ -21,7 +21,12 @@ type WorkspacePreset struct {
 }
 
 type WorkspacePrebuild struct {
-	Instances int `mapstructure:"instances"`
+	Instances         int                 `mapstructure:"instances"`
+	CacheInvalidation []CacheInvalidation `mapstructure:"cache_invalidation"`
path: provider/workspace_preset.go
user: login: ssncferreira
body: Addressed in: https://github.com/coder/terraform-provider-coder/pull/404/commits/1c1ea400ac86433a15d321108715b2aea82b6227
created_at: 2025-05-23T11:32:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -81,6 +86,23 @@ func workspacePresetDataSource() *schema.Resource {
 							ForceNew:     true,
 							ValidateFunc: validation.IntAtLeast(0),
 						},
+						"cache_invalidation": {
+							Type:        schema.TypeSet,
+							Description: "Configuration block that defines TTL (time-to-live) behavior for prebuilds. Use this to automatically invalidate and delete prebuilds after a certain period, ensuring they stay up-to-date.",
+							Optional:    true,
+							MaxItems:    1,
+							Elem: &schema.Resource{
+								Schema: map[string]*schema.Schema{
+									"invalidate_after_secs": {
+										Type:         schema.TypeInt,
+										Description:  "Time in seconds after which an unclaimed prebuild is considered expired and eligible for cleanup.",
+										Required:     true,
+										ForceNew:     true,
+										ValidateFunc: validation.IntAtLeast(0),
path: provider/workspace_preset.go
user: login: ssncferreira
body: Addressed in https://github.com/coder/terraform-provider-coder/pull/404/commits/1ae84e8533d6fa108d6d394ebe819263ff268ec4
created_at: 2025-05-23T11:46:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -81,6 +86,23 @@ func workspacePresetDataSource() *schema.Resource {
 							ForceNew:     true,
 							ValidateFunc: validation.IntAtLeast(0),
 						},
+						"cache_invalidation": {
+							Type:        schema.TypeSet,
+							Description: "Configuration block that defines TTL (time-to-live) behavior for prebuilds. Use this to automatically invalidate and delete prebuilds after a certain period, ensuring they stay up-to-date.",
+							Optional:    true,
+							MaxItems:    1,
+							Elem: &schema.Resource{
+								Schema: map[string]*schema.Schema{
+									"invalidate_after_secs": {
+										Type:         schema.TypeInt,
+										Description:  "Time in seconds after which an unclaimed prebuild is considered expired and eligible for cleanup.",
+										Required:     true,
+										ForceNew:     true,
+										ValidateFunc: validation.IntAtLeast(0),
path: provider/workspace_preset.go
user: login: ssncferreira
body: After discussion on [internally](https://codercom.slack.com/archives/C08MES58YTU/p1748001024615809) regarding concerns that the previous maximum value was too low, the limit has been updated to 1 year.: https://github.com/coder/terraform-provider-coder/pull/404/commits/2a34f0d27a4687fa0b8094d03f35a7adab5d1c6a
created_at: 2025-05-23T17:00:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,3 +51,14 @@ data "coder_workspace_preset" "example" {
 Required:
 
 - `instances` (Number) The number of workspaces to keep in reserve for this preset.
+
+Optional:
+
+- `cache_invalidation` (Block Set, Max: 1) Configuration block that defines TTL (time-to-live) behavior for prebuilds. Use this to automatically invalidate and delete prebuilds after a certain period, ensuring they stay up-to-date. (see [below for nested schema](#nestedblock--prebuilds--cache_invalidation))
+
+<a id="nestedblock--prebuilds--cache_invalidation"></a>
+### Nested Schema for `prebuilds.cache_invalidation`
+
+Required:
+
+- `invalidate_after_secs` (Number) Time in seconds after which an unclaimed prebuild is considered expired and eligible for cleanup.
path: docs/data-sources/workspace_preset.md
user: login: ssncferreira
body: Addressed in: https://github.com/coder/terraform-provider-coder/pull/404/commits/2a34f0d27a4687fa0b8094d03f35a7adab5d1c6a
Related to comment: https://github.com/coder/terraform-provider-coder/pull/404#issuecomment-2905156272
created_at: 2025-05-23T17:04:46Z
author_association: CONTRIBUTOR

PR comments:
user: login: SasSwart
created_at: 2025-05-23T10:35:45Z
body: Looks good. I'd just like to revisit the schema:
```
prebuilds {
	  instances = 2
	  cache_invalidation {
		  invalidate_after_secs = 86400
	  }
  }
```

Do we need the nested `cache_invalidation` block? Is it because we expect other cache invalidation related fields to eventually be added? Could we get away with the following?
```
prebuilds {
	  instances = 2
	  invalidate_after_secs = 86400
  }
```
author_association: CONTRIBUTOR
user: login: ssncferreira
created_at: 2025-05-23T10:51:39Z
body: > Looks good. I'd just like to revisit the schema:
> 
> ```
> prebuilds {
> 	  instances = 2
> 	  cache_invalidation {
> 		  invalidate_after_secs = 86400
> 	  }
>   }
> ```
> 
> Do we need the nested `cache_invalidation` block? Is it because we expect other cache invalidation related fields to eventually be added? Could we get away with the following?
> 
> ```
> prebuilds {
> 	  instances = 2
> 	  invalidate_after_secs = 86400
>   }
> ```

I also thought about that, the nested `cache_invalidation` block was mainly to keep things flexible in case we want to add more expiration or invalidation-related settings down the road. For readability, I actually prefer this option, especially since if I understand correctly there’s another config coming for `autoscaling`. It makes sense to keep these configurations grouped per block and separated by concern. But I’m definitely open to suggestions! 🙂 

One other concern I had was about the naming of `cache_invalidation`. Since we’re not really dealing with traditional caching but more with managing prebuild lifecycle or resource expiration, the name might be a bit misleading. Maybe something like `expiration_policy` would make more sense? What do you think?
author_association: CONTRIBUTOR
user: login: ssncferreira
created_at: 2025-05-23T17:04:18Z
body: > > Looks good. I'd just like to revisit the schema:
> > ```
> > prebuilds {
> > 	  instances = 2
> > 	  cache_invalidation {
> > 		  invalidate_after_secs = 86400
> > 	  }
> >   }
> > ```
> >   
> > Do we need the nested `cache_invalidation` block? Is it because we expect other cache invalidation related fields to eventually be added? Could we get away with the following?
> > ```
> > prebuilds {
> > 	  instances = 2
> > 	  invalidate_after_secs = 86400
> >   }
> > ```
> 
> I also thought about that, the nested `cache_invalidation` block was mainly to keep things flexible in case we want to add more expiration or invalidation-related settings down the road. For readability, I actually prefer this option, especially since if I understand correctly there’s another config coming for `autoscaling`. It makes sense to keep these configurations grouped per block and separated by concern. But I’m definitely open to suggestions! 🙂
> 
> One other concern I had was about the naming of `cache_invalidation`. Since we’re not really dealing with traditional caching but more with managing prebuild lifecycle or resource expiration, the name might be a bit misleading. Maybe something like `expiration_policy` would make more sense? What do you think?

As discussed [internally](https://codercom.slack.com/archives/C04M9CNGHGF/p1748011721885699), and after getting the confirmation that we'll likely introduce more expiration-related parameters in the future, the terraform schema was updated to:
```
prebuilds = {
  instances = 2
  expiration_policy {
    ttl = 86400
  }
}
```
author_association: CONTRIBUTOR