Skip to content

Commit ab9b152

Browse files
CSOAR-3990: Add Terraform support for CSOAR Playbooks
1 parent 133a9fe commit ab9b152

File tree

4 files changed

+493
-54
lines changed

4 files changed

+493
-54
lines changed

sumologic/resource_sumologic_csoar_playbook.go

Lines changed: 102 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package sumologic
22

33
import (
4+
"encoding/json"
45
"fmt"
56

67
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
@@ -75,24 +76,14 @@ func resourceSumologicCsoarPlaybook() *schema.Resource {
7576
Default: true,
7677
},
7778
"links": {
78-
Type: schema.TypeList,
79-
Optional: true,
80-
Elem: &schema.Schema{
81-
Type: schema.TypeMap,
82-
Elem: &schema.Schema{
83-
Type: schema.TypeString,
84-
},
85-
},
79+
Type: schema.TypeString,
80+
Optional: true,
81+
Description: "JSON string representation of playbook links",
8682
},
8783
"nodes": {
88-
Type: schema.TypeList,
89-
Optional: true,
90-
Elem: &schema.Schema{
91-
Type: schema.TypeMap,
92-
Elem: &schema.Schema{
93-
Type: schema.TypeString,
94-
},
95-
},
84+
Type: schema.TypeString,
85+
Optional: true,
86+
Description: "JSON string representation of playbook nodes",
9687
},
9788
},
9889
}
@@ -108,53 +99,107 @@ func resourceSumologicCsoarPlaybookUpdate(d *schema.ResourceData, meta interface
10899
c := meta.(*Client)
109100

110101
playbook := Playbook{
111-
Description: d.Get("description").(string),
112-
Name: d.Get("name").(string),
113-
UpdatedName: d.Get("updated_name").(string),
114-
Tags: d.Get("tags").(string),
115-
IsDeleted: d.Get("is_deleted").(bool),
116-
Draft: d.Get("draft").(bool),
117-
IsPublished: d.Get("is_published").(bool),
118-
LastUpdated: int64(d.Get("last_updated").(int)),
119-
CreatedBy: int64(d.Get("created_by").(int)),
120-
UpdatedBy: int64(d.Get("updated_by").(int)),
121-
Nested: d.Get("nested").(bool),
122-
Type: d.Get("type").(string),
123-
IsEnabled: d.Get("is_enabled").(bool),
124-
}
125-
126-
if v, ok := d.Get("links").([]interface{}); ok {
127-
links := make([]map[string]interface{}, len(v))
128-
for i, link := range v {
129-
if linkMap, ok := link.(map[string]interface{}); ok {
130-
links[i] = linkMap
131-
}
102+
Name: d.Get("name").(string),
103+
}
104+
105+
playbook.Description = d.Get("description").(string)
106+
playbook.UpdatedName = d.Get("updated_name").(string)
107+
playbook.Tags = d.Get("tags").(string)
108+
playbook.Type = d.Get("type").(string)
109+
playbook.IsDeleted = d.Get("is_deleted").(bool)
110+
playbook.Draft = d.Get("draft").(bool)
111+
playbook.IsPublished = d.Get("is_published").(bool)
112+
playbook.Nested = d.Get("nested").(bool)
113+
playbook.IsEnabled = d.Get("is_enabled").(bool)
114+
115+
// Handle links as JSON string
116+
if linksJSON, ok := d.Get("links").(string); ok && linksJSON != "" {
117+
var links []map[string]interface{}
118+
if err := json.Unmarshal([]byte(linksJSON), &links); err != nil {
119+
return fmt.Errorf("error parsing links JSON: %v", err)
132120
}
133121
playbook.Links = links
134122
}
135123

136-
if v, ok := d.Get("nodes").([]interface{}); ok {
137-
nodes := make([]map[string]interface{}, len(v))
138-
for i, node := range v {
139-
if nodeMap, ok := node.(map[string]interface{}); ok {
140-
nodes[i] = nodeMap
141-
}
124+
// Handle nodes as JSON string
125+
if nodesJSON, ok := d.Get("nodes").(string); ok && nodesJSON != "" {
126+
var nodes []map[string]interface{}
127+
if err := json.Unmarshal([]byte(nodesJSON), &nodes); err != nil {
128+
return fmt.Errorf("error parsing nodes JSON: %v", err)
142129
}
143130
playbook.Nodes = nodes
144131
}
145132

146-
return c.UpdatePlaybook(playbook)
133+
err := c.UpdatePlaybook(playbook)
134+
if err != nil {
135+
return err
136+
}
137+
138+
// Always set state after successful update
139+
d.Set("description", playbook.Description)
140+
d.Set("tags", playbook.Tags)
141+
d.Set("updated_name", playbook.UpdatedName)
142+
d.Set("type", playbook.Type)
143+
d.Set("is_deleted", playbook.IsDeleted)
144+
d.Set("draft", playbook.Draft)
145+
d.Set("is_published", playbook.IsPublished)
146+
d.Set("nested", playbook.Nested)
147+
d.Set("is_enabled", playbook.IsEnabled)
148+
149+
linksJSON := d.Get("links").(string)
150+
d.Set("links", linksJSON)
151+
152+
nodesJSON := d.Get("nodes").(string)
153+
d.Set("nodes", nodesJSON)
154+
155+
return nil
147156
}
148157

149158
func resourceSumologicCsoarPlaybookCreate(d *schema.ResourceData, meta interface{}) error {
150-
return fmt.Errorf("playbooks cannot be created via Terraform. Please create the playbook in the Sumo Logic UI and then import it using 'terraform import'")
159+
return fmt.Errorf("playbooks cannot be created via Terraform. Please create the playbook in the CSOAR UI, export it as JSON, and then import it using 'terraform import'")
151160
}
152161

153162
func resourceSumologicCsoarPlaybookRead(d *schema.ResourceData, meta interface{}) error {
154163
if d.Id() == "" {
155164
return fmt.Errorf("resource ID is empty")
156165
}
157166

167+
// For import-only resources, ensure the name matches the ID
168+
d.Set("name", d.Id())
169+
170+
// For import-only resources, preserve existing state values or set reasonable defaults
171+
// This prevents Terraform from thinking the resource doesn't exist
172+
if d.Get("description") == nil {
173+
d.Set("description", "")
174+
}
175+
if d.Get("tags") == nil {
176+
d.Set("tags", "")
177+
}
178+
if d.Get("is_deleted") == nil {
179+
d.Set("is_deleted", false)
180+
}
181+
if d.Get("draft") == nil {
182+
d.Set("draft", false)
183+
}
184+
if d.Get("is_published") == nil {
185+
d.Set("is_published", true)
186+
}
187+
if d.Get("nested") == nil {
188+
d.Set("nested", false)
189+
}
190+
if d.Get("type") == nil {
191+
d.Set("type", "General")
192+
}
193+
if d.Get("is_enabled") == nil {
194+
d.Set("is_enabled", true)
195+
}
196+
if d.Get("nodes") == nil {
197+
d.Set("nodes", "[]")
198+
}
199+
if d.Get("links") == nil {
200+
d.Set("links", "[]")
201+
}
202+
158203
return nil
159204
}
160205

@@ -168,5 +213,17 @@ func resourceSumologicCsoarPlaybookImport(d *schema.ResourceData, meta interface
168213
d.SetId(playbookName)
169214
d.Set("name", playbookName)
170215

216+
// Set default values for all schema fields to prevent Terraform from thinking this is a new resource
217+
d.Set("description", "")
218+
d.Set("tags", "")
219+
d.Set("is_deleted", false)
220+
d.Set("draft", false)
221+
d.Set("is_published", true)
222+
d.Set("nested", false)
223+
d.Set("type", "General")
224+
d.Set("is_enabled", true)
225+
d.Set("nodes", "[]")
226+
d.Set("links", "[]")
227+
171228
return []*schema.ResourceData{d}, nil
172229
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package sumologic
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
"testing"
7+
8+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest"
9+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
10+
"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
11+
)
12+
13+
const PUBLISHED_PLAYBOOK_NAME = "Test Playbook For Terraform"
14+
15+
func TestAccSumologicCsoarPlaybook_createShouldFail(t *testing.T) {
16+
rname := acctest.RandomWithPrefix("tf-acc-test-playbook")
17+
18+
resource.Test(t, resource.TestCase{
19+
PreCheck: func() { testAccPreCheck(t) },
20+
Providers: testAccProviders,
21+
Steps: []resource.TestStep{
22+
{
23+
Config: testAccSumologicCsoarPlaybookConfigBasic(rname),
24+
ExpectError: regexp.MustCompile("playbooks cannot be created via Terraform"),
25+
},
26+
},
27+
})
28+
}
29+
30+
func TestAccSumologicCsoarPlaybook_importAndUpdate(t *testing.T) {
31+
playbookName := PUBLISHED_PLAYBOOK_NAME
32+
resourceName := "sumologic_csoar_playbook.test"
33+
34+
resource.Test(t, resource.TestCase{
35+
PreCheck: func() { testAccPreCheck(t) },
36+
Providers: testAccProviders,
37+
CheckDestroy: testAccCheckCsoarPlaybookDestroy,
38+
Steps: []resource.TestStep{
39+
{
40+
Config: testAccSumologicCsoarPlaybookConfigUpdate(playbookName),
41+
ImportState: true,
42+
ResourceName: resourceName,
43+
ImportStateId: playbookName,
44+
Check: resource.ComposeTestCheckFunc(
45+
resource.TestCheckResourceAttr(resourceName, "name", playbookName),
46+
resource.TestCheckResourceAttr(resourceName, "description", "Updated via Terraform test"),
47+
resource.TestCheckResourceAttr(resourceName, "tags", "terraform,test"),
48+
),
49+
},
50+
},
51+
})
52+
}
53+
54+
func TestAccSumologicCsoarPlaybook_booleanFieldUpdates(t *testing.T) {
55+
playbookName := PUBLISHED_PLAYBOOK_NAME
56+
resourceName := "sumologic_csoar_playbook.test"
57+
58+
resource.Test(t, resource.TestCase{
59+
PreCheck: func() { testAccPreCheck(t) },
60+
Providers: testAccProviders,
61+
CheckDestroy: testAccCheckCsoarPlaybookDestroy,
62+
Steps: []resource.TestStep{
63+
{
64+
Config: testAccSumologicCsoarPlaybookConfigBooleanTest(playbookName, false),
65+
ImportState: true,
66+
ResourceName: resourceName,
67+
ImportStateId: playbookName,
68+
Check: resource.ComposeTestCheckFunc(
69+
resource.TestCheckResourceAttr(resourceName, "name", playbookName),
70+
resource.TestCheckResourceAttr(resourceName, "is_enabled", "false"),
71+
resource.TestCheckResourceAttr(resourceName, "is_published", "false"),
72+
resource.TestCheckResourceAttr(resourceName, "nested", "false"),
73+
),
74+
},
75+
},
76+
})
77+
}
78+
79+
func TestAccSumologicCsoarPlaybook_updatedNameField(t *testing.T) {
80+
playbookName := PUBLISHED_PLAYBOOK_NAME
81+
resourceName := "sumologic_csoar_playbook.test"
82+
83+
resource.Test(t, resource.TestCase{
84+
PreCheck: func() { testAccPreCheck(t) },
85+
Providers: testAccProviders,
86+
CheckDestroy: testAccCheckCsoarPlaybookDestroy,
87+
Steps: []resource.TestStep{
88+
{
89+
Config: testAccSumologicCsoarPlaybookConfigWithUpdatedName(playbookName),
90+
ImportState: true,
91+
ResourceName: resourceName,
92+
ImportStateId: playbookName,
93+
Check: resource.ComposeTestCheckFunc(
94+
resource.TestCheckResourceAttr(resourceName, "name", playbookName),
95+
resource.TestCheckResourceAttr(resourceName, "updated_name", "New Playbook"),
96+
resource.TestCheckResourceAttr(resourceName, "description", "Test with updated_name"),
97+
),
98+
},
99+
},
100+
})
101+
}
102+
103+
func testAccCheckCsoarPlaybookDestroy(s *terraform.State) error {
104+
for _, rs := range s.RootModule().Resources {
105+
if rs.Type != "sumologic_csoar_playbook" {
106+
continue
107+
}
108+
if rs.Primary.ID == "" {
109+
return fmt.Errorf("CSOAR Playbook destruction check: playbook ID is not set")
110+
}
111+
}
112+
return nil
113+
}
114+
115+
func testAccSumologicCsoarPlaybookConfigBasic(name string) string {
116+
return fmt.Sprintf(`
117+
resource "sumologic_csoar_playbook" "test" {
118+
name = "%s"
119+
}`, name)
120+
}
121+
122+
func testAccSumologicCsoarPlaybookConfigUpdate(name string) string {
123+
return fmt.Sprintf(`
124+
resource "sumologic_csoar_playbook" "test" {
125+
name = "%s"
126+
description = "Updated via Terraform test"
127+
tags = "terraform,test"
128+
}`, name)
129+
}
130+
131+
func testAccSumologicCsoarPlaybookConfigBooleanTest(name string, enabledState bool) string {
132+
return fmt.Sprintf(`
133+
resource "sumologic_csoar_playbook" "test" {
134+
name = "%s"
135+
description = "Boolean test"
136+
tags = ""
137+
is_deleted = false
138+
draft = false
139+
is_published = %t
140+
nested = %t
141+
type = "General"
142+
is_enabled = %t
143+
nodes = jsonencode([])
144+
links = jsonencode([])
145+
}`, name, enabledState, enabledState, enabledState)
146+
}
147+
148+
func testAccSumologicCsoarPlaybookConfigWithUpdatedName(name string) string {
149+
return fmt.Sprintf(`
150+
resource "sumologic_csoar_playbook" "test" {
151+
name = "%s"
152+
description = "Test with updated_name"
153+
updated_name = "New Playbook Name"
154+
tags = ""
155+
is_deleted = false
156+
draft = false
157+
is_published = true
158+
nested = false
159+
type = "General"
160+
is_enabled = true
161+
nodes = jsonencode([])
162+
links = jsonencode([])
163+
}`, name)
164+
}

sumologic/sumologic_csoar_playbook.go

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,30 @@ import (
66
)
77

88
func (s *Client) DeletePlaybook(name string) error {
9-
_, err := s.Delete(fmt.Sprintf("api/csoar/v3/playbook/?name=%s", url.QueryEscape(name)))
9+
_, err := s.Delete(fmt.Sprintf("csoar/v3/playbook/?name=%s", url.QueryEscape(name)))
1010

1111
return err
1212
}
1313

1414
func (s *Client) UpdatePlaybook(playbook Playbook) error {
15-
_, err := s.Put("api/csoar/v3/playbook/", playbook)
16-
15+
_, err := s.Put("csoar/v3/playbook/", playbook)
1716
return err
1817
}
1918

2019
type Playbook struct {
2120
Description string `json:"description,omitempty"`
2221
Name string `json:"name"`
2322
UpdatedName string `json:"updated_name,omitempty"`
24-
Tags string `json:"tags,omitempty"`
25-
IsDeleted bool `json:"is_deleted,omitempty"`
26-
Draft bool `json:"draft,omitempty"`
27-
IsPublished bool `json:"is_published,omitempty"`
23+
Tags string `json:"tags"`
24+
IsDeleted bool `json:"is_deleted"`
25+
Draft bool `json:"draft"`
26+
IsPublished bool `json:"is_published"`
2827
LastUpdated int64 `json:"last_updated,omitempty"`
2928
Links []map[string]interface{} `json:"links,omitempty"`
3029
Nodes []map[string]interface{} `json:"nodes,omitempty"`
3130
CreatedBy int64 `json:"created_by,omitempty"`
3231
UpdatedBy int64 `json:"updated_by,omitempty"`
33-
Nested bool `json:"nested,omitempty"`
32+
Nested bool `json:"nested"`
3433
Type string `json:"type,omitempty"`
35-
IsEnabled bool `json:"is_enabled,omitempty"`
34+
IsEnabled bool `json:"is_enabled"`
3635
}

0 commit comments

Comments
 (0)