diff --git a/docs/resources/app.md b/docs/resources/app.md index 61331143..e45f717a 100644 --- a/docs/resources/app.md +++ b/docs/resources/app.md @@ -10,14 +10,19 @@ description: |- Provides a Heroku App resource. This can be used to create and manage applications on Heroku. +The Heroku platform supports two generations: +- **Cedar** (default): Traditional platform with support for buildpacks, stack configuration, and internal routing +- **Fir**: Next-generation platform with Cloud Native Buildpacks (CNB), enhanced security, and modern containerization + -> **Always reference apps by ID (UUID) in Terraform configuration** Starting with v5.0 of this provider, all HCL app references are by ID. Read more details in [Upgrading](guides/upgrading.html). ## Example Usage +### Cedar Generation (Default) ```hcl-terraform -resource "heroku_app" "default" { - name = "my-cool-app" +resource "heroku_app" "cedar_app" { + name = "my-cedar-app" region = "us" config_vars = { @@ -27,6 +32,37 @@ resource "heroku_app" "default" { buildpacks = [ "heroku/go" ] + + stack = "heroku-22" +} +``` + +### Fir Generation (via Fir Space) +```hcl-terraform +# Create a Fir generation space first +resource "heroku_space" "fir_space" { + name = "my-fir-space" + organization = "my-org" + region = "virginia" + generation = "fir" +} + +# Apps deployed to Fir spaces automatically use Fir generation +resource "heroku_app" "fir_app" { + name = "my-fir-app" + region = "virginia" + space = heroku_space.fir_space.name + + organization { + name = "my-org" + } + + config_vars = { + FOOBAR = "baz" + } + + # Note: buildpacks and stack are not supported for Fir generation + # Use project.toml in your application code instead } ``` @@ -52,9 +88,12 @@ The following arguments are supported: * `name` - (Required) The name of the application. In Heroku, this is also the unique ID, so it must be unique and have a minimum of 3 characters. * `region` - (Required) The region that the app should be deployed in. -* `stack` - (Optional) The application stack is what platform to run the application in. +* `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`. + - `cedar`: Traditional platform supporting buildpacks, stack configuration, and internal routing + - `fir`: Next-generation platform with Cloud Native Buildpacks (CNB). Does not support `buildpacks`, `stack`, or `internal_routing` fields +* `stack` - (Optional) The application stack is what platform to run the application in. **Note**: Not supported for `fir` generation apps. * `buildpacks` - (Optional) Buildpack names or URLs for the application. - Buildpacks configured externally won't be altered if this is not present. + Buildpacks configured externally won't be altered if this is not present. **Note**: Not supported for `fir` generation apps. Use project.toml for Cloud Native Buildpacks configuration instead. * `config_vars`[1](#deleting-vars) - (Optional) Configuration variables for the application. The config variables in this map are not the final set of configuration variables, but rather variables you want present. That is, other @@ -68,7 +107,7 @@ The following arguments are supported: * `space` - (Optional) The name of a private space to create the app in. * `internal_routing` - (Optional) If true, the application will be routable only internally in a private space. This option is only available for apps - that also specify `space`. + that also specify `space`. **Note**: Not supported for `fir` generation apps. * `organization` - (Optional) A block that can be specified once to define Heroku Team settings for this app. The fields for this block are documented below. @@ -96,6 +135,7 @@ The following attributes are exported: * `id` - The ID (UUID) of the app. * `name` - The name of the app. +* `generation` - Generation of the app platform (cedar or fir). Automatically determined from the space the app is deployed to. * `stack` - The application stack is what platform to run the application in. * `space` - The private space the app should run in. * `internal_routing` - Whether internal routing is enabled the private space app. @@ -113,6 +153,71 @@ The following attributes are exported: attribute `set_app_all_config_vars_in_state` is `false`. * `uuid` - The unique UUID of the Heroku app. **NOTE:** Use this for `null_resource` triggers. +## Cloud Native Buildpacks (Fir Generation) + +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: + +### project.toml Configuration + +Instead of specifying `buildpacks` in Terraform, create a `project.toml` file in your application root: + +```toml +[build] +[[build.buildpacks]] +id = "heroku/nodejs" + +[[build.buildpacks]] +id = "heroku/procfile" + +[build.env] +BP_NODE_VERSION = "18.*" +``` + +### Migration from Cedar to Fir + +When migrating from Cedar to Fir generation: + +1. **Create Fir space**: Create a new space with `generation = "fir"` +2. **Remove incompatible fields**: Remove `buildpacks`, `stack`, and `internal_routing` from your Terraform configuration +3. **Add project.toml**: Create a `project.toml` file in your application code with buildpack configuration +4. **Update app space**: Change your app's `space` to use the Fir space +5. **Redeploy**: Deploy your application with the new configuration + +```hcl-terraform +# Before (Cedar) +resource "heroku_space" "cedar_space" { + name = "my-space" + organization = "my-org" + region = "virginia" +} + +resource "heroku_app" "example" { + name = "my-app" + region = "virginia" + space = heroku_space.cedar_space.name + + buildpacks = ["heroku/nodejs"] + stack = "heroku-22" +} + +# After (Fir) +resource "heroku_space" "fir_space" { + name = "my-space-fir" + organization = "my-org" + region = "virginia" + generation = "fir" +} + +resource "heroku_app" "example" { + name = "my-app" + region = "virginia" + space = heroku_space.fir_space.name + + # buildpacks and stack removed - configured via project.toml + # generation is automatically "fir" from the space +} +``` + ## Import Apps can be imported using an existing app's `UUID` or name. diff --git a/heroku/heroku_supported_features.go b/heroku/heroku_supported_features.go index ecd497cd..03be55be 100644 --- a/heroku/heroku_supported_features.go +++ b/heroku/heroku_supported_features.go @@ -20,6 +20,15 @@ var featureMatrix = map[string]map[string]map[string]bool{ "inbound_ruleset": true, // Cedar supports inbound rulesets "peering_connection": true, // Cedar supports IPv4 peering }, + "app": { + "buildpacks": true, // Cedar supports traditional buildpacks + "stack": true, // Cedar supports stack configuration + "internal_routing": true, // Cedar supports internal routing + "cloud_native_buildpacks": false, // Cedar doesn't use CNB by default + }, + "drain": { + "app_log_drains": true, // Cedar supports traditional log drains + }, }, "fir": { "space": { @@ -34,6 +43,15 @@ var featureMatrix = map[string]map[string]map[string]bool{ "inbound_ruleset": false, // Inbound rulesets not supported "peering_connection": false, // IPv4 peering not supported }, + "app": { + "buildpacks": false, // Fir doesn't support traditional buildpacks + "stack": false, // Fir doesn't use traditional stack config + "internal_routing": false, // Fir doesn't support internal routing + "cloud_native_buildpacks": true, // Fir uses CNB exclusively + }, + "drain": { + "app_log_drains": false, // Fir apps don't support traditional log drains + }, }, } diff --git a/heroku/heroku_supported_features_test.go b/heroku/heroku_supported_features_test.go index ff13deab..ae611064 100644 --- a/heroku/heroku_supported_features_test.go +++ b/heroku/heroku_supported_features_test.go @@ -75,13 +75,63 @@ func TestIsFeatureSupported(t *testing.T) { }, // Unknown resource type tests + // App feature tests { - name: "Cedar app features should be unsupported (not implemented yet)", + name: "Cedar app buildpacks should be supported", generation: "cedar", resourceType: "app", - feature: "some_feature", + feature: "buildpacks", + expected: true, + }, + { + name: "Cedar app stack should be supported", + generation: "cedar", + resourceType: "app", + feature: "stack", + expected: true, + }, + { + name: "Cedar app internal_routing should be supported", + generation: "cedar", + resourceType: "app", + feature: "internal_routing", + expected: true, + }, + { + name: "Cedar app cloud_native_buildpacks should be unsupported", + generation: "cedar", + resourceType: "app", + feature: "cloud_native_buildpacks", + expected: false, + }, + { + name: "Fir app buildpacks should be unsupported", + generation: "fir", + resourceType: "app", + feature: "buildpacks", + expected: false, + }, + { + name: "Fir app stack should be unsupported", + generation: "fir", + resourceType: "app", + feature: "stack", + expected: false, + }, + { + name: "Fir app internal_routing should be unsupported", + generation: "fir", + resourceType: "app", + feature: "internal_routing", expected: false, }, + { + name: "Fir app cloud_native_buildpacks should be supported", + generation: "fir", + resourceType: "app", + feature: "cloud_native_buildpacks", + expected: true, + }, { name: "Fir build features should be unsupported (not implemented yet)", generation: "fir", diff --git a/heroku/resource_heroku_app.go b/heroku/resource_heroku_app.go index 1b18baf7..7c497273 100644 --- a/heroku/resource_heroku_app.go +++ b/heroku/resource_heroku_app.go @@ -45,15 +45,17 @@ type application struct { Vars map[string]string // Represents all vars on a heroku app. Buildpacks []string // The application's buildpack names or URLs IsTeamApp bool // Is the application a team (organization) app + Generation string // The generation of the app platform (cedar/fir) } func resourceHerokuApp() *schema.Resource { return &schema.Resource{ - Create: switchHerokuAppCreate, - Read: resourceHerokuAppRead, - Update: resourceHerokuAppUpdate, - Delete: resourceHerokuAppDelete, - Exists: resourceHerokuAppExists, + Create: switchHerokuAppCreate, + Read: resourceHerokuAppRead, + Update: resourceHerokuAppUpdate, + Delete: resourceHerokuAppDelete, + Exists: resourceHerokuAppExists, + CustomizeDiff: resourceHerokuAppCustomizeDiff, Importer: &schema.ResourceImporter{ State: resourceHerokuAppImport, @@ -77,6 +79,12 @@ func resourceHerokuApp() *schema.Resource { ForceNew: true, }, + "generation": { + Type: schema.TypeString, + Computed: true, + Description: "Generation of the app platform. Determined by the space the app is deployed to.", + }, + "stack": { Type: schema.TypeString, Optional: true, @@ -441,6 +449,9 @@ func resourceHerokuAppRead(d *schema.ResourceData, meta interface{}) error { return buildpacksErr } + // Set generation based on app characteristics + d.Set("generation", app.Generation) + if app.IsTeamApp { orgErr := setTeamDetails(d, app) if orgErr != nil { @@ -630,6 +641,14 @@ func (a *application) Update() error { a.App.Space = app.Space.Name } + // Determine generation from app's generation field (available in AppInfo response) + if app.Generation.Name != "" { + a.Generation = app.Generation.Name + } else { + // Default to cedar if generation is not specified + a.Generation = "cedar" + } + // If app is a team/org app, define additional values. if app.Organization != nil && app.Team != nil { // Set to true to control additional state actions downstream @@ -648,9 +667,17 @@ func (a *application) Update() error { var errs []error var err error - a.Buildpacks, err = retrieveBuildpacks(a.Id, a.Client) - if err != nil { - errs = append(errs, err) + + // Only retrieve buildpacks for apps that support traditional buildpacks + if IsFeatureSupported(a.Generation, "app", "buildpacks") { + a.Buildpacks, err = retrieveBuildpacks(a.Id, a.Client) + if err != nil { + errs = append(errs, err) + } + } else { + // CNB apps don't have traditional buildpacks + log.Printf("[DEBUG] App %s uses generation %s which doesn't support traditional buildpacks", a.Id, a.Generation) + a.Buildpacks = []string{} } a.Vars, err = retrieveConfigVars(a.Id, a.Client) @@ -685,6 +712,16 @@ func retrieveBuildpacks(id string, client *heroku.Service) ([]string, error) { return buildpacks, nil } +// isCNBError checks if an error is related to Cloud Native Buildpacks +func isCNBError(err error) bool { + if err == nil { + return false + } + errorMessage := err.Error() + return strings.Contains(errorMessage, "Cloud Native Buildpacks") || + strings.Contains(errorMessage, "project.toml") +} + func retrieveAcm(id string, client *heroku.Service) (bool, error) { result, err := client.AppInfo(context.TODO(), id) if err != nil { @@ -1034,3 +1071,9 @@ func resourceHerokuAppStateUpgradeV0(ctx context.Context, rawState map[string]in return rawState, nil } + +func resourceHerokuAppCustomizeDiff(ctx context.Context, diff *schema.ResourceDiff, v interface{}) error { + // Note: Generation is now computed based on the space, not user-configurable. + // Validation will happen during the apply phase when we can determine the actual generation. + return nil +} diff --git a/heroku/resource_heroku_app_test.go b/heroku/resource_heroku_app_test.go index 20fa295c..73860111 100644 --- a/heroku/resource_heroku_app_test.go +++ b/heroku/resource_heroku_app_test.go @@ -899,6 +899,49 @@ resource "heroku_app" "foobar" { }`, appName, org) } +func testAccCheckHerokuAppConfig_generation_cedar(spaceConfig, appName, org string) string { + return fmt.Sprintf(` +# heroku_space.foobar config inherited from previous steps (Cedar space) +%s + +resource "heroku_app" "foobar" { + name = "%s" + space = heroku_space.foobar.name + region = "virginia" + + organization { + name = "%s" + } + + buildpacks = ["heroku/nodejs"] + stack = "heroku-22" + + config_vars = { + FOO = "bar" + } +}`, spaceConfig, appName, org) +} + +func testAccCheckHerokuAppConfig_generation_fir(spaceConfig, appName, org string) string { + return fmt.Sprintf(` +# heroku_space.foobar config inherited from previous steps (Fir space) +%s + +resource "heroku_app" "foobar" { + name = "%s" + space = heroku_space.foobar.name + region = "virginia" + + organization { + name = "%s" + } + + config_vars = { + FOO = "bar" + } +}`, spaceConfig, appName, org) +} + func testAccCheckHerokuAppConfig_acm_disabled(appName, org string) string { return fmt.Sprintf(` resource "heroku_app" "foobar" { @@ -1006,3 +1049,76 @@ resource "heroku_app" "foobar" { } }`, appName) } + +// Unit tests for app generation support +// Simple unit test for app generation validation logic +func TestHerokuAppGeneration(t *testing.T) { + // Test that the feature matrix correctly identifies supported/unsupported features + tests := []struct { + name string + generation string + feature string + expected bool + }{ + // Cedar app features - should all be supported except CNB + {name: "Cedar buildpacks should be supported", generation: "cedar", feature: "buildpacks", expected: true}, + {name: "Cedar stack should be supported", generation: "cedar", feature: "stack", expected: true}, + {name: "Cedar internal_routing should be supported", generation: "cedar", feature: "internal_routing", expected: true}, + {name: "Cedar cloud_native_buildpacks should be unsupported", generation: "cedar", feature: "cloud_native_buildpacks", expected: false}, + + // Fir app features - traditional features should be unsupported, CNB should be supported + {name: "Fir buildpacks should be unsupported", generation: "fir", feature: "buildpacks", expected: false}, + {name: "Fir stack should be unsupported", generation: "fir", feature: "stack", expected: false}, + {name: "Fir internal_routing should be unsupported", generation: "fir", feature: "internal_routing", expected: false}, + {name: "Fir cloud_native_buildpacks should be supported", generation: "fir", feature: "cloud_native_buildpacks", expected: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + supported := IsFeatureSupported(tt.generation, "app", tt.feature) + if supported != tt.expected { + t.Errorf("Expected %t but got %t for generation %s feature %s", tt.expected, supported, tt.generation, tt.feature) + } + t.Logf("✅ Generation: %s, Feature: %s, Supported: %t", tt.generation, tt.feature, supported) + }) + } +} + +// Generates a "test step" not a whole test, so that it can reuse the space. +// See: resource_heroku_space_test.go, where this is used. +func testStep_AccHerokuApp_Generation_Cedar(t *testing.T, spaceConfig, spaceName string) resource.TestStep { + var app heroku.TeamApp + appName := fmt.Sprintf("tftest-cedar-%s", acctest.RandString(10)) + org := testAccConfig.GetSpaceOrganizationOrSkip(t) + + return resource.TestStep{ + Config: testAccCheckHerokuAppConfig_generation_cedar(spaceConfig, appName, org), + Check: resource.ComposeTestCheckFunc( + testAccCheckHerokuAppExistsOrg("heroku_app.foobar", &app), + resource.TestCheckResourceAttr("heroku_app.foobar", "generation", "cedar"), + resource.TestCheckResourceAttr("heroku_app.foobar", "buildpacks.#", "1"), + resource.TestCheckResourceAttr("heroku_app.foobar", "buildpacks.0", "heroku/nodejs"), + resource.TestCheckResourceAttr("heroku_app.foobar", "stack", "heroku-22"), + ), + } +} + +// Generates a "test step" not a whole test, so that it can reuse the space. +// See: resource_heroku_space_test.go, where this is used. +func testStep_AccHerokuApp_Generation_Fir(t *testing.T, spaceConfig, spaceName string) resource.TestStep { + var app heroku.TeamApp + appName := fmt.Sprintf("tftest-fir-%s", acctest.RandString(10)) + org := testAccConfig.GetSpaceOrganizationOrSkip(t) + + return resource.TestStep{ + Config: testAccCheckHerokuAppConfig_generation_fir(spaceConfig, appName, org), + Check: resource.ComposeTestCheckFunc( + testAccCheckHerokuAppExistsOrg("heroku_app.foobar", &app), + resource.TestCheckResourceAttr("heroku_app.foobar", "generation", "fir"), + // Fir apps should have empty buildpacks (CNB apps don't show traditional buildpacks) + resource.TestCheckResourceAttr("heroku_app.foobar", "buildpacks.#", "0"), + // Fir apps should show CNB stack + resource.TestCheckResourceAttr("heroku_app.foobar", "stack", "cnb"), + ), + } +} diff --git a/heroku/resource_heroku_space_test.go b/heroku/resource_heroku_space_test.go index 4afa6694..dc6bba2b 100644 --- a/heroku/resource_heroku_space_test.go +++ b/heroku/resource_heroku_space_test.go @@ -39,6 +39,8 @@ func TestAccHerokuSpace(t *testing.T) { testStep_AccDatasourceHerokuSpacePeeringInfo_Basic(t, spaceConfig), testStep_AccHerokuApp_Space(t, spaceConfig, spaceName), testStep_AccHerokuApp_Space_Internal(t, spaceConfig, spaceName), + // App generation acceptance tests + testStep_AccHerokuApp_Generation_Cedar(t, spaceConfig, spaceName), testStep_AccHerokuSlug_WithFile_InPrivateSpace(t, spaceConfig), testStep_AccHerokuSpaceAppAccess_Basic(t, spaceConfig), testStep_AccHerokuSpaceAppAccess_importBasic(t, spaceName), @@ -82,6 +84,8 @@ func TestAccHerokuSpace_Fir(t *testing.T) { resource.TestCheckResourceAttrSet("heroku_space.foobar", "cidr"), ), }, + // Step 2: Test Fir app generation behavior + testStep_AccHerokuApp_Generation_Fir(t, spaceConfig, spaceName), }, }) }