Skip to content

Commit 53f753a

Browse files
committed
Add heroku_pipeline_promotion resource
1 parent 7669514 commit 53f753a

File tree

4 files changed

+315
-0
lines changed

4 files changed

+315
-0
lines changed
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
---
2+
layout: "heroku"
3+
page_title: "Heroku: heroku_pipeline_promotion"
4+
sidebar_current: "docs-heroku-resource-pipeline-promotion"
5+
description: |-
6+
Provides a Heroku Pipeline Promotion resource.
7+
---
8+
9+
# heroku\_pipeline\_promotion
10+
11+
Provides a [Heroku Pipeline Promotion](https://devcenter.heroku.com/articles/pipelines)
12+
resource.
13+
14+
A pipeline promotion allows you to deploy releases from one app to other apps within the same
15+
pipeline. This enables infrastructure-as-code workflow for promoting code between pipeline stages
16+
such as staging to production.
17+
18+
Currently promotes the latest release from the source app. Support for promoting specific releases
19+
(`release_id` parameter) requires additional API support from the Heroku platform team.
20+
21+
## Example Usage
22+
23+
```hcl
24+
# Basic promotion from staging to production
25+
resource "heroku_pipeline_promotion" "staging_to_prod" {
26+
pipeline = heroku_pipeline.my_app.id
27+
source_app_id = heroku_app.staging.id
28+
targets = [heroku_app.production.id]
29+
}
30+
31+
# Promotion to multiple target apps
32+
resource "heroku_pipeline_promotion" "staging_to_multiple" {
33+
pipeline = heroku_pipeline.my_app.id
34+
source_app_id = heroku_app.staging.id
35+
targets = [
36+
heroku_app.production.id,
37+
heroku_app.demo.id
38+
]
39+
}
40+
```
41+
42+
## Argument Reference
43+
44+
The following arguments are supported:
45+
46+
* `pipeline` - (Required) The UUID of the pipeline containing the apps.
47+
* `source_app_id` - (Required) The UUID of the source app to promote from.
48+
* `targets` - (Required) Set of UUIDs of target apps to promote to.
49+
* `release_id` - (Optional) **Not yet supported**. The UUID of a specific release to promote.
50+
Currently returns an error as this requires additional Heroku platform API support.
51+
52+
## Attributes Reference
53+
54+
The following attributes are exported:
55+
56+
* `id` - The UUID of this pipeline promotion.
57+
* `status` - The status of the promotion (`pending`, `completed`).
58+
* `created_at` - When the promotion was created.
59+
* `promoted_release_id` - The UUID of the release that was actually promoted.
60+
61+
## Notes
62+
63+
* Pipeline promotions are immutable - they cannot be updated or modified after creation.
64+
* All apps (source and targets) must be in the same pipeline.
65+
* All apps must have the same generation (Cedar or Fir). See [`heroku_pipeline`](./pipeline.html) for generation compatibility requirements.
66+
* The source app must have at least one release to promote.
67+
* Promotions copy the latest release from the source app to all target apps.
68+
69+
## Future Enhancement
70+
71+
The `release_id` parameter will be supported once the Heroku platform team adds the necessary API
72+
functionality. This will enable promoting specific releases rather than just the latest release.

heroku/provider.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ func Provider() *schema.Provider {
119119
"heroku_pipeline": resourceHerokuPipeline(),
120120
"heroku_pipeline_config_var": resourceHerokuPipelineConfigVar(),
121121
"heroku_pipeline_coupling": resourceHerokuPipelineCoupling(),
122+
"heroku_pipeline_promotion": resourceHerokuPipelinePromotion(),
122123
"heroku_review_app_config": resourceHerokuReviewAppConfig(),
123124
"heroku_slug": resourceHerokuSlug(),
124125
"heroku_space": resourceHerokuSpace(),
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Pipeline Promotion Resource
2+
//
3+
// This resource allows promoting releases between apps in a Heroku Pipeline.
4+
// Currently promotes the latest release from the source app to target apps.
5+
//
6+
// DEPENDENCY: The 'release_id' field requires Flow team to add Promotion#release_id
7+
// API support. Until then, only latest release promotion is supported.
8+
package heroku
9+
10+
import (
11+
"context"
12+
"fmt"
13+
"log"
14+
15+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
16+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
17+
heroku "github.com/heroku/heroku-go/v6"
18+
)
19+
20+
func resourceHerokuPipelinePromotion() *schema.Resource {
21+
return &schema.Resource{
22+
Create: resourceHerokuPipelinePromotionCreate,
23+
Read: resourceHerokuPipelinePromotionRead,
24+
Delete: resourceHerokuPipelinePromotionDelete,
25+
26+
Schema: map[string]*schema.Schema{
27+
"pipeline": {
28+
Type: schema.TypeString,
29+
Required: true,
30+
ForceNew: true,
31+
ValidateFunc: validation.IsUUID,
32+
Description: "Pipeline ID for the promotion",
33+
},
34+
35+
"source_app_id": {
36+
Type: schema.TypeString,
37+
Required: true,
38+
ForceNew: true,
39+
ValidateFunc: validation.IsUUID,
40+
Description: "Source app ID to promote from",
41+
},
42+
43+
"release_id": {
44+
Type: schema.TypeString,
45+
Optional: true,
46+
ForceNew: true,
47+
ValidateFunc: validation.IsUUID,
48+
Description: "Specific release ID to promote (requires Flow team API update)",
49+
},
50+
51+
"targets": {
52+
Type: schema.TypeSet,
53+
Required: true,
54+
ForceNew: true,
55+
Description: "Set of target app IDs to promote to",
56+
Elem: &schema.Schema{
57+
Type: schema.TypeString,
58+
ValidateFunc: validation.IsUUID,
59+
},
60+
},
61+
62+
// Computed fields
63+
"status": {
64+
Type: schema.TypeString,
65+
Computed: true,
66+
Description: "Status of the promotion (pending, completed)",
67+
},
68+
69+
"created_at": {
70+
Type: schema.TypeString,
71+
Computed: true,
72+
Description: "When the promotion was created",
73+
},
74+
75+
"promoted_release_id": {
76+
Type: schema.TypeString,
77+
Computed: true,
78+
Description: "ID of the release that was actually promoted",
79+
},
80+
},
81+
}
82+
}
83+
84+
func resourceHerokuPipelinePromotionCreate(d *schema.ResourceData, meta interface{}) error {
85+
client := meta.(*Config).Api
86+
87+
log.Printf("[DEBUG] Creating pipeline promotion")
88+
89+
// Check if release_id is specified - this requires Flow team API support
90+
if releaseID, ok := d.GetOk("release_id"); ok {
91+
return fmt.Errorf("release_id parameter (%s) is not yet supported - waiting for Flow team to add Promotion#release_id API support", releaseID.(string))
92+
}
93+
94+
// Build promotion options using current API
95+
pipelineID := d.Get("pipeline").(string)
96+
sourceAppID := d.Get("source_app_id").(string)
97+
targets := d.Get("targets").(*schema.Set)
98+
99+
opts := heroku.PipelinePromotionCreateOpts{}
100+
opts.Pipeline.ID = pipelineID
101+
opts.Source.App = &struct {
102+
ID *string `json:"id,omitempty" url:"id,omitempty,key"`
103+
}{ID: &sourceAppID}
104+
105+
// Convert targets set to slice
106+
for _, target := range targets.List() {
107+
targetAppID := target.(string)
108+
targetApp := &struct {
109+
ID *string `json:"id,omitempty" url:"id,omitempty,key"`
110+
}{ID: &targetAppID}
111+
112+
opts.Targets = append(opts.Targets, struct {
113+
App *struct {
114+
ID *string `json:"id,omitempty" url:"id,omitempty,key"`
115+
} `json:"app,omitempty" url:"app,omitempty,key"`
116+
}{App: targetApp})
117+
}
118+
119+
log.Printf("[DEBUG] Pipeline promotion create configuration: %#v", opts)
120+
121+
promotion, err := client.PipelinePromotionCreate(context.TODO(), opts)
122+
if err != nil {
123+
return fmt.Errorf("error creating pipeline promotion: %s", err)
124+
}
125+
126+
log.Printf("[INFO] Created pipeline promotion ID: %s", promotion.ID)
127+
d.SetId(promotion.ID)
128+
129+
return resourceHerokuPipelinePromotionRead(d, meta)
130+
}
131+
132+
func resourceHerokuPipelinePromotionRead(d *schema.ResourceData, meta interface{}) error {
133+
client := meta.(*Config).Api
134+
135+
log.Printf("[DEBUG] Reading pipeline promotion: %s", d.Id())
136+
137+
promotion, err := client.PipelinePromotionInfo(context.TODO(), d.Id())
138+
if err != nil {
139+
return fmt.Errorf("error retrieving pipeline promotion: %s", err)
140+
}
141+
142+
// Set computed fields
143+
d.Set("status", promotion.Status)
144+
d.Set("created_at", promotion.CreatedAt.String())
145+
146+
// Set the release that was actually promoted
147+
if promotion.Source.Release.ID != "" {
148+
d.Set("promoted_release_id", promotion.Source.Release.ID)
149+
}
150+
151+
// Set configuration from API response
152+
d.Set("pipeline", promotion.Pipeline.ID)
153+
d.Set("source_app_id", promotion.Source.App.ID)
154+
155+
log.Printf("[DEBUG] Pipeline promotion read completed for: %s", d.Id())
156+
return nil
157+
}
158+
159+
func resourceHerokuPipelinePromotionDelete(d *schema.ResourceData, meta interface{}) error {
160+
log.Printf("[INFO] There is no DELETE for pipeline promotion resource so this is a no-op. Promotion will be removed from state.")
161+
return nil
162+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package heroku
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
8+
)
9+
10+
func TestResourceHerokuPipelinePromotion_Schema(t *testing.T) {
11+
resource := resourceHerokuPipelinePromotion()
12+
13+
// Test required fields
14+
requiredFields := []string{"pipeline", "source_app_id", "targets"}
15+
for _, field := range requiredFields {
16+
if _, ok := resource.Schema[field]; !ok {
17+
t.Errorf("Required field %s not found in schema", field)
18+
}
19+
if !resource.Schema[field].Required {
20+
t.Errorf("Field %s should be required", field)
21+
}
22+
if !resource.Schema[field].ForceNew {
23+
t.Errorf("Field %s should be ForceNew", field)
24+
}
25+
}
26+
27+
// Test optional fields
28+
optionalFields := []string{"release_id"}
29+
for _, field := range optionalFields {
30+
if _, ok := resource.Schema[field]; !ok {
31+
t.Errorf("Optional field %s not found in schema", field)
32+
}
33+
if !resource.Schema[field].Optional {
34+
t.Errorf("Field %s should be optional", field)
35+
}
36+
if !resource.Schema[field].ForceNew {
37+
t.Errorf("Field %s should be ForceNew", field)
38+
}
39+
}
40+
41+
// Test computed fields
42+
computedFields := []string{"status", "created_at", "promoted_release_id"}
43+
for _, field := range computedFields {
44+
if _, ok := resource.Schema[field]; !ok {
45+
t.Errorf("Computed field %s not found in schema", field)
46+
}
47+
if !resource.Schema[field].Computed {
48+
t.Errorf("Field %s should be computed", field)
49+
}
50+
}
51+
52+
// Test targets field is a Set
53+
if resource.Schema["targets"].Type != schema.TypeSet {
54+
t.Errorf("targets field should be TypeSet")
55+
}
56+
}
57+
58+
func TestResourceHerokuPipelinePromotion_ReleaseIdValidation(t *testing.T) {
59+
// This test validates that release_id parameter currently returns an error
60+
// Once Flow team adds API support, this test should be updated
61+
62+
d := schema.TestResourceDataRaw(t, resourceHerokuPipelinePromotion().Schema, map[string]interface{}{
63+
"pipeline": "01234567-89ab-cdef-0123-456789abcdef",
64+
"source_app_id": "01234567-89ab-cdef-0123-456789abcdef",
65+
"release_id": "01234567-89ab-cdef-0123-456789abcdef",
66+
"targets": []interface{}{"01234567-89ab-cdef-0123-456789abcdef"},
67+
})
68+
69+
meta := &Config{} // Mock config
70+
71+
err := resourceHerokuPipelinePromotionCreate(d, meta)
72+
if err == nil {
73+
t.Error("Expected error when release_id is provided, but got none")
74+
}
75+
76+
expectedError := "release_id parameter"
77+
if err != nil && !strings.Contains(err.Error(), expectedError) {
78+
t.Errorf("Expected error to contain '%s', got: %s", expectedError, err.Error())
79+
}
80+
}

0 commit comments

Comments
 (0)