From 2250397f778d2b245c59f5f941f13a9a4b6be7df Mon Sep 17 00:00:00 2001 From: Robin Schroer Date: Thu, 12 May 2022 10:39:03 +0000 Subject: [PATCH] Add a provider for scheduled pipelines This works structurally mostly like environment variables, but uses schedule IDs as internal IDs. Some hackery has been performed to make organization ID inheritance from provider settings work. It does work correctly for the most part, though local state can get a bit confused if the provider setting gets changed. Explicit organizations on schedules work just fine though. The scheduled actor ID in this is one of several magic IDs that we have at CircleCI, and I can guarantee to remain stable. CRUD operations have been verified to work off the local tree, and import of existing schedules works as well. Some provider-side validation is being performed, though it's much easier to just let the operation fail and print out the API error message, rather than duplicating all validation we perform in the API here. An example here is the project<>schedule-name uniqueness constraint, which is not checked in the provider. Similarly the requirement for either a branch or tag to be set as part of parameters, which is actually due to change soon. --- circleci/client/client.go | 17 +- circleci/client/schedule.go | 21 ++ circleci/provider.go | 1 + circleci/resource_circleci_schedule.go | 268 ++++++++++++++++++ circleci/resource_circleci_schedule_test.go | 242 ++++++++++++++++ .../circleci-cli/api/schedule.go | 6 +- 6 files changed, 548 insertions(+), 7 deletions(-) create mode 100644 circleci/client/schedule.go create mode 100644 circleci/resource_circleci_schedule.go create mode 100644 circleci/resource_circleci_schedule_test.go diff --git a/circleci/client/client.go b/circleci/client/client.go index 8e2ea41a..c5c3d2e7 100644 --- a/circleci/client/client.go +++ b/circleci/client/client.go @@ -16,6 +16,7 @@ import ( // It uses upstream client functionality where possible and defines its own methods as needed type Client struct { contexts *api.ContextRestClient + schedules *api.ScheduleRestClient rest *rest.Client vcs string organization string @@ -39,19 +40,27 @@ func New(config Config) (*Client, error) { rootURL := fmt.Sprintf("%s://%s", u.Scheme, u.Host) - contexts, err := api.NewContextRestClient(settings.Config{ + cfg := settings.Config{ Host: rootURL, RestEndpoint: u.Path, Token: config.Token, HTTPClient: http.DefaultClient, - }) + } + + contexts, err := api.NewContextRestClient(cfg) + if err != nil { + return nil, err + } + + schedules, err := api.NewScheduleRestClient(cfg) if err != nil { return nil, err } return &Client{ - rest: rest.New(rootURL, u.Path, config.Token), - contexts: contexts, + rest: rest.New(rootURL, u.Path, config.Token), + contexts: contexts, + schedules: schedules, vcs: config.VCS, organization: config.Organization, diff --git a/circleci/client/schedule.go b/circleci/client/schedule.go new file mode 100644 index 00000000..90317e60 --- /dev/null +++ b/circleci/client/schedule.go @@ -0,0 +1,21 @@ +package client + +import ( + "github.com/CircleCI-Public/circleci-cli/api" +) + +func (c *Client) GetSchedule(id string) (*api.Schedule, error) { + return c.schedules.ScheduleByID(id) +} + +func (c *Client) CreateSchedule(organization, project, name, description string, timetable api.Timetable, useSchedulingSystem bool, parameters map[string]string) (*api.Schedule, error) { + return c.schedules.CreateSchedule(c.vcs, organization, project, name, description, useSchedulingSystem, timetable, parameters) +} + +func (c *Client) DeleteSchedule(id string) error { + return c.schedules.DeleteSchedule(id) +} + +func (c *Client) UpdateSchedule(id, name, description string, timetable api.Timetable, useSchedulingActor bool, parameters map[string]string) (*api.Schedule, error) { + return c.schedules.UpdateSchedule(id, name, description, useSchedulingActor, timetable, parameters) +} diff --git a/circleci/provider.go b/circleci/provider.go index 52482355..cb11320d 100644 --- a/circleci/provider.go +++ b/circleci/provider.go @@ -39,6 +39,7 @@ func Provider() terraform.ResourceProvider { "circleci_environment_variable": resourceCircleCIEnvironmentVariable(), "circleci_context": resourceCircleCIContext(), "circleci_context_environment_variable": resourceCircleCIContextEnvironmentVariable(), + "circleci_schedule": resourceCircleCISchedule(), }, DataSourcesMap: map[string]*schema.Resource{ "circleci_context": dataSourceCircleCIContext(), diff --git a/circleci/resource_circleci_schedule.go b/circleci/resource_circleci_schedule.go new file mode 100644 index 00000000..60ee983d --- /dev/null +++ b/circleci/resource_circleci_schedule.go @@ -0,0 +1,268 @@ +package circleci + +import ( + "fmt" + "strings" + + "github.com/CircleCI-Public/circleci-cli/api" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + + client "github.com/mrolla/terraform-provider-circleci/circleci/client" +) + +// NB Magic scheduled actor ID +const scheduledActorID = "d9b3fcaa-6032-405a-8c75-40079ce33c3e" + +func resourceCircleCISchedule() *schema.Resource { + return &schema.Resource{ + Create: resourceCircleCIScheduleCreate, + Read: resourceCircleCIScheduleRead, + Delete: resourceCircleCIScheduleDelete, + Update: resourceCircleCIScheduleUpdate, + Importer: &schema.ResourceImporter{ + State: resourceCircleCIScheduleImport, + }, + Schema: map[string]*schema.Schema{ + "organization": { + Type: schema.TypeString, + Description: "The organization where the schedule will be created", + Optional: true, + ForceNew: true, + DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { + return old == d.Get("organization").(string) + }, + }, + "project": { + Type: schema.TypeString, + Description: "The name of the CircleCI project to create the schedule in", + Required: true, + ForceNew: true, + }, + "name": { + Type: schema.TypeString, + Description: "The name of the schedule", + Required: true, + }, + "description": { + Type: schema.TypeString, + Description: "The description of the schedule", + Optional: true, + }, + "per_hour": { + Type: schema.TypeInt, + Description: "How often per hour to trigger a pipeline", + Required: true, + }, + "hours_of_day": { + Type: schema.TypeList, + Elem: &schema.Schema{ + Type: schema.TypeInt, + }, + Description: "Which hours of the day to trigger a pipeline", + Required: true, + }, + "days_of_week": { + Type: schema.TypeList, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Description: "Which days of the week (\"MON\" .. \"SUN\") to trigger a pipeline on", + Required: true, + }, + "use_scheduling_system": { + Type: schema.TypeBool, + Description: "Use the scheduled system actor for attribution", + Required: true, + }, + "parameters": { + Type: schema.TypeMap, + Description: "Pipeline parameters to pass to created pipelines", + Optional: true, + }, + }, + } +} + +func resourceCircleCIScheduleCreate(d *schema.ResourceData, m interface{}) error { + c := m.(*client.Client) + + organization, err := c.Organization(d.Get("organization").(string)) + if err != nil { + return err + } + + project := d.Get("project").(string) + name := d.Get("name").(string) + description := d.Get("description").(string) + useSchedulingSystem := d.Get("use_scheduling_system").(bool) + + parsedHours := d.Get("hours_of_day").([]interface{}) + var hoursOfDay []uint + for _, hour := range parsedHours { + hoursOfDay = append(hoursOfDay, uint(hour.(int))) + } + + var exists = struct{}{} + validDays := make(map[string]interface{}) + validDays["MON"] = exists + validDays["TUE"] = exists + validDays["WED"] = exists + validDays["THU"] = exists + validDays["FRI"] = exists + validDays["SAT"] = exists + validDays["SUN"] = exists + + parsedDays := d.Get("days_of_week").([]interface{}) + var daysOfWeek []string + for _, day := range parsedDays { + if validDays[day.(string)] == nil { + return fmt.Errorf("Invalid day specified: %s", day) + } + daysOfWeek = append(daysOfWeek, day.(string)) + } + + timetable := api.Timetable{ + PerHour: uint(d.Get("per_hour").(int)), + HoursOfDay: hoursOfDay, + DaysOfWeek: daysOfWeek, + } + + parsedParams := d.Get("parameters").(map[string]interface{}) + parameters := make(map[string]string) + for k, v := range parsedParams { + parameters[k] = v.(string) + } + + schedule, err := c.CreateSchedule(organization, project, name, description, timetable, useSchedulingSystem, parameters) + if err != nil { + return fmt.Errorf("Failed to create schedule: %w", err) + } + + d.SetId(schedule.ID) + + return resourceCircleCIScheduleRead(d, m) +} + +func resourceCircleCIScheduleDelete(d *schema.ResourceData, m interface{}) error { + c := m.(*client.Client) + + if err := c.DeleteSchedule(d.Id()); err != nil { + return err + } + + d.SetId("") + + return nil +} + +func resourceCircleCIScheduleRead(d *schema.ResourceData, m interface{}) error { + c := m.(*client.Client) + id := d.Id() + + schedule, err := c.GetSchedule(id) + if err != nil { + return fmt.Errorf("Failed to read schedule: %s", id) + } + + if schedule == nil { + d.SetId("") + return nil + } + + _, organization, project, err := explodeProjectSlug(schedule.ProjectSlug) + if err != nil { + return err + } + + d.Set("organization", organization) + d.Set("project", project) + d.Set("name", schedule.Name) + d.Set("description", schedule.Description) + d.Set("per_hour", schedule.Timetable.PerHour) + d.Set("hours_of_day", schedule.Timetable.HoursOfDay) + d.Set("days_of_week", schedule.Timetable.DaysOfWeek) + d.Set("parameters", schedule.Parameters) + + if schedule.Actor.ID == scheduledActorID { + d.Set("use_scheduling_system", true) + } else { + d.Set("use_scheduling_system", false) + } + + return nil +} + +func resourceCircleCIScheduleUpdate(d *schema.ResourceData, m interface{}) error { + c := m.(*client.Client) + + id := d.Id() + name := d.Get("name").(string) + description := d.Get("description").(string) + attributionActor := d.Get("use_scheduling_system").(bool) + + parsedHours := d.Get("hours_of_day").([]interface{}) + var hoursOfDay []uint + for _, hour := range parsedHours { + hoursOfDay = append(hoursOfDay, uint(hour.(int))) + } + + var exists = struct{}{} + validDays := make(map[string]interface{}) + validDays["MON"] = exists + validDays["TUE"] = exists + validDays["WED"] = exists + validDays["THU"] = exists + validDays["FRI"] = exists + validDays["SAT"] = exists + validDays["SUN"] = exists + + parsedDays := d.Get("days_of_week").([]interface{}) + var daysOfWeek []string + for _, day := range parsedDays { + if validDays[day.(string)] == nil { + return fmt.Errorf("Invalid day specified: %s", day) + } + daysOfWeek = append(daysOfWeek, day.(string)) + } + + timetable := api.Timetable{ + PerHour: uint(d.Get("per_hour").(int)), + HoursOfDay: hoursOfDay, + DaysOfWeek: daysOfWeek, + } + + parsedParams := d.Get("parameters").(map[string]interface{}) + parameters := make(map[string]string) + for k, v := range parsedParams { + parameters[k] = v.(string) + } + + _, err := c.UpdateSchedule(id, name, description, timetable, attributionActor, parameters) + if err != nil { + return fmt.Errorf("Failed to update schedule: %w", err) + } + + return nil +} + +func resourceCircleCIScheduleImport(d *schema.ResourceData, m interface{}) ([]*schema.ResourceData, error) { + c := m.(*client.Client) + + schedule, err := c.GetSchedule(d.Id()) + if err != nil { + return nil, err + } + + d.SetId(schedule.ID) + + return []*schema.ResourceData{d}, nil +} + +func explodeProjectSlug(slug string) (string, string, string, error) { + matches := strings.Split(slug, "/") + + if len(matches) != 3 { + return "", "", "", fmt.Errorf("Splitting project-slug '%s' into vcs/org/project failed", slug) + } + return matches[0], matches[1], matches[2], nil +} diff --git a/circleci/resource_circleci_schedule_test.go b/circleci/resource_circleci_schedule_test.go new file mode 100644 index 00000000..c2b3a49c --- /dev/null +++ b/circleci/resource_circleci_schedule_test.go @@ -0,0 +1,242 @@ +package circleci + +import ( + "fmt" + "os" + "testing" + + "github.com/CircleCI-Public/circleci-cli/api" + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/terraform" + + client "github.com/mrolla/terraform-provider-circleci/circleci/client" +) + +func TestAccCircleCISchedule_basic(t *testing.T) { + schedule := &api.Schedule{} + organization := os.Getenv("TEST_CIRCLECI_ORGANIZATION") + project := os.Getenv("CIRCLECI_PROJECT") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccOrgProviders, + CheckDestroy: testAccCheckCircleCIScheduleDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCircleCISchedule_basic(organization, project, "terraform_test"), + Check: resource.ComposeTestCheckFunc( + testAccCheckCircleCIScheduleExists("circleci_schedule.terraform_test", schedule), + testAccCheckCircleCIScheduleAttributes_basic(schedule), + resource.TestCheckResourceAttr("circleci_schedule.terraform_test", "name", "terraform_test"), + ), + }, + }, + }) +} + +func testAccCheckCircleCIScheduleExists(addr string, schedule *api.Schedule) resource.TestCheckFunc { + return func(s *terraform.State) error { + c := testAccOrgProvider.Meta().(*client.Client) + + resource, ok := s.RootModule().Resources[addr] + if !ok { + return fmt.Errorf("Not found: %s", addr) + } + if resource.Primary.ID == "" { + return fmt.Errorf("No instance ID is set") + } + + ctx, err := c.GetSchedule(resource.Primary.ID) + if err != nil { + return fmt.Errorf("error getting schedule: %w", err) + } + + *schedule = *ctx + + return nil + } +} + +func testAccCheckCircleCIScheduleDestroy(s *terraform.State) error { + c := testAccOrgProvider.Meta().(*client.Client) + + for _, resource := range s.RootModule().Resources { + if resource.Type != "circleci_schedule" { + continue + } + + if resource.Primary.ID == "" { + return fmt.Errorf("No instance ID is set") + } + + _, err := c.GetSchedule(resource.Primary.ID) + if err == nil { + return fmt.Errorf("Schedule %s still exists: %w", resource.Primary.ID, err) + } + } + + return nil +} + +func TestAccCircleCISchedule_update(t *testing.T) { + organization := os.Getenv("TEST_CIRCLECI_ORGANIZATION") + project := os.Getenv("CIRCLECI_PROJECT") + schedule := &api.Schedule{} + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccOrgProviders, + CheckDestroy: testAccCheckCircleCIScheduleDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCircleCISchedule_basic(organization, project, "terraform_test"), + Check: resource.ComposeTestCheckFunc( + testAccCheckCircleCIScheduleExists("circleci_schedule.terraform_test", schedule), + testAccCheckCircleCIScheduleAttributes_basic(schedule), + resource.TestCheckResourceAttr("circleci_schedule.terraform_test", "name", "terraform_test"), + ), + }, + { + Config: testAccCircleCISchedule_update(organization, project, "updated_name"), + Check: resource.ComposeTestCheckFunc( + testAccCheckCircleCIScheduleExists("circleci_schedule.updated_name", schedule), + testAccCheckCircleCIScheduleAttributes_update(schedule), + resource.TestCheckResourceAttr("circleci_schedule.updated_name", "name", "updated_name"), + ), + }, + }, + }) +} + +func TestAccCircleCISchedule_import(t *testing.T) { + organization := os.Getenv("TEST_CIRCLECI_ORGANIZATION") + project := os.Getenv("CIRCLECI_PROJECT") + schedule := &api.Schedule{} + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccOrgProviders, + CheckDestroy: testAccCheckCircleCIScheduleDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCircleCISchedule_basic(organization, project, "terraform_test"), + Check: testAccCheckCircleCIScheduleExists("circleci_schedule.terraform_test", schedule), + }, + { + ResourceName: "circleci_schedule.terraform_test", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + return schedule.ID, nil + }, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccCheckCircleCIScheduleAttributes_basic(schedule *api.Schedule) resource.TestCheckFunc { + return func(s *terraform.State) error { + if schedule.Name != "terraform_test" { + return fmt.Errorf("Unexpected schedule name: %s", schedule.Name) + } + + if schedule.Description != "A terraform test schedule" { + return fmt.Errorf("Unexpected schedule description: %s", schedule.Description) + } + + if schedule.Actor.ID != "d9b3fcaa-6032-405a-8c75-40079ce33c3e" { + return fmt.Errorf("Unexpected schedule actor ID: %s", schedule.Actor.ID) + } + + if schedule.Timetable.PerHour != 1 { + return fmt.Errorf("Unexpected schedule per hour: %d", schedule.Timetable.PerHour) + } + + if len(schedule.Timetable.HoursOfDay) != 1 || schedule.Timetable.HoursOfDay[0] != 14 { + return fmt.Errorf("Unexpected schedule hours of day: %v", schedule.Timetable.HoursOfDay) + } + + if len(schedule.Timetable.DaysOfWeek) != 1 || schedule.Timetable.DaysOfWeek[0] != "MON" { + return fmt.Errorf("Unexpected schedule days of week: %v", schedule.Timetable.DaysOfWeek) + } + + if len(schedule.Parameters) != 2 || schedule.Parameters["foo"] != "bar" || schedule.Parameters["branch"] != "master" { + return fmt.Errorf("Unxepected schedule parameters: %v", schedule.Parameters) + } + + return nil + } +} + +func testAccCircleCISchedule_basic(organization, project, name string) string { + const template = ` +resource "circleci_schedule" "%[3]s" { + organization = "%[1]s" + project = "%[2]s" + name = "%[3]s" + description = "A terraform test schedule" + per_hour = 1 + hours_of_day = [14] + days_of_week = ["MON"] + use_scheduling_system = true + parameters = { + foo = "bar" + branch = "master" + } +} +` + return fmt.Sprintf(template, organization, project, name) +} + +func testAccCheckCircleCIScheduleAttributes_update(schedule *api.Schedule) resource.TestCheckFunc { + return func(s *terraform.State) error { + if schedule.Name != "updated_name" { + return fmt.Errorf("Unexpected schedule name: %s", schedule.Name) + } + + if schedule.Description != "An updated terraform test schedule" { + return fmt.Errorf("Unexpected schedule description: %s", schedule.Description) + } + + if schedule.Actor.ID == "d9b3fcaa-6032-405a-8c75-40079ce33c3e" { + return fmt.Errorf("Unexpected schedule actor ID: %s", schedule.Actor.ID) + } + + if schedule.Timetable.PerHour != 2 { + return fmt.Errorf("Unexpected schedule per hour: %d", schedule.Timetable.PerHour) + } + + if len(schedule.Timetable.HoursOfDay) != 1 || schedule.Timetable.HoursOfDay[0] != 19 { + return fmt.Errorf("Unexpected schedule hours of day: %v", schedule.Timetable.HoursOfDay) + } + + if len(schedule.Timetable.DaysOfWeek) != 1 || schedule.Timetable.DaysOfWeek[0] != "TUE" { + return fmt.Errorf("Unexpected schedule days of week: %v", schedule.Timetable.DaysOfWeek) + } + + if len(schedule.Parameters) != 1 || schedule.Parameters["branch"] != "main" { + return fmt.Errorf("Unxepected schedule parameters: %v", schedule.Parameters) + } + + return nil + } +} + +func testAccCircleCISchedule_update(organization, project, name string) string { + const template = ` +resource "circleci_schedule" "%[3]s" { + organization = "%[1]s" + project = "%[2]s" + name = "%[3]s" + description = "An updated terraform test schedule" + per_hour = 2 + hours_of_day = [19] + days_of_week = ["TUE"] + use_scheduling_system = false + parameters = { + branch = "main" + } +} +` + return fmt.Sprintf(template, organization, project, name) +} diff --git a/vendor/github.com/CircleCI-Public/circleci-cli/api/schedule.go b/vendor/github.com/CircleCI-Public/circleci-cli/api/schedule.go index e7704da8..9452195b 100644 --- a/vendor/github.com/CircleCI-Public/circleci-cli/api/schedule.go +++ b/vendor/github.com/CircleCI-Public/circleci-cli/api/schedule.go @@ -18,14 +18,14 @@ type Actor struct { type Schedule struct { ID string `json:"id"` - ProjectSlug string `json:"project_slug"` + ProjectSlug string `json:"project-slug"` Name string `json:"name"` Description string `json:"description,omitempty"` Timetable Timetable `json:"timetable"` Actor Actor `json:"actor"` Parameters map[string]string `json:"parameters"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created-at"` + UpdatedAt time.Time `json:"updated-at"` } type ScheduleInterface interface {