diff --git a/builtin/providers/heroku/provider.go b/builtin/providers/heroku/provider.go index 08432ac98b..fec57ca595 100644 --- a/builtin/providers/heroku/provider.go +++ b/builtin/providers/heroku/provider.go @@ -27,13 +27,15 @@ func Provider() terraform.ResourceProvider { }, ResourcesMap: map[string]*schema.Resource{ - "heroku_addon": resourceHerokuAddon(), - "heroku_app": resourceHerokuApp(), - "heroku_app_feature": resourceHerokuAppFeature(), - "heroku_cert": resourceHerokuCert(), - "heroku_domain": resourceHerokuDomain(), - "heroku_drain": resourceHerokuDrain(), - "heroku_space": resourceHerokuSpace(), + "heroku_addon": resourceHerokuAddon(), + "heroku_app": resourceHerokuApp(), + "heroku_app_feature": resourceHerokuAppFeature(), + "heroku_cert": resourceHerokuCert(), + "heroku_domain": resourceHerokuDomain(), + "heroku_drain": resourceHerokuDrain(), + "heroku_pipeline": resourceHerokuPipeline(), + "heroku_pipeline_coupling": resourceHerokuPipelineCoupling(), + "heroku_space": resourceHerokuSpace(), }, ConfigureFunc: providerConfigure, diff --git a/builtin/providers/heroku/resource_heroku_pipeline.go b/builtin/providers/heroku/resource_heroku_pipeline.go new file mode 100644 index 0000000000..5aedf33de3 --- /dev/null +++ b/builtin/providers/heroku/resource_heroku_pipeline.go @@ -0,0 +1,92 @@ +package heroku + +import ( + "context" + "fmt" + "log" + + "github.com/cyberdelia/heroku-go/v3" + "github.com/hashicorp/terraform/helper/schema" +) + +func resourceHerokuPipeline() *schema.Resource { + return &schema.Resource{ + Create: resourceHerokuPipelineCreate, + Update: resourceHerokuPipelineUpdate, + Read: resourceHerokuPipelineRead, + Delete: resourceHerokuPipelineDelete, + + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + }, + }, + } +} + +func resourceHerokuPipelineCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*heroku.Service) + + opts := heroku.PipelineCreateOpts{ + Name: d.Get("name").(string), + } + + log.Printf("[DEBUG] Pipeline create configuration: %#v", opts) + + p, err := client.PipelineCreate(context.TODO(), opts) + if err != nil { + return fmt.Errorf("Error creating pipeline: %s", err) + } + + d.SetId(p.ID) + d.Set("name", p.Name) + + log.Printf("[INFO] Pipeline ID: %s", d.Id()) + + return resourceHerokuPipelineUpdate(d, meta) +} + +func resourceHerokuPipelineUpdate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*heroku.Service) + + if d.HasChange("name") { + name := d.Get("name").(string) + opts := heroku.PipelineUpdateOpts{ + Name: &name, + } + + _, err := client.PipelineUpdate(context.TODO(), d.Id(), opts) + if err != nil { + return err + } + } + + return resourceHerokuPipelineRead(d, meta) +} + +func resourceHerokuPipelineDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*heroku.Service) + + log.Printf("[INFO] Deleting pipeline: %s", d.Id()) + + _, err := client.PipelineDelete(context.TODO(), d.Id()) + if err != nil { + return fmt.Errorf("Error deleting pipeline: %s", err) + } + + return nil +} + +func resourceHerokuPipelineRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*heroku.Service) + + p, err := client.PipelineInfo(context.TODO(), d.Id()) + if err != nil { + return fmt.Errorf("Error retrieving pipeline: %s", err) + } + + d.Set("name", p.Name) + + return nil +} diff --git a/builtin/providers/heroku/resource_heroku_pipeline_coupling.go b/builtin/providers/heroku/resource_heroku_pipeline_coupling.go new file mode 100644 index 0000000000..90b70447a1 --- /dev/null +++ b/builtin/providers/heroku/resource_heroku_pipeline_coupling.go @@ -0,0 +1,89 @@ +package heroku + +import ( + "context" + "fmt" + "log" + + "github.com/cyberdelia/heroku-go/v3" + "github.com/hashicorp/terraform/helper/schema" +) + +func resourceHerokuPipelineCoupling() *schema.Resource { + return &schema.Resource{ + Create: resourceHerokuPipelineCouplingCreate, + Read: resourceHerokuPipelineCouplingRead, + Delete: resourceHerokuPipelineCouplingDelete, + + Schema: map[string]*schema.Schema{ + "app": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "pipeline": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validateUUID, + }, + "stage": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validatePipelineStageName, + }, + }, + } +} + +func resourceHerokuPipelineCouplingCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*heroku.Service) + + opts := heroku.PipelineCouplingCreateOpts{ + App: d.Get("app").(string), + Pipeline: d.Get("pipeline").(string), + Stage: d.Get("stage").(string), + } + + log.Printf("[DEBUG] PipelineCoupling create configuration: %#v", opts) + + p, err := client.PipelineCouplingCreate(context.TODO(), opts) + if err != nil { + return fmt.Errorf("Error creating pipeline: %s", err) + } + + d.SetId(p.ID) + + log.Printf("[INFO] PipelineCoupling ID: %s", d.Id()) + + return resourceHerokuPipelineCouplingRead(d, meta) +} + +func resourceHerokuPipelineCouplingDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*heroku.Service) + + log.Printf("[INFO] Deleting pipeline: %s", d.Id()) + + _, err := client.PipelineCouplingDelete(context.TODO(), d.Id()) + if err != nil { + return fmt.Errorf("Error deleting pipeline: %s", err) + } + + return nil +} + +func resourceHerokuPipelineCouplingRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*heroku.Service) + + p, err := client.PipelineCouplingInfo(context.TODO(), d.Id()) + if err != nil { + return fmt.Errorf("Error retrieving pipeline: %s", err) + } + + d.Set("app", p.App) + d.Set("pipeline", p.Pipeline) + d.Set("stage", p.Stage) + + return nil +} diff --git a/builtin/providers/heroku/resource_heroku_pipeline_coupling_test.go b/builtin/providers/heroku/resource_heroku_pipeline_coupling_test.go new file mode 100644 index 0000000000..6fd8b51953 --- /dev/null +++ b/builtin/providers/heroku/resource_heroku_pipeline_coupling_test.go @@ -0,0 +1,123 @@ +package heroku + +import ( + "context" + "fmt" + "testing" + + heroku "github.com/cyberdelia/heroku-go/v3" + "github.com/hashicorp/terraform/helper/acctest" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" +) + +func TestAccHerokuPipelineCoupling_Basic(t *testing.T) { + var coupling heroku.PipelineCouplingInfoResult + + appName := fmt.Sprintf("tftest-%s", acctest.RandString(10)) + pipelineName := fmt.Sprintf("tftest-%s", acctest.RandString(10)) + stageName := "development" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckHerokuPipelineCouplingDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCheckHerokuPipelineCouplingConfig_basic(appName, pipelineName, stageName), + Check: resource.ComposeTestCheckFunc( + testAccCheckHerokuPipelineCouplingExists("heroku_pipeline_coupling.default", &coupling), + testAccCheckHerokuPipelineCouplingAttributes( + &coupling, + "heroku_pipeline.default", + stageName, + ), + ), + }, + }, + }) +} + +func testAccCheckHerokuPipelineCouplingConfig_basic(appName, pipelineName, stageName string) string { + return fmt.Sprintf(` +resource "heroku_app" "default" { + name = "%s" + region = "us" +} + +resource "heroku_pipeline" "default" { + name = "%s" +} + +resource "heroku_pipeline_coupling" "default" { + app = "${heroku_app.default.id}" + pipeline = "${heroku_pipeline.default.id}" + stage = "%s" +} +`, appName, pipelineName, stageName) +} + +func testAccCheckHerokuPipelineCouplingExists(n string, pipeline *heroku.PipelineCouplingInfoResult) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No coupling ID set") + } + + client := testAccProvider.Meta().(*heroku.Service) + + foundPipelineCoupling, err := client.PipelineCouplingInfo(context.TODO(), rs.Primary.ID) + if err != nil { + return err + } + + if foundPipelineCoupling.ID != rs.Primary.ID { + return fmt.Errorf("PipelineCoupling not found: %s != %s", foundPipelineCoupling.ID, rs.Primary.ID) + } + + *pipeline = *foundPipelineCoupling + + return nil + } +} + +func testAccCheckHerokuPipelineCouplingAttributes(coupling *heroku.PipelineCouplingInfoResult, pipelineResource, stageName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + pipeline, ok := s.RootModule().Resources[pipelineResource] + if !ok { + return fmt.Errorf("Pipeline not found: %s", pipelineResource) + } + + if coupling.Pipeline.ID != pipeline.Primary.ID { + return fmt.Errorf("Bad pipeline ID: %v != %v", coupling.Pipeline.ID, pipeline.Primary.ID) + } + if coupling.Stage != stageName { + return fmt.Errorf("Bad stage: %s", coupling.Stage) + } + + return nil + } +} + +func testAccCheckHerokuPipelineCouplingDestroy(s *terraform.State) error { + client := testAccProvider.Meta().(*heroku.Service) + + for _, rs := range s.RootModule().Resources { + if rs.Type != "heroku_pipeline_coupling" { + continue + } + + _, err := client.PipelineCouplingInfo(context.TODO(), rs.Primary.ID) + + if err == nil { + return fmt.Errorf("PipelineCoupling still exists") + } + } + + return nil +} diff --git a/builtin/providers/heroku/resource_heroku_pipeline_test.go b/builtin/providers/heroku/resource_heroku_pipeline_test.go new file mode 100644 index 0000000000..1c40e14037 --- /dev/null +++ b/builtin/providers/heroku/resource_heroku_pipeline_test.go @@ -0,0 +1,96 @@ +package heroku + +import ( + "context" + "fmt" + "testing" + + heroku "github.com/cyberdelia/heroku-go/v3" + "github.com/hashicorp/terraform/helper/acctest" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" +) + +func TestAccHerokuPipeline_Basic(t *testing.T) { + var pipeline heroku.PipelineInfoResult + pipelineName := fmt.Sprintf("tftest-%s", acctest.RandString(10)) + pipelineName2 := fmt.Sprintf("%s-2", pipelineName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckHerokuPipelineDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCheckHerokuPipelineConfig_basic(pipelineName), + Check: resource.ComposeTestCheckFunc( + testAccCheckHerokuPipelineExists("heroku_pipeline.foobar", &pipeline), + resource.TestCheckResourceAttr( + "heroku_pipeline.foobar", "name", pipelineName), + ), + }, + { + Config: testAccCheckHerokuPipelineConfig_basic(pipelineName2), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "heroku_pipeline.foobar", "name", pipelineName2), + ), + }, + }, + }) +} + +func testAccCheckHerokuPipelineConfig_basic(pipelineName string) string { + return fmt.Sprintf(` +resource "heroku_pipeline" "foobar" { + name = "%s" +} +`, pipelineName) +} + +func testAccCheckHerokuPipelineExists(n string, pipeline *heroku.PipelineInfoResult) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No pipeline name set") + } + + client := testAccProvider.Meta().(*heroku.Service) + + foundPipeline, err := client.PipelineInfo(context.TODO(), rs.Primary.ID) + if err != nil { + return err + } + + if foundPipeline.ID != rs.Primary.ID { + return fmt.Errorf("Pipeline not found") + } + + *pipeline = *foundPipeline + + return nil + } +} + +func testAccCheckHerokuPipelineDestroy(s *terraform.State) error { + client := testAccProvider.Meta().(*heroku.Service) + + for _, rs := range s.RootModule().Resources { + if rs.Type != "heroku_pipeline" { + continue + } + + _, err := client.PipelineInfo(context.TODO(), rs.Primary.ID) + + if err == nil { + return fmt.Errorf("Pipeline still exists") + } + } + + return nil +} diff --git a/builtin/providers/heroku/validators.go b/builtin/providers/heroku/validators.go new file mode 100644 index 0000000000..0b3702247e --- /dev/null +++ b/builtin/providers/heroku/validators.go @@ -0,0 +1,38 @@ +package heroku + +import ( + "fmt" + "strings" + + "github.com/satori/uuid" +) + +func validatePipelineStageName(v interface{}, k string) (ws []string, errors []error) { + validPipelineStageNames := []string{ + "review", + "development", + "staging", + "production", + } + + for _, s := range validPipelineStageNames { + if v == s { + return + } + } + + err := fmt.Errorf( + "%s is an invalid pipeline stage, must be one of [%s]", + v, + strings.Join(validPipelineStageNames, ", "), + ) + errors = append(errors, err) + return +} + +func validateUUID(v interface{}, k string) (ws []string, errors []error) { + if _, err := uuid.FromString(v.(string)); err != nil { + errors = append(errors, fmt.Errorf("%q is an invalid UUID: %s", k, err)) + } + return +} diff --git a/builtin/providers/heroku/validators_test.go b/builtin/providers/heroku/validators_test.go new file mode 100644 index 0000000000..6131be8bc3 --- /dev/null +++ b/builtin/providers/heroku/validators_test.go @@ -0,0 +1,53 @@ +package heroku + +import "testing" + +func TestPipelineStage(t *testing.T) { + valid := []string{ + "review", + "development", + "staging", + "production", + } + for _, v := range valid { + _, errors := validatePipelineStageName(v, "stage") + if len(errors) != 0 { + t.Fatalf("%q should be a valid stage: %q", v, errors) + } + } + + invalid := []string{ + "foobarbaz", + "another-stage", + "", + } + for _, v := range invalid { + _, errors := validatePipelineStageName(v, "stage") + if len(errors) == 0 { + t.Fatalf("%q should be an invalid stage", v) + } + } +} + +func TestValidateUUID(t *testing.T) { + valid := []string{ + "4812ccbc-2a2e-4c6c-bae4-a3d04ed51c0e", + } + for _, v := range valid { + _, errors := validateUUID(v, "id") + if len(errors) != 0 { + t.Fatalf("%q should be a valid UUID: %q", v, errors) + } + } + + invalid := []string{ + "foobarbaz", + "my-app-name", + } + for _, v := range invalid { + _, errors := validateUUID(v, "id") + if len(errors) == 0 { + t.Fatalf("%q should be an invalid UUID", v) + } + } +} diff --git a/website/source/docs/providers/heroku/r/pipeline.html.markdown b/website/source/docs/providers/heroku/r/pipeline.html.markdown new file mode 100644 index 0000000000..dcd38ab60d --- /dev/null +++ b/website/source/docs/providers/heroku/r/pipeline.html.markdown @@ -0,0 +1,62 @@ +--- +layout: "heroku" +page_title: "Heroku: heroku_pipeline_" +sidebar_current: "docs-heroku-resource-pipeline-x" +description: |- + Provides a Heroku Pipeline resource. +--- + +# heroku\_pipeline + + +Provides a [Heroku Pipeline](https://devcenter.heroku.com/articles/pipelines) +resource. + +A pipeline is a group of Heroku apps that share the same codebase. Once a +pipeline is created, and apps are added to different stages using +[`heroku_pipeline_coupling`](./pipeline_coupling.html), you can promote app +slugs to the next stage. + +## Example Usage + +```hcl +# Create Heroku apps for staging and production +resource "heroku_app" "staging" { + name = "test-app-staging" +} + +resource "heroku_app" "production" { + name = "test-app-production" +} + +# Create a Heroku pipeline +resource "heroku_pipeline" "test-app" { + name = "test-app" +} + +# Couple apps to different pipeline stages +resource "heroku_pipeline_coupling" "staging" { + app = "${heroku_app.staging.name}" + pipeline = "${heroku_pipeline.test-app.id}" + stage = "staging" +} + +resource "heroku_pipeline_coupling" "production" { + app = "${heroku_app.production.name}" + pipeline = "${heroku_pipeline.test-app.id}" + stage = "production" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `name` - (Required) The name of the pipeline. + +## Attributes Reference + +The following attributes are exported: + +* `id` - The UUID of the pipeline. +* `name` - The name of the pipeline. diff --git a/website/source/docs/providers/heroku/r/pipeline_coupling.html.markdown b/website/source/docs/providers/heroku/r/pipeline_coupling.html.markdown new file mode 100644 index 0000000000..90a5a9b508 --- /dev/null +++ b/website/source/docs/providers/heroku/r/pipeline_coupling.html.markdown @@ -0,0 +1,67 @@ +--- +layout: "heroku" +page_title: "Heroku: heroku_pipeline_coupling" +sidebar_current: "docs-heroku-resource-pipeline-coupling" +description: |- + Provides a Heroku Pipeline Coupling resource. +--- + +# heroku\_pipeline\_coupling + + +Provides a [Heroku Pipeline Coupling](https://devcenter.heroku.com/articles/pipelines) +resource. + +A pipeline is a group of Heroku apps that share the same codebase. Once a +pipeline is created using [`heroku_pipeline`](./pipeline), and apps are added +to different stages using `heroku_pipeline_coupling`, you can promote app slugs +to the downstream stages. + +## Example Usage + +```hcl +# Create Heroku apps for staging and production +resource "heroku_app" "staging" { + name = "test-app-staging" +} + +resource "heroku_app" "production" { + name = "test-app-production" +} + +# Create a Heroku pipeline +resource "heroku_pipeline" "test-app" { + name = "test-app" +} + +# Couple apps to different pipeline stages +resource "heroku_pipeline_coupling" "staging" { + app = "${heroku_app.staging.name}" + pipeline = "${heroku_pipeline.test-app.id}" + stage = "staging" +} + +resource "heroku_pipeline_coupling" "production" { + app = "${heroku_app.production.name}" + pipeline = "${heroku_pipeline.test-app.id}" + stage = "production" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `app` - (Required) The name of the app for this coupling. +* `pipeline` - (Required) The ID of the pipeline to add this app to. +* `stage` - (Required) The stage to couple this app to. Must be one of +`review`, `development`, `staging`, or `production`. + +## Attributes Reference + +The following attributes are exported: + +* `id` - The UUID of this pipeline coupling. +* `app` - The name of the application. +* `pipeline` - The UUID of the pipeline. +* `stage` - The stage for this coupling. diff --git a/website/source/layouts/heroku.erb b/website/source/layouts/heroku.erb index 6f7211c4f1..0d9d83b050 100644 --- a/website/source/layouts/heroku.erb +++ b/website/source/layouts/heroku.erb @@ -37,6 +37,14 @@ heroku_drain + > + heroku_pipeline + + + > + heroku_pipeline_coupling + + > heroku_space