Skip to content

Commit 2c19b4f

Browse files
committed
Add Fir generation support to heroku_app_release
1 parent 867294d commit 2c19b4f

File tree

4 files changed

+324
-16
lines changed

4 files changed

+324
-16
lines changed

docs/resources/app_release.md

Lines changed: 100 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,68 @@ resource.
1313

1414
An app release represents a combination of code, config vars and add-ons for an app on Heroku.
1515

16+
## Generation Compatibility
17+
18+
This resource supports both Cedar and Fir generation apps with different artifact types:
19+
20+
* **Cedar generation apps**: Use `slug_id` (traditional slugs)
21+
* **Fir generation apps**: Use `oci_image` (OCI container images)
22+
1623
~> **NOTE:**
17-
This resource requires the slug be uploaded to Heroku using [`heroku_slug`](slug.html)
18-
or with external tooling prior to running terraform.
24+
For Cedar apps, the slug must be uploaded to Heroku using [`heroku_slug`](slug.html) or external tooling.
25+
For Fir apps, the OCI image is typically created by [`heroku_build`](build.html) and can be promoted between apps.
1926

2027
## Example Usage
21-
```hcl-terraform
22-
resource "heroku_app" "foobar" {
23-
name = "foobar"
24-
region = "us"
28+
29+
### Cedar Generation (Traditional Slugs)
30+
31+
```hcl
32+
resource "heroku_app" "cedar_app" {
33+
name = "my-cedar-app"
34+
region = "us"
35+
}
36+
37+
# Upload your slug using heroku_slug or external tooling
38+
39+
resource "heroku_app_release" "cedar_release" {
40+
app_id = heroku_app.cedar_app.id
41+
slug_id = "01234567-89ab-cdef-0123-456789abcdef"
42+
description = "Deploy version 1.2.3"
43+
}
44+
```
45+
46+
### Fir Generation (OCI Images)
47+
48+
```hcl
49+
resource "heroku_space" "fir_space" {
50+
name = "my-fir-space"
51+
organization = "my-org"
52+
region = "virginia"
53+
generation = "fir"
2554
}
2655
27-
# Upload your slug
56+
resource "heroku_app" "fir_app" {
57+
name = "my-fir-app"
58+
region = heroku_space.fir_space.region
59+
space = heroku_space.fir_space.name
60+
organization {
61+
name = heroku_space.fir_space.organization
62+
}
63+
}
64+
65+
# Build creates an OCI image in Fir generation
66+
resource "heroku_build" "fir_build" {
67+
app_id = heroku_app.fir_app.id
68+
source {
69+
path = "."
70+
}
71+
}
2872
29-
resource "heroku_app_release" "foobar-release" {
30-
app_id = heroku_app.foobar.id
31-
slug_id = "01234567-89ab-cdef-0123-456789abcdef"
73+
# Promote the OCI image to another app or environment
74+
resource "heroku_app_release" "fir_release" {
75+
app_id = heroku_app.fir_app.id
76+
oci_image = "7f668938-7999-48a7-ad28-c24cbd46c51b" # From build or promotion
77+
description = "Deploy version 1.2.3 with CNB"
3278
}
3379
```
3480

@@ -37,16 +83,58 @@ resource "heroku_app_release" "foobar-release" {
3783
The following arguments are supported:
3884

3985
* `app_id` - (Required) Heroku app ID (do not use app name)
40-
* `slug_id` - unique identifier of slug
41-
* `description` - description of changes in this release
86+
* `slug_id` - (Optional) Unique identifier of slug. Required for Cedar generation apps. Conflicts with `oci_image`.
87+
* `oci_image` - (Optional) OCI image identifier (UUID or SHA256 digest). Required for Fir generation apps. Conflicts with `slug_id`.
88+
* `description` - (Optional) Description of changes in this release
89+
90+
### Artifact Type Requirements
91+
92+
Exactly one artifact type must be specified based on your app's generation:
93+
94+
| Generation | Required Field | Field Type | Example |
95+
|------------|----------------|------------|---------|
96+
| Cedar | `slug_id` | UUID | `"01234567-89ab-cdef-0123-456789abcdef"` |
97+
| Fir | `oci_image` | UUID or SHA256 | `"7f668938-7999-48a7-ad28-c24cbd46c51b"` or `"sha256:abc123..."` |
4298

4399
## Attributes Reference
44100

45101
The following attributes are exported:
46102

47103
* `id` - The ID of the app release
104+
* `slug_id` - The slug ID (for Cedar generation apps)
105+
* `oci_image` - The OCI image identifier (for Fir generation apps)
106+
107+
## Validation
108+
109+
The provider automatically validates that the correct artifact type is used for your app's generation:
110+
111+
* **Cedar apps**: Must use `slug_id`, cannot use `oci_image`
112+
* **Fir apps**: Must use `oci_image`, cannot use `slug_id`
113+
114+
If you specify the wrong artifact type, you'll receive a clear error message during `terraform plan`.
115+
116+
## OCI Image Formats
117+
118+
The `oci_image` field accepts multiple formats:
119+
120+
* **UUID format**: `"7f668938-7999-48a7-ad28-c24cbd46c51b"` (most common)
121+
* **SHA256 with prefix**: `"sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"`
122+
* **Bare SHA256**: `"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"`
123+
124+
## Best Practices
125+
126+
### Single App Release Deployment
127+
128+
Use `heroku_app_release` to deploy pre-built artifacts (slugs or OCI images) to individual apps:
129+
130+
1. **Pre-built artifacts**: Deploy slugs (Cedar) or OCI images (Fir) that were created elsewhere
131+
2. **Immutable deployments**: Use specific artifact IDs for consistent, repeatable deployments
132+
3. **Release management**: Track deployments with descriptive release descriptions
133+
134+
> **Note**: For promoting artifacts across multiple apps or environments, consider using Heroku Pipelines for more advanced workflow management.
48135
49136
## Import
137+
50138
The most recent app release can be imported using the application name.
51139

52140
For example:

heroku/resource_heroku_app_release.go

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,20 @@ func resourceHerokuAppRelease() *schema.Resource {
3333
},
3434

3535
"slug_id": { // An existing Heroku release cannot be updated so ForceNew is required
36-
Type: schema.TypeString,
37-
Required: true,
38-
ForceNew: true,
36+
Type: schema.TypeString,
37+
Optional: true,
38+
ForceNew: true,
39+
ConflictsWith: []string{"oci_image"},
40+
AtLeastOneOf: []string{"slug_id", "oci_image"},
41+
},
42+
43+
"oci_image": { // OCI image identifier for Fir generation apps
44+
Type: schema.TypeString,
45+
Optional: true,
46+
ForceNew: true,
47+
ConflictsWith: []string{"slug_id"},
48+
AtLeastOneOf: []string{"slug_id", "oci_image"},
49+
ValidateFunc: validateOCIImage,
3950
},
4051

4152
"description": {
@@ -58,6 +69,11 @@ func resourceHerokuAppRelease() *schema.Resource {
5869
func resourceHerokuAppReleaseCreate(d *schema.ResourceData, meta interface{}) error {
5970
client := meta.(*Config).Api
6071

72+
// Validate artifact type for app generation at apply time
73+
if err := validateArtifactForApp(client, d); err != nil {
74+
return err
75+
}
76+
6177
opts := heroku.ReleaseCreateOpts{}
6278

6379
appName := getAppId(d)
@@ -68,6 +84,12 @@ func resourceHerokuAppReleaseCreate(d *schema.ResourceData, meta interface{}) er
6884
opts.Slug = vs
6985
}
7086

87+
if v, ok := d.GetOk("oci_image"); ok {
88+
vs := v.(string)
89+
log.Printf("[DEBUG] OCI Image: %s", vs)
90+
opts.OciImage = &vs
91+
}
92+
7193
if v, ok := d.GetOk("description"); ok {
7294
vs := v.(string)
7395
log.Printf("[DEBUG] description: %s", vs)
@@ -101,6 +123,47 @@ func resourceHerokuAppReleaseCreate(d *schema.ResourceData, meta interface{}) er
101123
return resourceHerokuAppReleaseRead(d, meta)
102124
}
103125

126+
// validateArtifactForGeneration validates artifact type compatibility with a specific generation
127+
func validateArtifactForGeneration(generationName string, hasSlug bool, hasOci bool) error {
128+
switch generationName {
129+
case "cedar":
130+
if hasOci {
131+
return fmt.Errorf("cedar generation apps must use slug_id, not oci_image")
132+
}
133+
if !hasSlug {
134+
return fmt.Errorf("cedar generation apps require slug_id")
135+
}
136+
case "fir":
137+
if hasSlug {
138+
return fmt.Errorf("fir generation apps must use oci_image, not slug_id")
139+
}
140+
if !hasOci {
141+
return fmt.Errorf("fir generation apps require oci_image")
142+
}
143+
default:
144+
// Unknown generation - let the API handle it
145+
return nil
146+
}
147+
148+
return nil
149+
}
150+
151+
// validateArtifactForApp validates artifact type for the target app at apply time
152+
func validateArtifactForApp(client *heroku.Service, d *schema.ResourceData) error {
153+
hasSlug := d.Get("slug_id").(string) != ""
154+
hasOci := d.Get("oci_image").(string) != ""
155+
appID := d.Get("app_id").(string)
156+
157+
// Fetch app info to determine its generation
158+
app, err := client.AppInfo(context.TODO(), appID)
159+
if err != nil {
160+
return fmt.Errorf("error fetching app info: %s", err)
161+
}
162+
163+
// Validate artifact type against app generation
164+
return validateArtifactForGeneration(app.Generation.Name, hasSlug, hasOci)
165+
}
166+
104167
func resourceHerokuAppReleaseRead(d *schema.ResourceData, meta interface{}) error {
105168
client := meta.(*Config).Api
106169

@@ -113,7 +176,20 @@ func resourceHerokuAppReleaseRead(d *schema.ResourceData, meta interface{}) erro
113176
}
114177

115178
d.Set("app_id", appRelease.App.ID)
116-
d.Set("slug_id", appRelease.Slug.ID)
179+
180+
// Handle Cedar releases (with slugs)
181+
if appRelease.Slug != nil {
182+
d.Set("slug_id", appRelease.Slug.ID)
183+
}
184+
185+
// Handle Fir releases (with OCI images)
186+
for _, artifact := range appRelease.Artifacts {
187+
if artifact.Type == "oci-image" {
188+
d.Set("oci_image", artifact.ID)
189+
break
190+
}
191+
}
192+
117193
d.Set("description", appRelease.Description)
118194

119195
return nil

heroku/validators.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package heroku
22

33
import (
44
"fmt"
5+
"regexp"
56

67
"github.com/google/uuid"
78
)
@@ -17,3 +18,31 @@ func validateUUID(val interface{}, key string) ([]string, []error) {
1718
}
1819
return nil, nil
1920
}
21+
22+
// validateOCIImage validates that a string is either a valid UUID or SHA256 digest
23+
// OCI images can be referenced by UUID (Heroku internal) or SHA256 digest
24+
func validateOCIImage(val interface{}, key string) ([]string, []error) {
25+
s, ok := val.(string)
26+
if !ok {
27+
return nil, []error{fmt.Errorf("%q is an invalid OCI image identifier: unable to assert %q to string", key, val)}
28+
}
29+
30+
// Try UUID first (most common for Heroku internal images)
31+
if _, err := uuid.Parse(s); err == nil {
32+
return nil, nil
33+
}
34+
35+
// Try SHA256 digest format (sha256:hexstring)
36+
sha256Pattern := regexp.MustCompile(`^sha256:[a-fA-F0-9]{64}$`)
37+
if sha256Pattern.MatchString(s) {
38+
return nil, nil
39+
}
40+
41+
// Also accept bare SHA256 hex (64 chars)
42+
bareHexPattern := regexp.MustCompile(`^[a-fA-F0-9]{64}$`)
43+
if bareHexPattern.MatchString(s) {
44+
return nil, nil
45+
}
46+
47+
return nil, []error{fmt.Errorf("%q is an invalid OCI image identifier: must be a UUID or SHA256 digest (sha256:hex or bare hex)", key)}
48+
}

0 commit comments

Comments
 (0)