diff --git a/docs/resources/build.md b/docs/resources/build.md index 104e2ff1..9d946956 100644 --- a/docs/resources/build.md +++ b/docs/resources/build.md @@ -22,6 +22,15 @@ If the build fails, the build log will be output in the error message. To start the app from a successful build, use a [Formation resource](formation.html) to specify the process, dyno size, and dyno quantity. +## Generation Compatibility + +Build configuration varies between Cedar (traditional) and Fir (next-generation) apps: + +- **Cedar apps**: Support traditional `buildpacks` configuration for specifying buildpack URLs or names +- **Fir apps**: Use [Cloud Native Buildpacks](https://devcenter.heroku.com/articles/using-multiple-buildpacks-for-an-app) configured via `project.toml` in the source code. The `buildpacks` argument cannot be used with Fir generation apps. + +Apps inherit their generation from their space (Fir) or default to Cedar when not in a space. + ## Source code layout The code contained in the source directory or tarball must follow the layout required by the [buildpack](https://devcenter.heroku.com/articles/buildpacks) @@ -46,6 +55,25 @@ A [`heroku.yml` manifest](https://devcenter.heroku.com/articles/build-docker-ima file is required to declare which `Dockerfile` to build for each process. Be careful not to create conflicting configuration between `heroku.yml` and Terraform, such as addons or config vars. +### Building with Cloud Native Buildpacks (Fir Generation) + +Fir generation apps use [Cloud Native Buildpacks](https://devcenter.heroku.com/articles/using-multiple-buildpacks-for-an-app) instead of traditional buildpacks. Buildpack configuration must be specified in a `project.toml` file in the source code rather than the Terraform configuration. + +The `buildpacks` argument cannot be used with Fir generation apps. Attempting to do so will result in an error during `terraform apply`. + +Example `project.toml` for a Node.js app: + +```toml +[build] +buildpacks = ["heroku/nodejs"] + +[[build.env]] +name = "NODE_ENV" +value = "production" +``` + +For more information, see [Using Multiple Buildpacks for an App](https://devcenter.heroku.com/articles/using-multiple-buildpacks-for-an-app). + ## Source URLs A `source.url` may point to any `https://` URL that responds to a `GET` with a tarball source code. When running `terraform apply`, the source code will only be fetched once for a successful build. Change the URL to force a new resource. @@ -68,14 +96,16 @@ of the Terraform configuration. ### Example Usage with Source URL +#### Cedar Generation (Traditional) + ```hcl-terraform -resource "heroku_app" "foobar" { - name = "foobar" +resource "heroku_app" "cedar_app" { + name = "my-cedar-app" region = "us" } -resource "heroku_build" "foobar" { - app_id = heroku_app.foobar.id +resource "heroku_build" "cedar_build" { + app_id = heroku_app.cedar_app.id buildpacks = ["https://github.com/mars/create-react-app-buildpack"] source { @@ -85,12 +115,53 @@ resource "heroku_build" "foobar" { } } -resource "heroku_formation" "foobar" { - app_id = heroku_app.foobar.id +resource "heroku_formation" "cedar_formation" { + app_id = heroku_app.cedar_app.id + type = "web" + quantity = 1 + size = "Standard-1x" + depends_on = ["heroku_build.cedar_build"] +} +``` + +#### Fir Generation (Cloud Native Buildpacks) + +```hcl-terraform +resource "heroku_space" "fir_space" { + name = "my-fir-space" + organization = "my-organization" + region = "virginia" + generation = "fir" +} + +resource "heroku_app" "fir_app" { + name = "my-fir-app" + region = heroku_space.fir_space.region + space = heroku_space.fir_space.id + + organization { + name = "my-organization" + } +} + +resource "heroku_build" "fir_build" { + app_id = heroku_app.fir_app.id + # Note: Do not specify buildpacks for Fir apps + # Buildpacks are configured via project.toml in the source code + + source { + # Source must include project.toml for CNB configuration + url = "https://github.com/username/my-cnb-app/archive/v1.0.0.tar.gz" + version = "v1.0.0" + } +} + +resource "heroku_formation" "fir_formation" { + app_id = heroku_app.fir_app.id type = "web" quantity = 1 size = "Standard-1x" - depends_on = ["heroku_build.foobar"] + depends_on = ["heroku_build.fir_build"] } ``` @@ -113,28 +184,71 @@ When running `terraform apply`, if the contents (SHA256) of the source path chan ### Example Usage with Local Source Directory +#### Cedar Generation (Traditional) + ```hcl-terraform -resource "heroku_app" "foobar" { - name = "foobar" +resource "heroku_app" "cedar_app" { + name = "my-cedar-app" region = "us" } -resource "heroku_build" "foobar" { - app_id = heroku_app.foobar.id +resource "heroku_build" "cedar_build" { + app_id = heroku_app.cedar_app.id + buildpacks = ["heroku/nodejs"] source { # A local directory, changing its contents will # force a new build during `terraform apply` - path = "src/example-app" + path = "src/my-cedar-app" + } +} + +resource "heroku_formation" "cedar_formation" { + app_id = heroku_app.cedar_app.id + type = "web" + quantity = 1 + size = "Standard-1x" + depends_on = ["heroku_build.cedar_build"] +} +``` + +#### Fir Generation (Cloud Native Buildpacks) + +```hcl-terraform +resource "heroku_space" "fir_space" { + name = "my-fir-space" + organization = "my-organization" + region = "virginia" + generation = "fir" +} + +resource "heroku_app" "fir_app" { + name = "my-fir-app" + region = heroku_space.fir_space.region + space = heroku_space.fir_space.id + + organization { + name = "my-organization" + } +} + +resource "heroku_build" "fir_build" { + app_id = heroku_app.fir_app.id + # Note: Do not specify buildpacks for Fir apps + + source { + # Local directory must contain project.toml + # for Cloud Native Buildpack configuration + path = "src/my-cnb-app" } } -resource "heroku_formation" "foobar" { - app_id = heroku_app.foobar.id +resource "heroku_formation" "fir_formation" { + app_id = heroku_app.fir_app.id type = "web" quantity = 1 size = "Standard-1x" - depends_on = ["heroku_build.foobar"] + depends_on = ["heroku_build.fir_build"] } ``` diff --git a/heroku/resource_heroku_build.go b/heroku/resource_heroku_build.go index c7519827..9c6282af 100644 --- a/heroku/resource_heroku_build.go +++ b/heroku/resource_heroku_build.go @@ -178,6 +178,11 @@ func resourceHerokuBuildCreate(d *schema.ResourceData, meta interface{}) error { appID := getAppId(d) + // Apply-time validation: ensure buildpacks are not specified for Fir apps + if err := validateBuildpacksForApp(client, appID, d); err != nil { + return err + } + // Build up our creation options opts := heroku.BuildCreateOpts{} @@ -333,6 +338,11 @@ func resourceHerokuBuildDelete(d *schema.ResourceData, meta interface{}) error { } func resourceHerokuBuildCustomizeDiff(ctx context.Context, diff *schema.ResourceDiff, v interface{}) error { + // Validate buildpack compatibility with app generation during plan phase + if err := validateBuildpacksForAppGeneration(ctx, diff, v); err != nil { + return err + } + // Detect changes to the content of local source archive. if v, ok := diff.GetOk("source"); ok { vL := v.([]interface{}) @@ -376,6 +386,67 @@ func resourceHerokuBuildCustomizeDiff(ctx context.Context, diff *schema.Resource return nil } +// validateBuildpacksForAppGeneration validates buildpack configuration against the target app's generation +func validateBuildpacksForAppGeneration(ctx context.Context, diff *schema.ResourceDiff, v interface{}) error { + // Only validate if buildpacks are specified + if _, ok := diff.GetOk("buildpacks"); !ok { + return nil // No buildpacks specified, nothing to validate + } + + // Get the app ID to check its generation + appID := diff.Get("app_id").(string) + if appID == "" { + // During plan phase, the app_id might not be available yet if the app is being created + // in the same configuration. Skip validation for now - it will be caught at apply time. + return nil + } + + config := v.(*Config) + client := config.Api + + // Fetch app info to determine its generation + app, err := client.AppInfo(ctx, appID) + if err != nil { + // If we can't fetch the app (it might not exist yet during plan), skip validation + // It will be caught at apply time + return nil + } + + // Validate buildpacks against app generation + return validateBuildpacksForGeneration(app.Generation.Name) +} + +// validateBuildpacksForApp validates buildpack configuration at apply-time +func validateBuildpacksForApp(client *heroku.Service, appID string, d *schema.ResourceData) error { + // Only validate if buildpacks are specified + if _, ok := d.GetOk("buildpacks"); !ok { + return nil // No buildpacks specified, nothing to validate + } + + // Fetch app info to determine its generation + app, err := client.AppInfo(context.TODO(), appID) + if err != nil { + return fmt.Errorf("failed to get app info for build validation: %w", err) + } + + // Validate buildpacks against app generation + return validateBuildpacksForGeneration(app.Generation.Name) +} + +// validateBuildpacksForGeneration validates buildpacks against a given generation +func validateBuildpacksForGeneration(generationName string) error { + appGeneration := generationName + if appGeneration == "" { + appGeneration = "cedar" // Default to cedar if generation is not specified + } + + if !IsFeatureSupported(appGeneration, "app", "buildpacks") { + return fmt.Errorf("buildpacks cannot be specified for %s generation apps. Use project.toml to configure Cloud Native Buildpacks instead. See: https://devcenter.heroku.com/articles/using-multiple-buildpacks-for-an-app", appGeneration) + } + + return nil +} + func uploadSource(filePath, httpMethod, httpUrl string) error { method := strings.ToUpper(httpMethod) log.Printf("[DEBUG] Uploading source '%s' to %s %s", filePath, method, httpUrl) diff --git a/heroku/resource_heroku_build_test.go b/heroku/resource_heroku_build_test.go index 2aa32dbc..8903a428 100644 --- a/heroku/resource_heroku_build_test.go +++ b/heroku/resource_heroku_build_test.go @@ -471,3 +471,114 @@ func resetSourceFiles() error { } return nil } + +// TestHerokuBuildGeneration tests the generation validation logic for build resources +func TestHerokuBuildGeneration(t *testing.T) { + testCases := []struct { + name string + generation string + resourceType string + feature string + expectedSupported bool + }{ + { + name: "Cedar apps should support buildpacks", + generation: "cedar", + resourceType: "app", + feature: "buildpacks", + expectedSupported: true, + }, + { + name: "Fir apps should not support buildpacks", + generation: "fir", + resourceType: "app", + feature: "buildpacks", + expectedSupported: false, + }, + { + name: "Cedar apps should not support cloud_native_buildpacks", + generation: "cedar", + resourceType: "app", + feature: "cloud_native_buildpacks", + expectedSupported: false, + }, + { + name: "Fir apps should support cloud_native_buildpacks", + generation: "fir", + resourceType: "app", + feature: "cloud_native_buildpacks", + expectedSupported: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := IsFeatureSupported(tc.generation, tc.resourceType, tc.feature) + if result != tc.expectedSupported { + t.Errorf("Expected IsFeatureSupported(%s, %s, %s) = %v, got %v", + tc.generation, tc.resourceType, tc.feature, tc.expectedSupported, result) + } else { + t.Logf("✅ Generation: %s, Feature: %s, Supported: %v", tc.generation, tc.feature, result) + } + }) + } +} + +// testStep_AccHerokuBuild_Generation_FirValid tests that Fir builds work without buildpacks +func testStep_AccHerokuBuild_Generation_FirValid(spaceConfig, spaceName string) resource.TestStep { + return resource.TestStep{ + Config: fmt.Sprintf(` +%s + +resource "heroku_app" "fir_build_app_valid" { + name = "tftest-fir-bld-v-%s" + region = heroku_space.foobar.region + space = heroku_space.foobar.name + organization { + name = heroku_space.foobar.organization + } +} + +resource "heroku_build" "fir_build_valid" { + app_id = heroku_app.fir_build_app_valid.id + # No buildpacks - should work with CNB + + source { + path = "test-fixtures/app" + } +} +`, spaceConfig, acctest.RandString(6)), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("heroku_app.fir_build_app_valid", "generation", "fir"), + resource.TestCheckResourceAttr("heroku_build.fir_build_valid", "status", "succeeded"), + ), + } +} + +// testStep_AccHerokuBuild_Generation_FirInvalid tests that Fir builds fail with buildpacks +func testStep_AccHerokuBuild_Generation_FirInvalid(spaceConfig, spaceName string) resource.TestStep { + return resource.TestStep{ + Config: fmt.Sprintf(` +%s + +resource "heroku_app" "fir_build_app_invalid" { + name = "tftest-fir-bld-i-%s" + region = heroku_space.foobar.region + space = heroku_space.foobar.name + organization { + name = heroku_space.foobar.organization + } +} + +resource "heroku_build" "fir_build_invalid" { + app_id = heroku_app.fir_build_app_invalid.id + buildpacks = ["heroku/nodejs"] # Should fail + + source { + path = "test-fixtures/app" + } +} +`, spaceConfig, acctest.RandString(6)), + ExpectError: regexp.MustCompile("buildpacks cannot be specified for fir generation apps"), + } +} diff --git a/heroku/resource_heroku_space_test.go b/heroku/resource_heroku_space_test.go index dc6bba2b..9c0008d2 100644 --- a/heroku/resource_heroku_space_test.go +++ b/heroku/resource_heroku_space_test.go @@ -86,6 +86,10 @@ func TestAccHerokuSpace_Fir(t *testing.T) { }, // Step 2: Test Fir app generation behavior testStep_AccHerokuApp_Generation_Fir(t, spaceConfig, spaceName), + // Step 3: Test Fir build generation behavior (valid build) + testStep_AccHerokuBuild_Generation_FirValid(spaceConfig, spaceName), + // Step 4: Test Fir build generation behavior (invalid build with buildpacks) + testStep_AccHerokuBuild_Generation_FirInvalid(spaceConfig, spaceName), }, }) }