diff --git a/CHANGELOG.md b/CHANGELOG.md index 93dc84c1..f77ec8c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.13.0 (Unreleased) + +FEATURES: + +* **New Resource:** `turbot_rollout` ([#195](https://github.com/turbot/terraform-provider-turbot/issues/195)) + ## 1.12.3 (TBD) BUG FIXES: diff --git a/apiClient/queries.go b/apiClient/queries.go index 59d8d6c7..97c312ff 100644 --- a/apiClient/queries.go +++ b/apiClient/queries.go @@ -746,3 +746,101 @@ func (client *Client) GetTurbotWorkspaceVersion() (*semver.Version, error) { } return version, nil } + +// rollout +// filter and description are removed for a workaround, will be removed after a Core change. +func createRolloutMutation() string { + return `mutation CreateRollout($input: CreateRolloutInput!) { + rollout: createRollout(input: $input) { + title + status + description + recipients + phases + turbot { + id + parentId + akas + title + } + accounts { + items { + turbot { + id + } + } + } + guardrails { + items { + turbot { + id + } + } + } + } + }` +} + +func readRolloutQuery(id string) string { + return fmt.Sprintf(`{ + rollout(id: "%s") { + description + status + title + recipients + phases + accounts { + items { + turbot { + id + } + } + } + guardrails { + items { + turbot { + id + } + } + } + turbot { + id + akas + title + parentId + } + } + }`, id) +} + +func updateRolloutMutation() string { + return `mutation UpdateRollout($input: UpdateRolloutInput!) { + rollout: updateRollout(input: $input) { + title + status + description + recipients + phases + turbot { + id + parentId + akas + title + } + accounts { + items { + turbot { + id + } + } + } + guardrails { + items { + turbot { + id + } + } + } + } + }` +} diff --git a/apiClient/rollout.go b/apiClient/rollout.go new file mode 100644 index 00000000..34a5ed7f --- /dev/null +++ b/apiClient/rollout.go @@ -0,0 +1,39 @@ +package apiClient + +func (client *Client) CreateRollout(input map[string]interface{}) (*Rollout, error) { + query := createRolloutMutation() + responseData := &RolloutResponse{} + variables := map[string]interface{}{ + "input": input, + } + + // execute api call + if err := client.doRequest(query, variables, responseData); err != nil { + return nil, client.handleCreateError(err, input, "rollout") + } + return &responseData.Rollout, nil +} + +func (client *Client) ReadRollout(id string) (*Rollout, error) { + query := readRolloutQuery(id) + responseData := &RolloutResponse{} + + // execute api call + if err := client.doRequest(query, nil, responseData); err != nil { + return nil, client.handleReadError(err, id, "rollout") + } + return &responseData.Rollout, nil +} + +func (client *Client) UpdateRollout(input map[string]interface{}) (*Rollout, error) { + query := updateRolloutMutation() + responseData := &RolloutResponse{} + variables := map[string]interface{}{ + "input": input, + } + // execute api call + if err := client.doRequest(query, variables, responseData); err != nil { + return nil, client.handleUpdateError(err, input, "rollout") + } + return &responseData.Rollout, nil +} diff --git a/apiClient/types.go b/apiClient/types.go index dfcdfd05..242a14c5 100644 --- a/apiClient/types.go +++ b/apiClient/types.go @@ -535,3 +535,54 @@ type TurbotWatchMetadata struct { ResourceId string FavoriteId string } + +// Rollout +type RolloutResponse struct { + Rollout Rollout +} + +type Rollout struct { + Turbot TurbotResourceMetadata + Description string + Status string + Title string + Recipients []string + Accounts struct { + Items []struct { + Turbot TurbotResourceMetadata + } + } + Guardrails struct { + Items []struct { + Turbot TurbotResourceMetadata + } + } + Phases RolloutPhases `json:"phases"` +} + +type RolloutPhases struct { + Draft *TurbotRolloutDraftPhaseMetadata `json:"draft,omitempty"` + Preview *TurbotRolloutPreviewPhaseMetadata `json:"preview,omitempty"` + Check *TurbotRolloutPhaseMetadata `json:"check,omitempty"` + Enforce *TurbotRolloutPhaseMetadata `json:"enforce,omitempty"` + Detach *TurbotRolloutPhaseMetadata `json:"detach,omitempty"` +} + +type TurbotRolloutPhaseMetadata struct { + StartAt string `json:"startAt,omitempty"` + StartNotice string `json:"startNotice,omitempty"` + StartEarlyIf string `json:"startEarlyIf,omitempty"` + WarnAt []string `json:"warnAt,omitempty"` + Recipients []string `json:"recipients,omitempty"` +} + +type TurbotRolloutDraftPhaseMetadata struct { + StartAt string `json:"startAt,omitempty"` +} + +type TurbotRolloutPreviewPhaseMetadata struct { + StartAt string `json:"startAt,omitempty"` + StartNotice string `json:"startNotice,omitempty"` + StartEarlyIf string `json:"startEarlyIf,omitempty"` + Recipients []string `json:"recipients,omitempty"` +} diff --git a/errors/errors.go b/errors/errors.go index 2b630f7b..bce809a3 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -2,11 +2,12 @@ package errors import ( "fmt" - "github.com/pkg/errors" "net/http" "regexp" "strconv" "strings" + + "github.com/pkg/errors" ) func NotFoundError(err error) bool { @@ -21,6 +22,12 @@ func FailedValidationError(err error) bool { return expectedErr.Match([]byte(err.Error())) } +func ForbiddenError(err error) bool { + forbiddenErr := "graphql: Forbidden: Insufficient permissions for resource(?i)" + expectedErr := regexp.MustCompile(forbiddenErr) + return expectedErr.Match([]byte(err.Error())) +} + func ExtractErrorCode(err error) (int, error) { // error returned from machinebox/graphql is of graphql type // errorNon200Template = "graphql: server returned a non-200 status code: 503" diff --git a/turbot/provider.go b/turbot/provider.go index 9754e02b..b66cd0f9 100755 --- a/turbot/provider.go +++ b/turbot/provider.go @@ -35,6 +35,7 @@ func Provider() terraform.ResourceProvider { }, ResourcesMap: map[string]*schema.Resource{ + "turbot_rollout": resourceTurbotRollout(), "turbot_control_mute": resourceTurbotControlMute(), "turbot_file": resourceTurbotFile(), "turbot_folder": resourceTurbotFolder(), diff --git a/turbot/resource_data_helpers.go b/turbot/resource_data_helpers.go index 8e3c165c..6c331345 100644 --- a/turbot/resource_data_helpers.go +++ b/turbot/resource_data_helpers.go @@ -1,6 +1,8 @@ package turbot import ( + "reflect" + "github.com/hashicorp/terraform/helper/schema" "github.com/iancoleman/strcase" "github.com/turbot/terraform-provider-turbot/apiClient" @@ -58,3 +60,15 @@ func storeAkas(aka, propertyName string, d *schema.ResourceData, meta interface{ d.Set(propertyName, akas) return nil } + +// given an attribute, check for if the attribute contains a nil value +func isNil(i interface{}) bool { + if i == nil { + return true + } + switch reflect.TypeOf(i).Kind() { + case reflect.Ptr, reflect.Map, reflect.Chan, reflect.Slice: + return reflect.ValueOf(i).IsNil() + } + return false +} diff --git a/turbot/resource_turbot_rollout.go b/turbot/resource_turbot_rollout.go new file mode 100644 index 00000000..cdb168f3 --- /dev/null +++ b/turbot/resource_turbot_rollout.go @@ -0,0 +1,355 @@ +package turbot + +import ( + "github.com/hashicorp/terraform/helper/schema" + "github.com/turbot/terraform-provider-turbot/apiClient" + "github.com/turbot/terraform-provider-turbot/errors" + "github.com/turbot/terraform-provider-turbot/helpers" +) + +// properties which must be passed to a create/update call +var rolloutProperties = []interface{}{"title", "description", "status", "recipients", "preview", "check", "draft", "enforce", "detach", "guardrails", "accounts", "akas"} + +func getRolloutUpdateProperties() []interface{} { + excludedProperties := []string{"guardrails", "preview", "check", "draft", "enforce", "detach", "akas"} + return helpers.RemoveProperties(rolloutProperties, excludedProperties) +} + +func resourceTurbotRollout() *schema.Resource { + return &schema.Resource{ + Create: resourceTurbotRolloutCreate, + Read: resourceTurbotRolloutRead, + Update: resourceTurbotRolloutUpdate, + Delete: resourceTurbotRolloutDelete, + Exists: resourceTurbotRolloutExists, + Importer: &schema.ResourceImporter{ + State: resourceTurbotRolloutImport, + }, + Schema: map[string]*schema.Schema{ + "guardrails": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "accounts": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "recipients": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "title": { + Type: schema.TypeString, + Optional: true, + }, + "description": { + Type: schema.TypeString, + Optional: true, + }, + "status": { + Type: schema.TypeString, + Optional: true, + }, + "parent": { + Type: schema.TypeString, + Computed: true, + }, + "check": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: rolloutPhaseSchema(), + }, + }, + "detach": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: rolloutPhaseSchema(), + }, + }, + "draft": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: rolloutDraftSchema(), + }, + }, + "enforce": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: rolloutPhaseSchema(), + }, + }, + "preview": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: rolloutPreviewSchema(), + }, + }, + "akas": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + DiffSuppressFunc: suppressIfAkaRemoved(), + }, + }, + } +} + +func rolloutPhaseSchema() map[string]*schema.Schema { + return map[string]*schema.Schema{ + "start_at": { + Type: schema.TypeString, + Required: true, + }, + "start_notice": { + Type: schema.TypeString, + Optional: true, + }, + "start_early_if": { + Type: schema.TypeString, + Optional: true, + }, + "recipients": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "warn_at": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + } +} + +func rolloutDraftSchema() map[string]*schema.Schema { + return map[string]*schema.Schema{ + "start_at": { + Type: schema.TypeString, + Required: true, + }, + } +} + +func rolloutPreviewSchema() map[string]*schema.Schema { + return map[string]*schema.Schema{ + "start_at": { + Type: schema.TypeString, + Required: true, + }, + "start_notice": { + Type: schema.TypeString, + Optional: true, + }, + "start_early_if": { + Type: schema.TypeString, + Optional: true, + }, + "recipients": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + } +} + +func resourceTurbotRolloutExists(d *schema.ResourceData, meta interface{}) (b bool, e error) { + client := meta.(*apiClient.Client) + id := d.Id() + return client.ResourceExists(id) +} + +func resourceTurbotRolloutCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*apiClient.Client) + + // build map of rollout properties + input := mapFromResourceData(d, rolloutProperties) + + // extract and set phase-related inputs + phases := make(map[string]interface{}) + + phaseKeys := []string{"check", "enforce", "detach"} + for _, key := range phaseKeys { + if !isNil(input[key]) { + phases[key] = setPhaseAttribute(input, key) + delete(input, key) + } + } + + // special handling for "draft" + if !isNil(input["draft"]) { + phases["draft"] = setDraftInputAttribute(input, "draft") + delete(input, "draft") + } + + if !isNil(input["preview"]) { + phases["preview"] = setPreviewInputAttribute(input, "preview") + delete(input, "preview") + } + + input["phases"] = phases + + // create the rollout + rollout, err := client.CreateRollout(input) + if err != nil { + return err + } + + // assign the id + d.SetId(rollout.Turbot.Id) + + // TODO Remove Read call once schema changes are In. + return resourceTurbotRolloutRead(d, meta) +} + +func resourceTurbotRolloutUpdate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*apiClient.Client) + id := d.Id() + + // build map of folder properties + input := mapFromResourceData(d, getRolloutUpdateProperties()) + input["id"] = id + + _, err := client.UpdateRollout(input) + if err != nil { + return err + } + // set 'Read' Properties + return resourceTurbotRolloutRead(d, meta) +} + +func resourceTurbotRolloutRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*apiClient.Client) + id := d.Id() + + rollout, err := client.ReadRollout(id) + if err != nil { + if errors.NotFoundError(err) { + // folder was not found - clear id + d.SetId("") + } + return err + } + + // Set basic attributes + d.Set("description", rollout.Description) + d.Set("status", rollout.Status) + d.Set("title", rollout.Turbot.Title) + d.Set("recipients", rollout.Recipients) + d.Set("akas", rollout.Turbot.Akas) + + // helper to extract Turbot.Id from a slice of items + extractIds := func(items []struct { + Turbot apiClient.TurbotResourceMetadata + }) []string { + ids := make([]string, len(items)) + for i, item := range items { + ids[i] = item.Turbot.Id + } + return ids + } + + if len(rollout.Accounts.Items) > 0 { + d.Set("accounts", extractIds(rollout.Accounts.Items)) + } + if len(rollout.Guardrails.Items) > 0 { + d.Set("guardrails", extractIds(rollout.Guardrails.Items)) + } + if !isNil(rollout.Turbot.ParentId) { + d.Set("parent", rollout.Turbot.ParentId) + } + + // helper to set phase if it's not nil + setPhase := func(key string, phase interface{}) { + if !isNil(phase) { + d.Set(key, []interface{}{phase}) + } + } + + setPhase("preview", rollout.Phases.Preview) + setPhase("check", rollout.Phases.Check) + setPhase("enforce", rollout.Phases.Enforce) + setPhase("detach", rollout.Phases.Detach) + setPhase("draft", rollout.Phases.Draft) + + return nil +} + +func resourceTurbotRolloutDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*apiClient.Client) + id := d.Id() + err := client.DeleteResource(id) + if err != nil { + return err + } + + // clear the id to show we have deleted + d.SetId("") + + return nil +} + +func resourceTurbotRolloutImport(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + if err := resourceTurbotRolloutRead(d, meta); err != nil { + return nil, err + } + return []*schema.ResourceData{d}, nil +} + +func setPhaseAttribute(input map[string]interface{}, attributeName string) map[string]interface{} { + phaseList := input[attributeName].([]interface{}) + if len(phaseList) > 0 { + phase := phaseList[0].(map[string]interface{}) + + return map[string]interface{}{ + "startAt": phase["start_at"].(string), + "startNotice": phase["start_notice"].(string), + "startEarlyIf": phase["start_early_if"].(string), + "warnAt": phase["warn_at"].([]interface{}), + "recipients": phase["recipients"].([]interface{}), + } + } + return nil +} + +func setDraftInputAttribute(input map[string]interface{}, attributeName string) map[string]interface{} { + draftInputs := input[attributeName].([]interface{}) + if len(draftInputs) > 0 { + draft := draftInputs[0].(map[string]interface{}) + + return map[string]interface{}{ + "startAt": draft["start_at"].(string), + } + } + return nil +} + +func setPreviewInputAttribute(input map[string]interface{}, attributeName string) map[string]interface{} { + previewInputs := input[attributeName].([]interface{}) + if len(previewInputs) > 0 { + preview := previewInputs[0].(map[string]interface{}) + + return map[string]interface{}{ + "startAt": preview["start_at"].(string), + "startNotice": preview["start_notice"].(string), + "startEarlyIf": preview["start_early_if"].(string), + "recipients": preview["recipients"].([]interface{}), + } + } + return nil +} diff --git a/turbot/resource_turbot_rollout_test.go b/turbot/resource_turbot_rollout_test.go new file mode 100644 index 00000000..7e7feb84 --- /dev/null +++ b/turbot/resource_turbot_rollout_test.go @@ -0,0 +1,120 @@ +package turbot + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" + "github.com/turbot/terraform-provider-turbot/apiClient" + "github.com/turbot/terraform-provider-turbot/errors" +) + +// test suites +func TestAccRollout_Basic(t *testing.T) { + resourceName := "turbot_rollout.test" + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckRolloutDestroy, + Steps: []resource.TestStep{ + { + Config: testAccRolloutConfig(), + Check: resource.ComposeTestCheckFunc( + testAccCheckRolloutExists("turbot_rollout.test"), + resource.TestCheckResourceAttr("turbot_rollout.test", "title", "Test Rollout Resource Created Through Terraform"), + resource.TestCheckResourceAttr("turbot_rollout.test", "description", "Rollout For Testing"), + ), + }, + { + Config: testAccRolloutUpdateDescConfig(), + Check: resource.ComposeTestCheckFunc( + testAccCheckRolloutExists("turbot_rollout.test"), + resource.TestCheckResourceAttr("turbot_rollout.test", "title", "Test Rollout Resource Created Through Terraform"), + resource.TestCheckResourceAttr("turbot_rollout.test", "description", "Rollout For Testing Updated"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + +} + +// configs +func testAccRolloutConfig() string { + return ` +resource "turbot_rollout" "test" { + + title = "Test Rollout Resource Created Through Terraform" + description = "Rollout For Testing" + + guardrails = ["348351678444334"] + accounts = ["330101957430965"] + + preview { + start_at = "2025-11-29T00:00:00Z" + start_early_if = "no_alerts" + start_notice = "enabled" + } +} +` +} + +func testAccRolloutUpdateDescConfig() string { + return ` +resource "turbot_rollout" "test" { + + title = "Test Rollout Resource Created Through Terraform" + description = "Rollout For Testing Updated" + + guardrails = ["348351678444334"] + accounts = ["330101957430965"] + + preview { + start_at = "2025-11-29T00:00:00Z" + start_early_if = "no_alerts" + start_notice = "enabled" + } +} +` +} + +// helper functions +func testAccCheckRolloutExists(resource string) resource.TestCheckFunc { + return func(state *terraform.State) error { + rs, ok := state.RootModule().Resources[resource] + if !ok { + return fmt.Errorf("not found: %s", resource) + } + if rs.Primary.ID == "" { + return fmt.Errorf("no Record ID is set") + } + client := testAccProvider.Meta().(*apiClient.Client) + _, err := client.ReadRollout(rs.Primary.ID) + if err != nil { + return fmt.Errorf("error fetching item with resource %s. %s", resource, err) + } + return nil + } +} + +func testAccCheckRolloutDestroy(s *terraform.State) error { + client := testAccProvider.Meta().(*apiClient.Client) + for _, rs := range s.RootModule().Resources { + if rs.Type == "turbot_rollout" { + _, err := client.ReadRollout(rs.Primary.ID) + if err == nil { + return fmt.Errorf("alert still exists") + } + if !errors.ForbiddenError(err) { + return fmt.Errorf("expected 'forbidden' error, got %s", err) + } + } + } + + return nil +} diff --git a/website/docs/r/rollout.html.markdown b/website/docs/r/rollout.html.markdown new file mode 100644 index 00000000..be2c84a9 --- /dev/null +++ b/website/docs/r/rollout.html.markdown @@ -0,0 +1,99 @@ +--- +layout: "turbot" +title: "turbot" +template: Documentation +page_title: "Turbot: turbot_rollout" +nav: + title: turbot_rollout +--- + +# turbot_rollout + +`Turbot Rollout` allows you to define and automate the rollout of one or more guardrails across one or more accounts through a series of controlled phases (e.g., preview, check, enforce, detach), with scheduled transitions and optional communications. + +## Example Usage + +**Automated Rollout with Scheduled Transitions** + +```hcl +resource "turbot_rollout" "rollout" { + + title = "Test Rollout Created Through Terraform" + description = "This is a test rollout created through terraform." + + guardrails = ["356127854694770"] + accounts = ["350804102494366"] + recipients = ["Account/*", "Turbot/Owner", "Turbot/Admin"] + akas = ["terraform_test_rollout"] + status = "ACTIVE" + + preview { + start_at = "2025-12-29T00:00:00.000Z" + start_early_if = "no_alerts" + start_notice = "enabled" + } + + check { + start_at = "2025-11-30T00:00:00.000Z" + warn_at = ["2025-10-23T00:00:00.000Z", "2025-10-29T00:00:00.000Z"] + start_early_if = "no_alerts" + start_notice = "enabled" + } + + draft { + start_at = "2025-08-30T00:00:00.000Z" + } +} +``` + +## Argument Reference + +The following arguments are supported: + +- `title` - (Required) A short display name for the rollout. +- `akas` - (Optional) Unique identifier of the resource. +- `description` - (Optional) Description of the rollout’s purpose. +- `guardrails` - (Optional) List of guardrail IDs or AKAs to include in the rollout. +- `accounts` - (Optional) List of account IDs or AKAs the rollout will target. +- `recipients` - (Optional) List of recipients to notify. Can include notification profiles. +- `status` - (Optional) Current status of the rollout. Must be ACTIVE to initiate phase transitions. + +## Phase Blocks +At least one phase block is required. Each phase corresponds to a step in the rollout lifecycle. The valid phases are: `draft`, `preview`, `check`, `enforce`, and `detach`. + +### `draft` Block + +This is the initial phase and typically used for staging purposes. It supports: +- start_at – (Optional) Absolute timestamp when the rollout should transition to the draft phase. + +### `preview` Block + +This phase is used to introduce changes to stakeholders before enforcing them. It supports: +- start_at – (Optional) Timestamp when accounts should enter the preview phase. +- start_notice – (Optional) Whether to send welcome notices on entry. One of enabled, disabled (default). +- start_early_if – (Optional) Set to "no_alerts" to allow accounts to enter the phase early if no alerts are present. +- recipients – (Optional) Overrides the default recipients for this phase. + +### `check`, `enforce`, `detach` Blocks + +These phases support the full set of scheduling and communication options: +In addition to all the arguments above, the following attributes are exported: + +- start_at – (Optional) Timestamp when accounts should enter the preview phase. +- warn_at – (Optional) List of timestamps to send warning notices before the phase starts. +- start_notice – (Optional) Whether to send welcome notices on entry. One of enabled, disabled (default). +- start_early_if – (Optional) Set to "no_alerts" to allow accounts to enter the phase early if no alerts are present. +- recipients – (Optional) Overrides the default recipients for this phase. + +## Attributes Reference + +- `parent` - The id of the rollout’s parent resource. +- `id` - Unique identifier of the resource. + +## Import + +Rollouts can be imported using the id. For example: + +``` +terraform import turbot_rollout.rollout 123456789012 +```