Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions client/saved_searches.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"log"
"net/http"
"net/http/httputil"
"net/url"
)

func (client *Client) CreateSavedSearches(name, owner, app string, savedSearchObject *models.SavedSearchObject) (*http.Response, error) {
Expand Down Expand Up @@ -39,13 +40,30 @@ func (client *Client) ReadSavedSearches(name, owner, app string) (*http.Response
return client.Get(endpoint)
}

func (client *Client) UpdateSavedSearches(name string, owner string, app string, savedSearchObject *models.SavedSearchObject) error {
// UpdateSavedSearches posts the saved search. Splunk keeps any key an update
// leaves out, so each key in clearKeys is posted with an empty value to clear it.
func (client *Client) UpdateSavedSearches(name string, owner string, app string, savedSearchObject *models.SavedSearchObject, clearKeys []string) error {
values, err := query.Values(&savedSearchObject)
if err != nil {
return err
}
// EncodeRequestBody drops empty values, so the cleared keys are appended after it.
body, err := client.EncodeRequestBody(values)
if err != nil {
return err
}
cleared := url.Values{}
for _, key := range clearKeys {
cleared.Set(key, "")
}
if len(cleared) > 0 {
if len(body) > 0 {
body = append(body, '&')
}
body = append(body, cleared.Encode()...)
}
endpoint := client.BuildSplunkURLWithEscapedPathPart(nil, name, "servicesNS", owner, app, "saved", "searches")
resp, err := client.Post(endpoint, values)
resp, err := client.Post(endpoint, body)
if err != nil {
return err
}
Expand Down
26 changes: 21 additions & 5 deletions splunk/resource_splunk_saved_searches.go
Original file line number Diff line number Diff line change
Expand Up @@ -1073,15 +1073,13 @@ func savedSearches() *schema.Resource {
"dispatch_index_earliest": {
Type: schema.TypeString,
Optional: true,
Computed: true,
Description: "A time string that specifies the latest time for this search. Can be a relative or absolute time." +
Description: "A time string that specifies the earliest index time for this search. Can be a relative or absolute time. " +
"If this value is an absolute time, use the dispatch.time_format to format the value.",
},
"dispatch_index_latest": {
Type: schema.TypeString,
Optional: true,
Computed: true,
Description: "A time string that specifies the earliest time for this search. Can be a relative or absolute time." +
Description: "A time string that specifies the latest index time for this search. Can be a relative or absolute time. " +
"If this value is an absolute time, use the dispatch.time_format to format the value.",
},
"dispatch_indexed_realtime": {
Expand Down Expand Up @@ -1992,7 +1990,7 @@ func savedSearchesUpdate(d *schema.ResourceData, meta interface{}) error {
// Updating a shared object in the owner's namespace creates a private copy.
owner := sharedObjectOwner(aclObject)

err := (*provider.Client).UpdateSavedSearches(d.Id(), owner, aclObject.App, savedSearchesConfig)
err := (*provider.Client).UpdateSavedSearches(d.Id(), owner, aclObject.App, savedSearchesConfig, savedSearchesClearedKeys(d))
if err != nil {
return err
}
Expand Down Expand Up @@ -2026,6 +2024,24 @@ func savedSearchesDelete(d *schema.ResourceData, meta interface{}) error {
return nil
}

// savedSearchesClearableKeys pairs attributes whose Splunk default is empty with
// their form keys. Splunk keeps any key an update leaves out, so a removed
// attribute is only cleared when its key is posted with an empty value.
var savedSearchesClearableKeys = []struct{ attr, key string }{
{"dispatch_index_earliest", "dispatch.index_earliest"},
{"dispatch_index_latest", "dispatch.index_latest"},
}

func savedSearchesClearedKeys(d *schema.ResourceData) []string {
var keys []string
for _, k := range savedSearchesClearableKeys {
if d.HasChange(k.attr) && d.Get(k.attr).(string) == "" {
keys = append(keys, k.key)
}
}
return keys
}

func getSavedSearchesConfig(d *schema.ResourceData) (savedSearchesObj *models.SavedSearchObject) {
schedulePriority := d.Get("schedule_priority").(string)
if d.Get("ignore_schedule_priority").(bool) {
Expand Down
169 changes: 169 additions & 0 deletions splunk/resource_splunk_saved_searches_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,31 @@ resource "splunk_saved_searches" "test" {
}
}
`
const savedSearchesDispatchIndexWindow = `
resource "splunk_saved_searches" "test" {
name = "Test Dispatch Index Window"
search = "index=main"
dispatch_index_earliest = "-10m"
dispatch_index_latest = "-5m"
}
`

const savedSearchesDispatchIndexRemoved = `
resource "splunk_saved_searches" "test" {
name = "Test Dispatch Index Window"
search = "index=main"
}
`

const savedSearchesDispatchIndexEmpty = `
resource "splunk_saved_searches" "test" {
name = "Test Dispatch Index Window"
search = "index=main"
dispatch_index_earliest = ""
dispatch_index_latest = ""
}
`

const newSavedSearchesLogEvent = `
resource "splunk_saved_searches" "test" {
name = "Test Log Event Alert"
Expand Down Expand Up @@ -901,6 +926,31 @@ func TestAccSplunkSavedSearchesEmailIncludeLinksZero(t *testing.T) {
})
}

func TestAccSplunkSavedSearchesDispatchIndexCleared(t *testing.T) {
resourceName := "splunk_saved_searches.test"
window := resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(resourceName, "dispatch_index_earliest", "-10m"),
resource.TestCheckResourceAttr(resourceName, "dispatch_index_latest", "-5m"),
)
cleared := resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr(resourceName, "dispatch_index_earliest", ""),
resource.TestCheckResourceAttr(resourceName, "dispatch_index_latest", ""),
)
resource.Test(t, resource.TestCase{
PreCheck: func() {
testAccPreCheck(t)
},
Providers: testAccProviders,
CheckDestroy: testAccSplunkSavedSearchesDestroyResources,
Steps: []resource.TestStep{
{Config: savedSearchesDispatchIndexWindow, Check: window},
{Config: savedSearchesDispatchIndexRemoved, Check: cleared},
{Config: savedSearchesDispatchIndexWindow, Check: window},
{Config: savedSearchesDispatchIndexEmpty, Check: cleared},
},
})
}

func testAccSplunkSavedSearchesDestroyResources(s *terraform.State) error {
client, err := newTestClient()
if err != nil {
Expand Down Expand Up @@ -1564,6 +1614,125 @@ func TestSavedSearchesUpdateSendsACLOnlyWhenChanged(t *testing.T) {
}
}

// TestSavedSearchesUpdateClearsDispatchIndexWindow runs against a server that,
// like Splunk, keeps any key an update leaves out.
func TestSavedSearchesUpdateClearsDispatchIndexWindow(t *testing.T) {
keys := []string{"dispatch.index_earliest", "dispatch.index_latest"}
tests := []struct {
name string
cleared map[string]interface{}
}{
{"removed", map[string]interface{}{}},
{"empty", map[string]interface{}{"dispatch_index_earliest": "", "dispatch_index_latest": ""}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stored := map[string]string{}
var updates []url.Values
meta := newRoundTripProvider(t, func(r *http.Request) (*http.Response, error) {
if r.Method == http.MethodPost {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatal(err)
}
form, err := url.ParseQuery(string(body))
if err != nil {
t.Fatal(err)
}
if strings.HasSuffix(r.URL.Path, "/saved/searches/alert") {
updates = append(updates, form)
}
for _, key := range append([]string{"search"}, keys...) {
if form.Has(key) {
stored[key] = form.Get(key)
}
}
return jsonResponse(http.StatusOK, "{}"), nil
}
return jsonResponse(http.StatusOK, fmt.Sprintf(`{"entry":[{"name":"alert","acl":{"app":"search","owner":"admin","sharing":"app"},"content":{"search":%q,"dispatch.index_earliest":%q,"dispatch.index_latest":%q}}]}`,
stored["search"], stored["dispatch.index_earliest"], stored["dispatch.index_latest"])), nil
})

savedSearch := savedSearches()
config := func(search string, attrs map[string]interface{}) *terraform.ResourceConfig {
raw := map[string]interface{}{"name": "alert", "search": search}
for k, v := range attrs {
raw[k] = v
}
return terraform.NewResourceConfigRaw(raw)
}
apply := func(state *terraform.InstanceState, config *terraform.ResourceConfig) *terraform.InstanceState {
t.Helper()
diff, err := savedSearch.Diff(state, config, meta)
if err != nil {
t.Fatal(err)
}
if diff.Empty() {
return state
}
state, err = savedSearch.Apply(state, diff, meta)
if err != nil {
t.Fatal(err)
}
return state
}

state := apply(nil, config("index=main", map[string]interface{}{"dispatch_index_earliest": "-10m", "dispatch_index_latest": "-5m"}))
if stored[keys[0]] != "-10m" || stored[keys[1]] != "-5m" {
t.Fatalf("index window not created: %v", stored)
}

updates = nil
state = apply(state, config("index=main", tt.cleared))
for _, key := range keys {
if got := stored[key]; got != "" {
t.Errorf("%s = %q after the update, want it cleared", key, got)
}
}
if len(updates) != 1 {
t.Fatalf("updates = %d, want 1", len(updates))
}
for key, vals := range updates[0] {
if vals[0] == "" && key != keys[0] && key != keys[1] {
t.Errorf("update posted an empty %s", key)
}
}

diff, err := savedSearch.Diff(state, config("index=main", tt.cleared), meta)
if err != nil {
t.Fatal(err)
}
if !diff.Empty() {
for _, attr := range []string{"dispatch_index_earliest", "dispatch_index_latest"} {
if _, ok := diff.Attributes[attr]; ok {
t.Errorf("plan after the update still changes %s", attr)
}
}
}

updates = nil
state = apply(state, config("index=main | head 1", tt.cleared))
if len(updates) != 1 {
t.Fatalf("updates = %d, want 1", len(updates))
}
for _, key := range keys {
if updates[0].Has(key) {
t.Errorf("a later search change posted %s", key)
}
}

updates = nil
apply(state, config("index=main | head 1", map[string]interface{}{"dispatch_index_earliest": "-10m", "dispatch_index_latest": "-5m"}))
if len(updates) != 1 {
t.Fatalf("updates = %d, want 1", len(updates))
}
if got := updates[0][keys[0]]; len(got) != 1 || got[0] != "-10m" {
t.Errorf("%s = %q when set again, want [-10m]", keys[0], got)
}
})
}
}

func TestSavedSearchesReadRetriesNotFound(t *testing.T) {
reads := 0
meta := newRoundTripProvider(t, func(r *http.Request) (*http.Response, error) {
Expand Down
Loading