diff --git a/internal/kibana/saved_objects.go b/internal/kibana/saved_objects.go index 348332fb4..77e990a5b 100644 --- a/internal/kibana/saved_objects.go +++ b/internal/kibana/saved_objects.go @@ -5,8 +5,10 @@ package kibana import ( + "bytes" "encoding/json" "fmt" + "mime/multipart" "net/http" "sort" "strings" @@ -93,11 +95,11 @@ func (c *Client) findDashboardsNextPage(page int) (*savedObjectsResponse, error) path := fmt.Sprintf("%s/_find?type=dashboard&fields=title&per_page=%d&page=%d", SavedObjectsAPI, findDashboardsPerPage, page) statusCode, respBody, err := c.get(path) if err != nil { - return nil, fmt.Errorf("could not find dashboards; API status code = %d; response body = %s: %w", statusCode, respBody, err) + return nil, fmt.Errorf("could not find dashboards; API status code = %d; response body = %s: %w", statusCode, string(respBody), err) } if statusCode != http.StatusOK { - return nil, fmt.Errorf("could not find dashboards; API status code = %d; response body = %s", statusCode, respBody) + return nil, fmt.Errorf("could not find dashboards; API status code = %d; response body = %s", statusCode, string(respBody)) } var r savedObjectsResponse @@ -107,3 +109,146 @@ func (c *Client) findDashboardsNextPage(page int) (*savedObjectsResponse, error) } return &r, nil } + +// SetManagedSavedObject method sets the managed property in a saved object. +// For example managed dashboards cannot be edited, and setting managed to false will +// allow to edit them. +// Managed property cannot be directly changed, so we modify it by exporting the +// saved object and importing it again, overwriting the original one. +func (c *Client) SetManagedSavedObject(savedObjectType string, id string, managed bool) error { + exportRequest := ExportSavedObjectsRequest{ + ExcludeExportDetails: true, + IncludeReferencesDeep: false, + Objects: []ExportSavedObjectsRequestObject{ + { + ID: id, + Type: savedObjectType, + }, + }, + } + objects, err := c.ExportSavedObjects(exportRequest) + if err != nil { + return fmt.Errorf("failed to export %s %s: %w", savedObjectType, id, err) + } + + for _, o := range objects { + o["managed"] = managed + } + + importRequest := ImportSavedObjectsRequest{ + Overwrite: true, + Objects: objects, + } + _, err = c.ImportSavedObjects(importRequest) + if err != nil { + return fmt.Errorf("failed to import %s %s: %w", savedObjectType, id, err) + } + + return nil +} + +type ExportSavedObjectsRequest struct { + ExcludeExportDetails bool `json:"excludeExportDetails"` + IncludeReferencesDeep bool `json:"includeReferencesDeep"` + Objects []ExportSavedObjectsRequestObject `json:"objects"` +} + +type ExportSavedObjectsRequestObject struct { + ID string `json:"id"` + Type string `json:"type"` +} + +func (c *Client) ExportSavedObjects(request ExportSavedObjectsRequest) ([]map[string]any, error) { + body, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to encode request: %w", err) + } + + path := SavedObjectsAPI + "/_export" + statusCode, respBody, err := c.SendRequest(http.MethodPost, path, body) + if err != nil { + return nil, fmt.Errorf("could not export saved objects; API status code = %d; response body = %s: %w", statusCode, string(respBody), err) + } + if statusCode != http.StatusOK { + return nil, fmt.Errorf("could not export saved objects; API status code = %d; response body = %s", statusCode, string(respBody)) + } + + var objects []map[string]any + decoder := json.NewDecoder(bytes.NewReader(respBody)) + for decoder.More() { + var object map[string]any + err := decoder.Decode(&object) + if err != nil { + return nil, fmt.Errorf("unmarshalling response failed (body: \n%s): %w", string(respBody), err) + } + + objects = append(objects, object) + } + + return objects, nil +} + +type ImportSavedObjectsRequest struct { + Overwrite bool + Objects []map[string]any +} + +type ImportSavedObjectsResponse struct { + Success bool `json:"success"` + Count int `json:"successCount"` + Results []ImportResult `json:"successResults"` + Errors []ImportResult `json:"errors"` +} + +type ImportResult struct { + ID string `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Error map[string]any `json:"error"` + Meta map[string]any `json:"meta"` +} + +func (c *Client) ImportSavedObjects(importRequest ImportSavedObjectsRequest) (*ImportSavedObjectsResponse, error) { + var body bytes.Buffer + multipartWriter := multipart.NewWriter(&body) + fileWriter, err := multipartWriter.CreateFormFile("file", "file.ndjson") + if err != nil { + return nil, fmt.Errorf("failed to create multipart form file: %w", err) + } + enc := json.NewEncoder(fileWriter) + for _, object := range importRequest.Objects { + // Encode includes the newline delimiter. + err := enc.Encode(object) + if err != nil { + return nil, fmt.Errorf("failed to encode object as json: %w", err) + } + } + multipartWriter.Close() + + path := SavedObjectsAPI + "/_import" + request, err := c.newRequest(http.MethodPost, path, &body) + if err != nil { + return nil, fmt.Errorf("cannot create new request: %w", err) + } + request.Header.Set("Content-Type", multipartWriter.FormDataContentType()) + if importRequest.Overwrite { + q := request.URL.Query() + q.Set("overwrite", "true") + request.URL.RawQuery = q.Encode() + } + + statusCode, respBody, err := c.doRequest(request) + if err != nil { + return nil, fmt.Errorf("could not import saved objects; API status code = %d; response body = %s: %w", statusCode, string(respBody), err) + } + if statusCode != http.StatusOK { + return nil, fmt.Errorf("could not import saved objects; API status code = %d; response body = %s", statusCode, string(respBody)) + } + + var results ImportSavedObjectsResponse + err = json.Unmarshal(respBody, &results) + if err != nil { + return nil, fmt.Errorf("could not decode response; response body: %s: %w", respBody, err) + } + return &results, nil +} diff --git a/internal/kibana/saved_objects_test.go b/internal/kibana/saved_objects_test.go new file mode 100644 index 000000000..2a9701447 --- /dev/null +++ b/internal/kibana/saved_objects_test.go @@ -0,0 +1,84 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License; +// you may not use this file except in compliance with the Elastic License. + +package kibana_test + +import ( + "errors" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/elastic/elastic-package/internal/kibana" + "github.com/elastic/elastic-package/internal/stack" +) + +func TestSetManagedSavedObject(t *testing.T) { + // TODO: Use kibana test client when we support recording POST requests. + client, err := stack.NewKibanaClient(kibana.RetryMax(0)) + var undefinedEnvError *stack.ErrUndefinedEnv + if errors.As(err, &undefinedEnvError) { + t.Skip("Kibana host required:", err) + } + require.NoError(t, err) + + id := preloadDashboard(t, client) + require.True(t, getManagedSavedObject(t, client, "dashboard", id)) + + err = client.SetManagedSavedObject("dashboard", id, false) + require.NoError(t, err) + assert.False(t, getManagedSavedObject(t, client, "dashboard", id)) +} + +func preloadDashboard(t *testing.T, client *kibana.Client) string { + id := uuid.New().String() + importRequest := kibana.ImportSavedObjectsRequest{ + Overwrite: false, // Highly unlikely, but avoid overwriting existing objects. + Objects: []map[string]any{ + { + "attributes": map[string]any{ + "title": "Empty Dashboard", + }, + "managed": true, + "type": "dashboard", + "id": id, + }, + }, + } + _, err := client.ImportSavedObjects(importRequest) + require.NoError(t, err) + + t.Cleanup(func() { + statusCode, _, err := client.SendRequest(http.MethodDelete, kibana.SavedObjectsAPI+"/dashboard/"+id, nil) + require.NoError(t, err) + require.Equal(t, http.StatusOK, statusCode) + }) + + return id +} + +func getManagedSavedObject(t *testing.T, client *kibana.Client, savedObjectType string, id string) bool { + exportRequest := kibana.ExportSavedObjectsRequest{ + ExcludeExportDetails: true, + Objects: []kibana.ExportSavedObjectsRequestObject{ + { + ID: id, + Type: "dashboard", + }, + }, + } + export, err := client.ExportSavedObjects(exportRequest) + require.NoError(t, err) + require.Len(t, export, 1) + + managed, found := export[0]["managed"] + if !found { + return false + } + + return managed.(bool) +}