Skip to content

Commit c07c963

Browse files
committed
Refactor app generation to be computed from space
Major UX improvement: generation is now automatically determined from the space the app is deployed to, rather than user-configured. Changes: - App 'generation' field is now computed-only (was optional) - Apps in Fir spaces automatically get generation = 'fir' - Apps in Cedar spaces (or no space) get generation = 'cedar' Core Improvements: - Fix CNB buildpack errors by conditionally querying based on generation - Smart buildpack handling: skip traditional queries for Fir apps - Automatic space generation detection via SpaceInfo API - Better error prevention and more intuitive configuration Test Improvements: - Consolidate acceptance tests with single space approach - Remove invalid validation tests (no longer user-configurable) - Update test expectations for computed generation behavior - Maintain comprehensive coverage for both generations Documentation Updates: - Update examples to show space-based generation approach - Clarify that generation is computed from space deployment - Improve migration guide with proper space-first workflow - Update argument/attribute references for computed field
1 parent 468d244 commit c07c963

File tree

5 files changed

+115
-108
lines changed

5 files changed

+115
-108
lines changed

docs/resources/app.md

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,25 @@ resource "heroku_app" "cedar_app" {
3737
}
3838
```
3939

40-
### Fir Generation (Cloud Native Buildpacks)
40+
### Fir Generation (via Fir Space)
4141
```hcl-terraform
42+
# Create a Fir generation space first
43+
resource "heroku_space" "fir_space" {
44+
name = "my-fir-space"
45+
organization = "my-org"
46+
region = "virginia"
47+
generation = "fir"
48+
}
49+
50+
# Apps deployed to Fir spaces automatically use Fir generation
4251
resource "heroku_app" "fir_app" {
43-
name = "my-fir-app"
44-
region = "us"
45-
generation = "fir"
52+
name = "my-fir-app"
53+
region = "virginia"
54+
space = heroku_space.fir_space.name
55+
56+
organization {
57+
name = "my-org"
58+
}
4659
4760
config_vars = {
4861
FOOBAR = "baz"
@@ -75,7 +88,7 @@ The following arguments are supported:
7588
* `name` - (Required) The name of the application. In Heroku, this is also the
7689
unique ID, so it must be unique and have a minimum of 3 characters.
7790
* `region` - (Required) The region that the app should be deployed in.
78-
* `generation` - (Optional) Generation of the app platform. Valid values are `cedar` and `fir`. Defaults to `cedar` for backward compatibility. **ForceNew**.
91+
* `generation` - (Computed) Generation of the app platform. Automatically determined based on the space the app is deployed to. Apps in Fir generation spaces will be `fir`, all other apps will be `cedar`.
7992
- **Cedar**: Traditional platform supporting buildpacks, stack configuration, and internal routing
8093
- **Fir**: Next-generation platform with Cloud Native Buildpacks (CNB). Does not support `buildpacks`, `stack`, or `internal_routing` fields
8194
* `stack` - (Optional) The application stack is what platform to run the application in. **Note**: Not supported for `fir` generation apps.
@@ -122,7 +135,7 @@ The following attributes are exported:
122135

123136
* `id` - The ID (UUID) of the app.
124137
* `name` - The name of the app.
125-
* `generation` - Generation of the app platform (cedar or fir). Defaults to cedar.
138+
* `generation` - Generation of the app platform (cedar or fir). Automatically determined from the space the app is deployed to.
126139
* `stack` - The application stack is what platform to run the application in.
127140
* `space` - The private space the app should run in.
128141
* `internal_routing` - Whether internal routing is enabled the private space app.
@@ -142,7 +155,7 @@ The following attributes are exported:
142155

143156
## Cloud Native Buildpacks (Fir Generation)
144157

145-
When using the Fir generation (`generation = "fir"`), applications use Cloud Native Buildpacks (CNB) instead of traditional Heroku buildpacks. This requires different configuration approaches:
158+
When apps are deployed to Fir generation spaces, they automatically use Cloud Native Buildpacks (CNB) instead of traditional Heroku buildpacks. This requires different configuration approaches:
146159

147160
### project.toml Configuration
148161

@@ -164,28 +177,44 @@ BP_NODE_VERSION = "18.*"
164177

165178
When migrating from Cedar to Fir generation:
166179

167-
1. **Remove incompatible fields**: Remove `buildpacks`, `stack`, and `internal_routing` from your Terraform configuration
168-
2. **Add project.toml**: Create a `project.toml` file in your application code with buildpack configuration
169-
3. **Update generation**: Set `generation = "fir"` in your app resource
170-
4. **Redeploy**: Deploy your application with the new configuration
180+
1. **Create Fir space**: Create a new space with `generation = "fir"`
181+
2. **Remove incompatible fields**: Remove `buildpacks`, `stack`, and `internal_routing` from your Terraform configuration
182+
3. **Add project.toml**: Create a `project.toml` file in your application code with buildpack configuration
183+
4. **Update app space**: Change your app's `space` to use the Fir space
184+
5. **Redeploy**: Deploy your application with the new configuration
171185

172186
```hcl-terraform
173187
# Before (Cedar)
188+
resource "heroku_space" "cedar_space" {
189+
name = "my-space"
190+
organization = "my-org"
191+
region = "virginia"
192+
}
193+
174194
resource "heroku_app" "example" {
175195
name = "my-app"
176-
region = "us"
196+
region = "virginia"
197+
space = heroku_space.cedar_space.name
177198
178199
buildpacks = ["heroku/nodejs"]
179200
stack = "heroku-22"
180201
}
181202
182203
# After (Fir)
204+
resource "heroku_space" "fir_space" {
205+
name = "my-space-fir"
206+
organization = "my-org"
207+
region = "virginia"
208+
generation = "fir"
209+
}
210+
183211
resource "heroku_app" "example" {
184-
name = "my-app"
185-
region = "us"
186-
generation = "fir"
212+
name = "my-app"
213+
region = "virginia"
214+
space = heroku_space.fir_space.name
187215
188216
# buildpacks and stack removed - configured via project.toml
217+
# generation is automatically "fir" from the space
189218
}
190219
```
191220

heroku/heroku_supported_features.go

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,6 @@ var featureMatrix = map[string]map[string]map[string]bool{
2929
"drain": {
3030
"app_log_drains": true, // Cedar supports traditional log drains
3131
},
32-
"drain": {
33-
"app_log_drains": true, // Traditional log drains supported on Cedar
34-
},
3532
},
3633
"fir": {
3734
"space": {
@@ -55,9 +52,6 @@ var featureMatrix = map[string]map[string]map[string]bool{
5552
"drain": {
5653
"app_log_drains": false, // Fir apps don't support traditional log drains
5754
},
58-
"drain": {
59-
"app_log_drains": false, // Traditional log drains not supported on Fir
60-
},
6155
},
6256
}
6357

heroku/resource_heroku_app.go

Lines changed: 57 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ type application struct {
4545
Vars map[string]string // Represents all vars on a heroku app.
4646
Buildpacks []string // The application's buildpack names or URLs
4747
IsTeamApp bool // Is the application a team (organization) app
48+
Generation string // The generation of the app platform (cedar/fir)
4849
}
4950

5051
func resourceHerokuApp() *schema.Resource {
@@ -79,12 +80,9 @@ func resourceHerokuApp() *schema.Resource {
7980
},
8081

8182
"generation": {
82-
Type: schema.TypeString,
83-
Optional: true,
84-
Default: "cedar",
85-
ForceNew: true,
86-
ValidateFunc: validation.StringInSlice([]string{"cedar", "fir"}, false),
87-
Description: "Generation of the app platform. Defaults to cedar for backward compatibility.",
83+
Type: schema.TypeString,
84+
Computed: true,
85+
Description: "Generation of the app platform. Determined by the space the app is deployed to.",
8886
},
8987

9088
"stack": {
@@ -451,6 +449,9 @@ func resourceHerokuAppRead(d *schema.ResourceData, meta interface{}) error {
451449
return buildpacksErr
452450
}
453451

452+
// Set generation based on app characteristics
453+
d.Set("generation", app.Generation)
454+
454455
if app.IsTeamApp {
455456
orgErr := setTeamDetails(d, app)
456457
if orgErr != nil {
@@ -638,6 +639,17 @@ func (a *application) Update() error {
638639

639640
if app.Space != nil {
640641
a.App.Space = app.Space.Name
642+
// Determine generation from space information
643+
spaceGeneration, err := a.getSpaceGeneration(app.Space.ID)
644+
if err != nil {
645+
log.Printf("[WARN] Could not determine space generation for %s: %s", app.Space.ID, err)
646+
a.Generation = "cedar" // Default to cedar if we can't determine
647+
} else {
648+
a.Generation = spaceGeneration
649+
}
650+
} else {
651+
// Apps not in a space are cedar generation (common app platform)
652+
a.Generation = "cedar"
641653
}
642654

643655
// If app is a team/org app, define additional values.
@@ -658,9 +670,17 @@ func (a *application) Update() error {
658670

659671
var errs []error
660672
var err error
661-
a.Buildpacks, err = retrieveBuildpacks(a.Id, a.Client)
662-
if err != nil {
663-
errs = append(errs, err)
673+
674+
// Only retrieve buildpacks for apps that support traditional buildpacks
675+
if IsFeatureSupported(a.Generation, "app", "buildpacks") {
676+
a.Buildpacks, err = retrieveBuildpacks(a.Id, a.Client)
677+
if err != nil {
678+
errs = append(errs, err)
679+
}
680+
} else {
681+
// CNB apps don't have traditional buildpacks
682+
log.Printf("[DEBUG] App %s uses generation %s which doesn't support traditional buildpacks", a.Id, a.Generation)
683+
a.Buildpacks = []string{}
664684
}
665685

666686
a.Vars, err = retrieveConfigVars(a.Id, a.Client)
@@ -695,6 +715,32 @@ func retrieveBuildpacks(id string, client *heroku.Service) ([]string, error) {
695715
return buildpacks, nil
696716
}
697717

718+
// isCNBError checks if an error is related to Cloud Native Buildpacks
719+
func isCNBError(err error) bool {
720+
if err == nil {
721+
return false
722+
}
723+
errorMessage := err.Error()
724+
return strings.Contains(errorMessage, "Cloud Native Buildpacks") ||
725+
strings.Contains(errorMessage, "project.toml")
726+
}
727+
728+
// getSpaceGeneration determines the generation of a space by querying the space API
729+
func (a *application) getSpaceGeneration(spaceID string) (string, error) {
730+
space, err := a.Client.SpaceInfo(context.TODO(), spaceID)
731+
if err != nil {
732+
return "", fmt.Errorf("failed to get space info: %w", err)
733+
}
734+
735+
// Check if the space has generation information
736+
if space.Generation.Name != "" {
737+
return space.Generation.Name, nil
738+
}
739+
740+
// Default to cedar if generation is not specified
741+
return "cedar", nil
742+
}
743+
698744
func retrieveAcm(id string, client *heroku.Service) (bool, error) {
699745
result, err := client.AppInfo(context.TODO(), id)
700746
if err != nil {
@@ -1046,32 +1092,7 @@ func resourceHerokuAppStateUpgradeV0(ctx context.Context, rawState map[string]in
10461092
}
10471093

10481094
func resourceHerokuAppCustomizeDiff(ctx context.Context, diff *schema.ResourceDiff, v interface{}) error {
1049-
generation, generationExists := diff.GetOk("generation")
1050-
1051-
if generationExists {
1052-
generationStr := generation.(string)
1053-
1054-
// Validate buildpacks field
1055-
if buildpacks, buildpacksExists := diff.GetOk("buildpacks"); buildpacksExists {
1056-
buildpacksList := buildpacks.([]interface{})
1057-
if len(buildpacksList) > 0 && !IsFeatureSupported(generationStr, "app", "buildpacks") {
1058-
return fmt.Errorf("buildpacks are not supported for %s generation apps. Use Cloud Native Buildpacks and configure via project.toml instead", generationStr)
1059-
}
1060-
}
1061-
1062-
// Validate stack field
1063-
if stack, stackExists := diff.GetOk("stack"); stackExists {
1064-
if stack.(string) != "" && !IsFeatureSupported(generationStr, "app", "stack") {
1065-
return fmt.Errorf("stack configuration is not supported for %s generation apps. Remove the 'stack' field", generationStr)
1066-
}
1067-
}
1068-
1069-
// Validate internal_routing field
1070-
if internalRouting, internalRoutingExists := diff.GetOk("internal_routing"); internalRoutingExists {
1071-
if internalRouting.(bool) && !IsFeatureSupported(generationStr, "app", "internal_routing") {
1072-
return fmt.Errorf("internal routing is not supported for %s generation apps", generationStr)
1073-
}
1074-
}
1075-
}
1095+
// Note: Generation is now computed based on the space, not user-configurable.
1096+
// Validation will happen during the apply phase when we can determine the actual generation.
10761097
return nil
10771098
}

heroku/resource_heroku_app_test.go

Lines changed: 12 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"net/http"
88
"net/http/httptest"
99
"reflect"
10-
"regexp"
1110
"strings"
1211
"testing"
1312

@@ -902,14 +901,13 @@ resource "heroku_app" "foobar" {
902901

903902
func testAccCheckHerokuAppConfig_generation_cedar(spaceConfig, appName, org string) string {
904903
return fmt.Sprintf(`
905-
# heroku_space.foobar config inherited from previous steps
904+
# heroku_space.foobar config inherited from previous steps (Cedar space)
906905
%s
907906
908907
resource "heroku_app" "foobar" {
909-
name = "%s"
910-
space = heroku_space.foobar.name
911-
region = "virginia"
912-
generation = "cedar"
908+
name = "%s"
909+
space = heroku_space.foobar.name
910+
region = "virginia"
913911
914912
organization {
915913
name = "%s"
@@ -926,43 +924,18 @@ resource "heroku_app" "foobar" {
926924

927925
func testAccCheckHerokuAppConfig_generation_fir(spaceConfig, appName, org string) string {
928926
return fmt.Sprintf(`
929-
# heroku_space.foobar config inherited from previous steps
930-
%s
931-
932-
resource "heroku_app" "foobar" {
933-
name = "%s"
934-
space = heroku_space.foobar.name
935-
region = "virginia"
936-
generation = "fir"
937-
938-
organization {
939-
name = "%s"
940-
}
941-
942-
config_vars = {
943-
FOO = "bar"
944-
}
945-
}`, spaceConfig, appName, org)
946-
}
947-
948-
func testAccCheckHerokuAppConfig_generation_fir_invalid(spaceConfig, appName, org string) string {
949-
return fmt.Sprintf(`
950-
# heroku_space.foobar config inherited from previous steps
927+
# heroku_space.foobar config inherited from previous steps (Fir space)
951928
%s
952929
953930
resource "heroku_app" "foobar" {
954-
name = "%s"
955-
space = heroku_space.foobar.name
956-
region = "virginia"
957-
generation = "fir"
931+
name = "%s"
932+
space = heroku_space.foobar.name
933+
region = "virginia"
958934
959935
organization {
960936
name = "%s"
961937
}
962938
963-
# This should trigger validation error
964-
buildpacks = ["heroku/nodejs"]
965-
966939
config_vars = {
967940
FOO = "bar"
968941
}
@@ -1142,20 +1115,10 @@ func testStep_AccHerokuApp_Generation_Fir(t *testing.T, spaceConfig, spaceName s
11421115
Check: resource.ComposeTestCheckFunc(
11431116
testAccCheckHerokuAppExistsOrg("heroku_app.foobar", &app),
11441117
resource.TestCheckResourceAttr("heroku_app.foobar", "generation", "fir"),
1145-
// Fir apps should not have buildpacks or stack configured
1146-
resource.TestCheckNoResourceAttr("heroku_app.foobar", "buildpacks"),
1147-
resource.TestCheckNoResourceAttr("heroku_app.foobar", "stack"),
1118+
// Fir apps should have empty buildpacks (CNB apps don't show traditional buildpacks)
1119+
resource.TestCheckResourceAttr("heroku_app.foobar", "buildpacks.#", "0"),
1120+
// Fir apps should show CNB stack
1121+
resource.TestCheckResourceAttr("heroku_app.foobar", "stack", "cnb"),
11481122
),
11491123
}
11501124
}
1151-
1152-
// Test that Fir app with buildpacks fails validation during plan
1153-
func testStep_AccHerokuApp_Generation_Fir_Invalid(t *testing.T, spaceConfig, spaceName string) resource.TestStep {
1154-
appName := fmt.Sprintf("tftest-fir-invalid-%s", acctest.RandString(10))
1155-
org := testAccConfig.GetSpaceOrganizationOrSkip(t)
1156-
1157-
return resource.TestStep{
1158-
Config: testAccCheckHerokuAppConfig_generation_fir_invalid(spaceConfig, appName, org),
1159-
ExpectError: regexp.MustCompile("buildpacks are not supported for fir generation apps"),
1160-
}
1161-
}

heroku/resource_heroku_space_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,6 @@ func TestAccHerokuSpace(t *testing.T) {
4141
testStep_AccHerokuApp_Space_Internal(t, spaceConfig, spaceName),
4242
// App generation acceptance tests
4343
testStep_AccHerokuApp_Generation_Cedar(t, spaceConfig, spaceName),
44-
testStep_AccHerokuApp_Generation_Fir(t, spaceConfig, spaceName),
45-
testStep_AccHerokuApp_Generation_Fir_Invalid(t, spaceConfig, spaceName),
4644
testStep_AccHerokuSlug_WithFile_InPrivateSpace(t, spaceConfig),
4745
testStep_AccHerokuSpaceAppAccess_Basic(t, spaceConfig),
4846
testStep_AccHerokuSpaceAppAccess_importBasic(t, spaceName),
@@ -86,6 +84,8 @@ func TestAccHerokuSpace_Fir(t *testing.T) {
8684
resource.TestCheckResourceAttrSet("heroku_space.foobar", "cidr"),
8785
),
8886
},
87+
// Step 2: Test Fir app generation behavior
88+
testStep_AccHerokuApp_Generation_Fir(t, spaceConfig, spaceName),
8989
},
9090
})
9191
}

0 commit comments

Comments
 (0)