Skip to content

Commit 590405b

Browse files
committed
Add Fir generation support to heroku_app_release
1 parent 867294d commit 590405b

File tree

4 files changed

+408
-20
lines changed

4 files changed

+408
-20
lines changed

docs/resources/app_release.md

Lines changed: 133 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"
2535
}
2636
27-
# Upload your slug
37+
# Upload your slug using heroku_slug or external tooling
2838
29-
resource "heroku_app_release" "foobar-release" {
30-
app_id = heroku_app.foobar.id
31-
slug_id = "01234567-89ab-cdef-0123-456789abcdef"
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"
54+
}
55+
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+
}
72+
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,91 @@ 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+
### Artifact Promotion Workflow
127+
128+
1. **Build once**: Create your build in one environment
129+
2. **Promote everywhere**: Use `heroku_app_release` to deploy the same artifact to other environments
130+
3. **Immutable artifacts**: Never modify artifacts, always create new ones for changes
131+
132+
### Cedar Example Workflow
133+
```hcl
134+
# 1. Upload slug (external process)
135+
# 2. Deploy to staging
136+
resource "heroku_app_release" "staging" {
137+
app_id = heroku_app.staging.id
138+
slug_id = var.slug_id
139+
}
140+
141+
# 3. Promote to production
142+
resource "heroku_app_release" "production" {
143+
app_id = heroku_app.production.id
144+
slug_id = var.slug_id # Same slug as staging
145+
}
146+
```
147+
148+
### Fir Example Workflow
149+
```hcl
150+
# 1. Build in development
151+
resource "heroku_build" "dev_build" {
152+
app_id = heroku_app.development.id
153+
source { path = "." }
154+
}
155+
156+
# 2. Promote to staging
157+
resource "heroku_app_release" "staging" {
158+
app_id = heroku_app.staging.id
159+
oci_image = var.oci_image_id # From dev build
160+
}
161+
162+
# 3. Promote to production
163+
resource "heroku_app_release" "production" {
164+
app_id = heroku_app.production.id
165+
oci_image = var.oci_image_id # Same OCI image
166+
}
167+
```
48168

49169
## Import
170+
50171
The most recent app release can be imported using the application name.
51172

52173
For example:

heroku/resource_heroku_app_release.go

Lines changed: 131 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@ import (
1515

1616
func resourceHerokuAppRelease() *schema.Resource {
1717
return &schema.Resource{
18-
Create: resourceHerokuAppReleaseCreate,
19-
Read: resourceHerokuAppReleaseRead,
20-
Update: resourceHerokuAppReleaseUpdate,
21-
Delete: resourceHerokuAppReleaseDelete,
18+
Create: resourceHerokuAppReleaseCreate,
19+
Read: resourceHerokuAppReleaseRead,
20+
Update: resourceHerokuAppReleaseUpdate,
21+
Delete: resourceHerokuAppReleaseDelete,
22+
CustomizeDiff: resourceHerokuAppReleaseCustomizeDiff,
2223

2324
Importer: &schema.ResourceImporter{
2425
State: resourceHerokuAppReleaseImport,
@@ -33,9 +34,18 @@ func resourceHerokuAppRelease() *schema.Resource {
3334
},
3435

3536
"slug_id": { // An existing Heroku release cannot be updated so ForceNew is required
36-
Type: schema.TypeString,
37-
Required: true,
38-
ForceNew: true,
37+
Type: schema.TypeString,
38+
Optional: true,
39+
ForceNew: true,
40+
ConflictsWith: []string{"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+
ValidateFunc: validateOCIImage,
3949
},
4050

4151
"description": {
@@ -58,6 +68,11 @@ func resourceHerokuAppRelease() *schema.Resource {
5868
func resourceHerokuAppReleaseCreate(d *schema.ResourceData, meta interface{}) error {
5969
client := meta.(*Config).Api
6070

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

6378
appName := getAppId(d)
@@ -68,6 +83,12 @@ func resourceHerokuAppReleaseCreate(d *schema.ResourceData, meta interface{}) er
6883
opts.Slug = vs
6984
}
7085

86+
if v, ok := d.GetOk("oci_image"); ok {
87+
vs := v.(string)
88+
log.Printf("[DEBUG] OCI Image: %s", vs)
89+
opts.OciImage = &vs
90+
}
91+
7192
if v, ok := d.GetOk("description"); ok {
7293
vs := v.(string)
7394
log.Printf("[DEBUG] description: %s", vs)
@@ -101,6 +122,75 @@ func resourceHerokuAppReleaseCreate(d *schema.ResourceData, meta interface{}) er
101122
return resourceHerokuAppReleaseRead(d, meta)
102123
}
103124

125+
// validateArtifactForAppGeneration validates artifact type (slug_id vs oci_image) against the target app's generation
126+
func validateArtifactForAppGeneration(ctx context.Context, diff *schema.ResourceDiff, v interface{}) error {
127+
hasSlug := diff.Get("slug_id").(string) != ""
128+
hasOci := diff.Get("oci_image").(string) != ""
129+
130+
// Get the app ID to check its generation
131+
appID := diff.Get("app_id").(string)
132+
if appID == "" {
133+
// During plan phase, the app_id might not be available yet if the app is being created
134+
// in the same configuration. Skip validation for now - it will be caught at apply time.
135+
return nil
136+
}
137+
138+
config := v.(*Config)
139+
client := config.Api
140+
141+
// Fetch app info to determine its generation
142+
app, err := client.AppInfo(ctx, appID)
143+
if err != nil {
144+
// If we can't fetch the app (it might not exist yet during plan), skip validation
145+
// It will be caught at apply time
146+
return nil
147+
}
148+
149+
// Validate artifact type against app generation
150+
return validateArtifactForGeneration(app.Generation.Name, hasSlug, hasOci)
151+
}
152+
153+
// validateArtifactForGeneration validates artifact type compatibility with a specific generation
154+
func validateArtifactForGeneration(generationName string, hasSlug bool, hasOci bool) error {
155+
switch generationName {
156+
case "cedar":
157+
if hasOci {
158+
return fmt.Errorf("cedar generation apps must use slug_id, not oci_image")
159+
}
160+
if !hasSlug {
161+
return fmt.Errorf("cedar generation apps require slug_id")
162+
}
163+
case "fir":
164+
if hasSlug {
165+
return fmt.Errorf("fir generation apps must use oci_image, not slug_id")
166+
}
167+
if !hasOci {
168+
return fmt.Errorf("fir generation apps require oci_image")
169+
}
170+
default:
171+
// Unknown generation - let the API handle it
172+
return nil
173+
}
174+
175+
return nil
176+
}
177+
178+
// validateArtifactForApp validates artifact type for the target app at apply time
179+
func validateArtifactForApp(client *heroku.Service, d *schema.ResourceData) error {
180+
hasSlug := d.Get("slug_id").(string) != ""
181+
hasOci := d.Get("oci_image").(string) != ""
182+
appID := d.Get("app_id").(string)
183+
184+
// Fetch app info to determine its generation
185+
app, err := client.AppInfo(context.TODO(), appID)
186+
if err != nil {
187+
return fmt.Errorf("error fetching app info: %s", err)
188+
}
189+
190+
// Validate artifact type against app generation
191+
return validateArtifactForGeneration(app.Generation.Name, hasSlug, hasOci)
192+
}
193+
104194
func resourceHerokuAppReleaseRead(d *schema.ResourceData, meta interface{}) error {
105195
client := meta.(*Config).Api
106196

@@ -113,7 +203,20 @@ func resourceHerokuAppReleaseRead(d *schema.ResourceData, meta interface{}) erro
113203
}
114204

115205
d.Set("app_id", appRelease.App.ID)
116-
d.Set("slug_id", appRelease.Slug.ID)
206+
207+
// Handle Cedar releases (with slugs)
208+
if appRelease.Slug != nil {
209+
d.Set("slug_id", appRelease.Slug.ID)
210+
}
211+
212+
// Handle Fir releases (with OCI images)
213+
for _, artifact := range appRelease.Artifacts {
214+
if artifact.Type == "oci-image" {
215+
d.Set("oci_image", artifact.ID)
216+
break
217+
}
218+
}
219+
117220
d.Set("description", appRelease.Description)
118221

119222
return nil
@@ -168,6 +271,26 @@ func resourceHerokuAppReleaseImport(d *schema.ResourceData, meta interface{}) ([
168271
return []*schema.ResourceData{d}, nil
169272
}
170273

274+
func resourceHerokuAppReleaseCustomizeDiff(ctx context.Context, diff *schema.ResourceDiff, v interface{}) error {
275+
hasSlug := diff.Get("slug_id").(string) != ""
276+
hasOci := diff.Get("oci_image").(string) != ""
277+
278+
// Must have exactly one artifact type
279+
if !hasSlug && !hasOci {
280+
return fmt.Errorf("either slug_id or oci_image must be provided")
281+
}
282+
if hasSlug && hasOci {
283+
return fmt.Errorf("slug_id and oci_image are mutually exclusive")
284+
}
285+
286+
// Validate artifact type compatibility with app generation during plan phase
287+
if err := validateArtifactForAppGeneration(ctx, diff, v); err != nil {
288+
return err
289+
}
290+
291+
return nil
292+
}
293+
171294
func resourceHerokuAppReleaseV0() *schema.Resource {
172295
return &schema.Resource{
173296
Schema: map[string]*schema.Schema{

0 commit comments

Comments
 (0)