Skip to content

Commit 13f75a0

Browse files
Add Fir generation support for Heroku Private Spaces (#401)
* Include .cursor in gitignore * Add foundational feature matrix system for generation support - Implement IsFeatureSupported() helper function for Cedar/Fir generation differences - Add feature matrix tracking space capabilities across generations - Include comprehensive test coverage with 14 test cases - Cedar generation: supports all space features including shield spaces - Fir generation: supports private spaces only, shield spaces unsupported - Foundation for graceful handling of generation-specific feature differences * Add Fir generation support for Private Spaces - Add generation field to heroku_space resource with cedar/fir validation - Implement shield feature validation blocking Fir+Shield combinations - Add comprehensive CRUD validation (Create errors, Read warnings) - Enhance feature matrix with complete space feature coverage - Add extensive test suite: 24 tests including acceptance tests - Update documentation with generation examples and guidance - Maintain backward compatibility with cedar default - Foundation for incremental space feature validation Resolves core deliverables for Fir Private Space compatibility. Generated shield spaces require cedar generation. ForceNew ensures generation cannot be changed after creation. * Improve generation validation with plan-time error checking - Add CustomizeDiff function for shield+generation validation during plan phase - Users now get immediate feedback during 'terraform plan' instead of waiting for apply - Remove redundant validation from Create function since CustomizeDiff handles it - Better UX: clear error messages during planning prevent surprises during apply * Document outbound IPs limitation for Fir generation - Add note to space documentation that outbound IP management is not supported for fir generation spaces - Provides clear guidance to users about generation differences * Optimize Fir space acceptance tests to use single space pattern - Consolidate TestAccHerokuSpace_Generation from 3 spaces to 1 space - Create new TestAccHerokuSpace_Fir following efficient single-space pattern - Remove TestAccHerokuSpace_GenerationForceNew (redundant with generation change test) - Add Fir-specific validation test steps for VPN/inbound/peering failures - Reduces space creations from 6 to 2 (~70% faster test execution) - Follows established pattern from main TestAccHerokuSpace function * Update docs/resources/space.md Co-authored-by: Sandy Lai <[email protected]> Signed-off-by: Johnny Winn <[email protected]> Update docs/resources/space.md Co-authored-by: Sandy Lai <[email protected]> Signed-off-by: Johnny Winn <[email protected]> Update docs/resources/space.md Co-authored-by: Sandy Lai <[email protected]> Signed-off-by: Johnny Winn <[email protected]> Update docs/resources/space.md Co-authored-by: Sandy Lai <[email protected]> Signed-off-by: Johnny Winn <[email protected]> Update docs/resources/space.md Co-authored-by: Sandy Lai <[email protected]> Signed-off-by: Johnny Winn <[email protected]> Update docs/resources/space.md Co-authored-by: Sandy Lai <[email protected]> Signed-off-by: Johnny Winn <[email protected]> Update docs/resources/space.md Co-authored-by: Sandy Lai <[email protected]> Signed-off-by: Johnny Winn <[email protected]> Update docs/resources/space.md Co-authored-by: Sandy Lai <[email protected]> Signed-off-by: Johnny Winn <[email protected]> Update docs/resources/space.md Co-authored-by: Sandy Lai <[email protected]> Signed-off-by: Johnny Winn <[email protected]> Update docs/resources/space.md Co-authored-by: Sandy Lai <[email protected]> Signed-off-by: Johnny Winn <[email protected]> Update docs/resources/space.md Co-authored-by: Sandy Lai <[email protected]> Signed-off-by: Johnny Winn <[email protected]> Update heroku/resource_heroku_space.go Co-authored-by: Sandy Lai <[email protected]> Signed-off-by: Johnny Winn <[email protected]> Update docs/resources/space.md Co-authored-by: Sandy Lai <[email protected]> Signed-off-by: Johnny Winn <[email protected]> * Pass generation field to Heroku API for space creation - Add generation field to SpaceCreateOpts when creating spaces - Fix resourceHerokuSpaceRead to properly read generation from API response - Ensure users get the generation they specify instead of defaulting to Cedar - Add debug logging for space creation with generation - Tested: Cedar space creation confirmed working, Fir space API correctly validates generation * Remove default value for CIDR from spaces * Include the fir feature branch for acceptance test runs * Remove generation name check * Remove unused test steps --------- Signed-off-by: Johnny Winn <[email protected]> Co-authored-by: Sandy Lai <[email protected]>
1 parent 3a5a85d commit 13f75a0

File tree

8 files changed

+507
-20
lines changed

8 files changed

+507
-20
lines changed

.github/workflows/acceptance-tests.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ name: Acceptance
22
on:
33
pull_request:
44
branches:
5-
- master
5+
- master
6+
- fir-compatibility
67
paths-ignore:
78
- 'docs/**'
89
- '**.md'

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,5 @@ website/vendor
3232
# Test exclusions
3333
!command/test-fixtures/**/*.tfstate
3434
!command/test-fixtures/**/.terraform/
35+
36+
.cursor/

.tool-versions

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
golang 1.24.5
2+
terraform 1.5.7

docs/resources/space.md

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,42 @@ description: |-
1010

1111
Provides a Heroku Private Space resource for running apps in isolated, highly available, secure app execution environments.
1212

13+
Both generations of the Heroku platform offer Private Spaces:
14+
15+
* **Cedar** (default): The [Cedar generation](https://devcenter.heroku.com/articles/private-spaces#additional-features-for-cedar-private-spaces) supports all Private Space features, including Shield spaces.
16+
* ```
17+
* **Fir**: The next-generation platform supports enhanced capabilities for Cloud Native Buildpacks (CNB), but with has some [feature limitations](https://devcenter.heroku.com/articles/generations?preview=1#feature-parity) compared to Cedar Private Spaces.
18+
19+
~> **Note:** You can't change the `generation` parameter after space creation. Choose carefully based on your application requirements.
20+
1321
## Example Usage
1422
1523
A Heroku "team" was originally called an "organization", and that is still
1624
the identifier used in this resource.
1725
1826
```hcl-terraform
19-
// Create a new Heroku space
20-
resource "heroku_space" "default" {
21-
name = "test-space"
27+
// Create a new Cedar-generation space (default)
28+
resource "heroku_space" "cedar_space" {
29+
name = "test-cedar-space"
30+
organization = "my-company"
31+
region = "virginia"
32+
shield = true // Cedar supports Shield spaces
33+
}
34+
35+
// Create a new Fir-generation space
36+
resource "heroku_space" "fir_space" {
37+
name = "test-fir-space"
2238
organization = "my-company"
2339
region = "virginia"
40+
generation = "fir"
41+
// Note: Shield spaces are unavailable for the Fir generation.
2442
}
2543
2644
// Create a new Heroku app in test-space, same region
2745
resource "heroku_app" "default" {
2846
name = "test-app"
2947
region = "virginia"
30-
space = heroku_space.default.id
48+
space = heroku_space.cedar_space.id
3149
organization = {
3250
name = "my-company"
3351
}
@@ -40,12 +58,13 @@ The following arguments are supported:
4058

4159
* `name` - (Required) The name of the Private Space.
4260
* `organization` - (Required) The name of the Heroku Team which will own the Private Space.
61+
* `generation` - (Optional) The generation of the Heroku platform for the space. Valid values are `cedar` and `fir`. Defaults to `cedar` for backward compatibility. It can't be changed after space creation.
4362
* `cidr` - (Optional) The RFC-1918 CIDR the Private Space will use.
4463
It must be a /16 in 10.0.0.0/8, 172.16.0.0/12 or 192.168.0.0/16
4564
* `data_cidr` - (Optional) The RFC-1918 CIDR that the Private Space will use for the Heroku-managed peering connection
46-
thats automatically created when using Heroku Data add-ons. It must be between a /16 and a /20
47-
* `region` - (Optional) provision in a specific [Private Spaces region](https://devcenter.heroku.com/articles/regions#viewing-available-regions).
48-
* `shield` - (Optional) provision as a [Shield Private Space](https://devcenter.heroku.com/articles/private-spaces#shield-private-spaces).
65+
that's automatically created when using Heroku Data add-ons. It must be between a /16 and a /20
66+
* `region` - (Optional) The [region](https://devcenter.heroku.com/articles/regions#viewing-available-regions) to provision a space in.
67+
* `shield` - (Optional) `true` if provisioning as a [Shield Private Space](https://devcenter.heroku.com/articles/private-spaces#shield-private-spaces). Note: Shield spaces are only supported for the `cedar` generation.
4968

5069
## Attributes Reference
5170

@@ -54,10 +73,11 @@ The following attributes are exported:
5473
* `id` - The ID (UUID) of the space.
5574
* `name` - The space's name.
5675
* `organization` - The space's Heroku Team.
76+
* `generation` - The generation of the Heroku platform for the space (`cedar` or `fir`).
5777
* `region` - The space's region.
5878
* `cidr` - The space's CIDR.
5979
* `data_cidr` - The space's Data CIDR.
60-
* `outbound_ips` - The space's stable outbound [NAT IPs](https://devcenter.heroku.com/articles/platform-api-reference#space-network-address-translation).
80+
* `outbound_ips` - The space's stable outbound [NAT IPs](https://devcenter.heroku.com/articles/platform-api-reference#space-network-address-translation). Note: Outbound IP management is not supported for `fir` generation spaces.
6181

6282
## Import
6383

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package heroku
2+
3+
// Feature matrix system for graceful handling of generation differences
4+
// between Cedar and Fir generations in Terraform Provider Heroku.
5+
6+
// featureMatrix defines which features are supported for each generation and resource type.
7+
// This is the single source of truth for feature availability based on the
8+
// unsupported features data from Platform API's 3.sdk Generation endpoints.
9+
var featureMatrix = map[string]map[string]map[string]bool{
10+
"cedar": {
11+
"space": {
12+
"private": true, // All spaces are private
13+
"shield": true, // Cedar supports shield spaces
14+
"trusted_ip_ranges": true,
15+
"private_vpn": true,
16+
"outbound_rules": true,
17+
"private_space_logging": true,
18+
"outbound_ips": true, // Cedar supports outbound IPs
19+
"vpn_connection": true, // Cedar supports VPN connections
20+
"inbound_ruleset": true, // Cedar supports inbound rulesets
21+
"peering_connection": true, // Cedar supports IPv4 peering
22+
},
23+
},
24+
"fir": {
25+
"space": {
26+
"private": true, // All spaces are private
27+
"shield": false, // Fir does not support shield spaces
28+
"trusted_ip_ranges": false, // trusted_ip_ranges
29+
"private_vpn": false, // private_vpn
30+
"outbound_rules": false, // outbound_rules
31+
"private_space_logging": false, // private_space_logging
32+
"outbound_ips": false, // space_outbound_ips
33+
"vpn_connection": false, // VPN connections not supported
34+
"inbound_ruleset": false, // Inbound rulesets not supported
35+
"peering_connection": false, // IPv4 peering not supported
36+
},
37+
},
38+
}
39+
40+
// IsFeatureSupported checks if a feature is supported for a given generation and resource type.
41+
// Returns true if the feature is supported, false otherwise.
42+
//
43+
// Parameters:
44+
// - generation: "cedar" or "fir"
45+
// - resourceType: "space", "app", "build", etc.
46+
// - feature: "shield", "trusted_ip_ranges", "private_vpn", etc.
47+
//
48+
// Example:
49+
//
50+
// if IsFeatureSupported("fir", "space", "shield") {
51+
// // proceed with shield configuration
52+
// }
53+
func IsFeatureSupported(generation, resourceType, feature string) bool {
54+
if gen, exists := featureMatrix[generation]; exists {
55+
if res, exists := gen[resourceType]; exists {
56+
if supported, exists := res[feature]; exists {
57+
return supported
58+
}
59+
}
60+
}
61+
62+
// Default to false for any unknown generation/resource/feature combination
63+
return false
64+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package heroku
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestIsFeatureSupported(t *testing.T) {
8+
testCases := []struct {
9+
name string
10+
generation string
11+
resourceType string
12+
feature string
13+
expected bool
14+
}{
15+
// Cedar generation tests - all space features supported
16+
{
17+
name: "Cedar space private should be supported",
18+
generation: "cedar",
19+
resourceType: "space",
20+
feature: "private",
21+
expected: true,
22+
},
23+
{
24+
name: "Cedar space shield should be supported",
25+
generation: "cedar",
26+
resourceType: "space",
27+
feature: "shield",
28+
expected: true,
29+
},
30+
{
31+
name: "Cedar space trusted_ip_ranges should be supported",
32+
generation: "cedar",
33+
resourceType: "space",
34+
feature: "trusted_ip_ranges",
35+
expected: true,
36+
},
37+
38+
// Fir generation tests - private supported, shield and others unsupported
39+
{
40+
name: "Fir space private should be supported",
41+
generation: "fir",
42+
resourceType: "space",
43+
feature: "private",
44+
expected: true,
45+
},
46+
{
47+
name: "Fir space shield should be unsupported",
48+
generation: "fir",
49+
resourceType: "space",
50+
feature: "shield",
51+
expected: false,
52+
},
53+
{
54+
name: "Fir space trusted_ip_ranges should be unsupported",
55+
generation: "fir",
56+
resourceType: "space",
57+
feature: "trusted_ip_ranges",
58+
expected: false,
59+
},
60+
61+
// Unsupported feature tests (features not in matrix)
62+
{
63+
name: "Cedar space unknown_feature should be unsupported (not in matrix)",
64+
generation: "cedar",
65+
resourceType: "space",
66+
feature: "unknown_feature",
67+
expected: false,
68+
},
69+
{
70+
name: "Fir space unknown_feature should be unsupported (not in matrix)",
71+
generation: "fir",
72+
resourceType: "space",
73+
feature: "unknown_feature",
74+
expected: false,
75+
},
76+
77+
// Unknown resource type tests
78+
{
79+
name: "Cedar app features should be unsupported (not implemented yet)",
80+
generation: "cedar",
81+
resourceType: "app",
82+
feature: "some_feature",
83+
expected: false,
84+
},
85+
{
86+
name: "Fir build features should be unsupported (not implemented yet)",
87+
generation: "fir",
88+
resourceType: "build",
89+
feature: "some_feature",
90+
expected: false,
91+
},
92+
93+
// Invalid generation tests
94+
{
95+
name: "Invalid generation should be unsupported",
96+
generation: "invalid",
97+
resourceType: "space",
98+
feature: "shield",
99+
expected: false,
100+
},
101+
{
102+
name: "Empty generation should be unsupported",
103+
generation: "",
104+
resourceType: "space",
105+
feature: "shield",
106+
expected: false,
107+
},
108+
109+
// Edge case tests
110+
{
111+
name: "Empty resource type should be unsupported",
112+
generation: "cedar",
113+
resourceType: "",
114+
feature: "shield",
115+
expected: false,
116+
},
117+
{
118+
name: "Empty feature should be unsupported",
119+
generation: "cedar",
120+
resourceType: "space",
121+
feature: "",
122+
expected: false,
123+
},
124+
}
125+
126+
for _, tc := range testCases {
127+
t.Run(tc.name, func(t *testing.T) {
128+
result := IsFeatureSupported(tc.generation, tc.resourceType, tc.feature)
129+
if result != tc.expected {
130+
t.Errorf("IsFeatureSupported(%q, %q, %q) = %v, expected %v",
131+
tc.generation, tc.resourceType, tc.feature, result, tc.expected)
132+
}
133+
})
134+
}
135+
}
136+
137+
// TestFeatureMatrixConsistency ensures the feature matrix is internally consistent
138+
func TestFeatureMatrixConsistency(t *testing.T) {
139+
// Verify that all generations have at least one resource
140+
for generation, resources := range featureMatrix {
141+
if len(resources) == 0 {
142+
t.Errorf("Generation %s has no resources defined", generation)
143+
}
144+
145+
// Verify that all resources have at least one feature
146+
for resourceType, features := range resources {
147+
if len(features) == 0 {
148+
t.Errorf("Generation %s, resource %s has no features defined", generation, resourceType)
149+
}
150+
151+
// Verify that all features are properly set (no nil values)
152+
for feature, supported := range features {
153+
if feature == "" {
154+
t.Errorf("Generation %s, resource %s has empty feature name", generation, resourceType)
155+
}
156+
// supported is bool, so just verify it's not accidentally unset in a way that would matter
157+
_ = supported // This is intentional - we're just ensuring the value exists
158+
}
159+
}
160+
}
161+
162+
// Verify minimum required features exist for Task 1
163+
// Both generations support private spaces
164+
if !IsFeatureSupported("cedar", "space", "private") {
165+
t.Error("Cedar space private must be supported")
166+
}
167+
if !IsFeatureSupported("fir", "space", "private") {
168+
t.Error("Fir space private must be supported")
169+
}
170+
171+
// Only cedar supports shield spaces
172+
if !IsFeatureSupported("cedar", "space", "shield") {
173+
t.Error("Cedar space shield must be supported")
174+
}
175+
if IsFeatureSupported("fir", "space", "shield") {
176+
t.Error("Fir space shield must be unsupported")
177+
}
178+
}

0 commit comments

Comments
 (0)