Skip to content
Draft
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
169 changes: 162 additions & 7 deletions cmd/export_dashboards.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ package cmd
import (
"context"
"fmt"
"strings"

"github.com/elastic/elastic-package/internal/packages"
"github.com/elastic/elastic-package/internal/tui"

"github.com/spf13/cobra"
Expand All @@ -19,10 +21,14 @@ import (
"github.com/elastic/elastic-package/internal/stack"
)

const exportDashboardsLongDescription = `Use this command to export dashboards with referenced objects from the Kibana instance.
const (
exportDashboardsLongDescription = `Use this command to export dashboards with referenced objects from the Kibana instance.

Use this command to download selected dashboards and other associated saved objects from Kibana. This command adjusts the downloaded saved objects according to package naming conventions (prefixes, unique IDs) and writes them locally into folders corresponding to saved object types (dashboard, visualization, map, etc.).`

newDashboardOption = "New dashboard"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Title for the option to be selected in order to show all the dashboards from the Serverless instance.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This option is shown the first one in the list.
Example:

 $ elastic-package -C ../integrations-main/packages/nginx export dashboards 
Export Kibana dashboards
Which packages would you like to export dashboards from? (nginx-2.3.2)
> New dashboard                 
  elastic_package_registry-0.3.1
  fleet_server-1.6.0            
  nginx-2.3.2                   
  synthetics-1.4.2              
                       

)

func exportDashboardsCmd(cmd *cobra.Command, args []string) error {
cmd.Println("Export Kibana dashboards")

Expand Down Expand Up @@ -67,16 +73,17 @@ func exportDashboardsCmd(cmd *cobra.Command, args []string) error {
fmt.Printf("Warning: %s\n", message)
}

// Just query for dashboards if none were provided as flags
if len(dashboardIDs) == 0 {
dashboardIDs, err = promptDashboardIDs(cmd.Context(), kibanaClient)
dashboardIDs, err = selectDashboardIds(cmd, kibanaClient, kibanaVersion)
if err != nil {
return fmt.Errorf("prompt for dashboard selection failed: %w", err)
return fmt.Errorf("selecting dashboard IDs failed: %w", err)
}
}

if len(dashboardIDs) == 0 {
fmt.Println("No dashboards were found in Kibana.")
return nil
}
if len(dashboardIDs) == 0 {
fmt.Println("No dashboards were found in Kibana.")
return nil
}

err = export.Dashboards(cmd.Context(), kibanaClient, dashboardIDs)
Expand All @@ -88,6 +95,154 @@ func exportDashboardsCmd(cmd *cobra.Command, args []string) error {
return nil
}

// selectDashboardIds prompts the user to select dashboards to export. It handles
// different flows depending on whether the Kibana instance is a Serverless environment or not.
// In non-Serverless environments, it prompts directly for dashboard selection.
// In Serverless environments, it first prompts to select an installed package or choose
// to export new dashboards, and then prompts for dashboard selection accordingly.
func selectDashboardIds(cmd *cobra.Command, kibanaClient *kibana.Client, kibanaVersion kibana.VersionInfo) ([]string, error) {
if kibanaVersion.BuildFlavor != kibana.ServerlessFlavor {
// This method uses a deprecated API to search for saved objects.
// And this API is not available in Serverless environments.
dashboardIDs, err := promptDashboardIDs(cmd.Context(), kibanaClient)
if err != nil {
return nil, fmt.Errorf("prompt for dashboard selection failed: %w", err)
}
return dashboardIDs, nil
}

installedPackage, err := promptPackagesInstalled(cmd.Context(), kibanaClient)
if err != nil {
return nil, fmt.Errorf("prompt for package selection failed: %w", err)
}

if installedPackage == "" {
fmt.Println("No installed packages were found in Kibana.")
return nil, nil
}

if installedPackage == newDashboardOption {
dashboardIDs, err := promptDashboardIDs(cmd.Context(), kibanaClient)
if err != nil {
return nil, fmt.Errorf("prompt for dashboard selection failed: %w", err)
}
return dashboardIDs, nil
}
Comment on lines +124 to +130
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Used here the same method as in local instances to show all dashboards in the selector.

This function uses kibana.FindDashboards() that has been updated in order to show all dashboards also in Serverless via the export API.


// As it can be installed just one version of a package in Elastic, we can split by '-' to get the name.
// This package name will be used to get the data related to a package (kibana.GetPackage).
installedPackageName, _, found := strings.Cut(installedPackage, "-")
if !found {
return nil, fmt.Errorf("invalid package name: %s", installedPackage)
}

dashboardIDs, err := promptPackageDashboardIDs(cmd.Context(), kibanaClient, installedPackageName)
if err != nil {
return nil, fmt.Errorf("prompt for package dashboard selection failed: %w", err)
}

return dashboardIDs, nil
}

func promptPackagesInstalled(ctx context.Context, kibanaClient *kibana.Client) (string, error) {
packageRoot, err := packages.MustFindPackageRoot()
if err != nil {
return "", fmt.Errorf("locating package root failed: %w", err)
}

m, err := packages.ReadPackageManifestFromPackageRoot(packageRoot)
if err != nil {
return "", fmt.Errorf("reading package manifest failed (path: %s): %w", packageRoot, err)
}

installedPackages, err := kibanaClient.FindInstalledPackages(ctx)
if err != nil {
return "", fmt.Errorf("finding installed packages failed: %w", err)
}

// It always shows an option to export dashboards not related to any package.
options := []string{newDashboardOption}

options = append(options, installedPackages.Strings()...)
defaultPackage := ""
for _, ip := range installedPackages {
if ip.Name == m.Name {
// set default package to the one matching the package in the current directory
defaultPackage = ip.String()
break
}
}

packagesPrompt := tui.NewSelect("Which packages would you like to export dashboards from?", options, defaultPackage)

var selectedOption string
err = tui.AskOne(packagesPrompt, &selectedOption, tui.Required)
if err != nil {
return "", err
}

return selectedOption, nil
}

// promptPackageDashboardIDs prompts the user to select dashboards from the given package.
// It requires the package name to fetch the installed package information from Kibana.
func promptPackageDashboardIDs(ctx context.Context, kibanaClient *kibana.Client, packageName string) ([]string, error) {
installedPackage, err := kibanaClient.GetPackage(ctx, packageName)
if err != nil {
return nil, fmt.Errorf("failed to get package status: %w", err)
}
if installedPackage.Status == "not_installed" {
return nil, fmt.Errorf("package %s is not installed", packageName)
}

// get asset titles from IDs
packageAssets := []packages.Asset{}
for _, asset := range installedPackage.InstallationInfo.InstalledKibanaAssets {
if asset.Type != "dashboard" {
continue
}

packageAssets = append(packageAssets, packages.Asset{ID: asset.ID, Type: asset.Type})
}

assetsResponse, err := kibanaClient.GetDataFromPackageAssetIDs(ctx, packageAssets)
if err != nil {
return nil, fmt.Errorf("failed to get package assets: %w", err)
}

dashboardIDOptions := []string{}
for _, asset := range assetsResponse {
if asset.Type != "dashboard" {
continue
}
dashboardIDOptions = append(dashboardIDOptions, asset.String())
}

if len(dashboardIDOptions) == 0 {
return nil, fmt.Errorf("no dashboards found for package %s", packageName)
}

dashboardsPrompt := tui.NewMultiSelect("Which dashboards would you like to export?", dashboardIDOptions, []string{})
dashboardsPrompt.SetPageSize(100)

var selectedOptions []string
err = tui.AskOne(dashboardsPrompt, &selectedOptions, tui.Required)
if err != nil {
return nil, err
}

var selectedIDs []string
for _, option := range selectedOptions {
for _, asset := range assetsResponse {
if asset.String() == option {
selectedIDs = append(selectedIDs, asset.ID)
}
}
}

return selectedIDs, nil
}

func promptDashboardIDs(ctx context.Context, kibanaClient *kibana.Client) ([]string, error) {
savedDashboards, err := kibanaClient.FindDashboards(ctx)
Comment on lines 246 to 247
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kibana.FindDashboards has been updated in order to show all dashboards also in Serverless via the export API.

if err != nil {
Expand Down
13 changes: 13 additions & 0 deletions internal/kibana/dashboards.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ func (c *Client) Export(ctx context.Context, dashboardIDs []string) ([]common.Ma
return c.exportWithSavedObjectsAPI(ctx, dashboardIDs)
}

// exportAllDashboards method exports all dashboards using the Kibana APIs without any export details nor including references.
func (c *Client) exportAllDashboards(ctx context.Context) ([]common.MapStr, error) {
logger.Debug("Export dashboards using the Kibana Saved Objects Export API")

request := ExportSavedObjectsRequest{
ExcludeExportDetails: true,
IncludeReferencesDeep: false,
Type: "dashboard",
}

return c.ExportSavedObjects(ctx, request)
}
Comment on lines +36 to +46
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method would return all the dashboards available in the Kibana Instance.

According to the documentation, the number of assets that will be exported depend on savedObjects.maxImportExportSize setting (link):

NOTE: The savedObjects.maxImportExportSize configuration setting limits the number of saved objects which may be exported.

By default that setting is 10000 elements (savedObjects.maxImportExportSize setting):

The maximum count of saved objects that can be imported or exported. This setting exists to prevent the Kibana server from running out of memory when handling large numbers of saved objects. It is recommended to only raise this setting if you are confident your server can hold this many objects in memory. Default: 10000


func (c *Client) exportWithSavedObjectsAPI(ctx context.Context, dashboardIDs []string) ([]common.MapStr, error) {
logger.Debug("Export dashboards using the Kibana Saved Objects Export API")

Expand Down
120 changes: 120 additions & 0 deletions internal/kibana/packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,56 @@ import (
"fmt"
"net/http"
"os"
"sort"
"strings"

"github.com/elastic/elastic-package/internal/packages"
)

const findInstalledPackagesPerPage = 100

var ErrNotSupported error = errors.New("not supported")

type findInstalledPackagesResponse struct {
// Installed packages are listed in Items field.
Items []InstalledPackage `json:"items"`
Total int `json:"total"`
SearchAfter []string `json:"searchAfter"`
}

type InstalledPackages []InstalledPackage
type InstalledPackage struct {
Name string `json:"name"`
Version string `json:"version"`
}

// String method returns string representation for an installed package.
func (ip *InstalledPackage) String() string {
return fmt.Sprintf("%s-%s", ip.Name, ip.Version)
}

// Strings method returns string representation for a set of installed packages.
func (ips InstalledPackages) Strings() []string {
var entries []string
for _, ip := range ips {
entries = append(entries, ip.String())
}
return entries
}

type PackageAssetResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Attributes struct {
Description string `json:"description"`
Title string `json:"title"`
} `json:"attributes"`
}

func (p *PackageAssetResponse) String() string {
return fmt.Sprintf("%s (ID: %s, Type: %s)", p.Attributes.Title, p.ID, p.Type)
}

// InstallPackage installs the given package in Fleet.
func (c *Client) InstallPackage(ctx context.Context, name, version string) ([]packages.Asset, error) {
path := c.epmPackageUrl(name, version)
Expand Down Expand Up @@ -216,3 +260,79 @@ func processResults(action string, statusCode int, respBody []byte) ([]packages.

return resp.Items, nil
}

// FindInstalledPackages retrieves the current installed packages (name and version). Response is sorted by name.
func (c *Client) FindInstalledPackages(ctx context.Context) (InstalledPackages, error) {
var installed InstalledPackages
searchAfter := ""

for {
r, err := c.findInstalledPackagesNextPage(ctx, searchAfter)
if err != nil {
return nil, fmt.Errorf("can't fetch page with results: %w", err)
}
if len(installed) >= r.Total {
break
}
searchAfter = r.SearchAfter[0]
installed = append(installed, r.Items...)
}
sort.Slice(installed, func(i, j int) bool {
return sort.StringsAreSorted([]string{strings.ToLower(installed[i].Name), strings.ToLower(installed[j].Name)})
})

return installed, nil
}

// findInstalledPackagesNextPage retrieves the next set of packages after the given searchAfter value.
func (c *Client) findInstalledPackagesNextPage(ctx context.Context, searchAfter string) (*findInstalledPackagesResponse, error) {
path := fmt.Sprintf("%s/epm/packages/installed?perPage=%d&searchAfter=[\"%s\"]", FleetAPI, findInstalledPackagesPerPage, searchAfter)
statusCode, respBody, err := c.get(ctx, path)
if err != nil {
return nil, fmt.Errorf("could not find installed packages; API status code = %d; response body = %s: %w", statusCode, string(respBody), err)
}

if statusCode != http.StatusOK {
return nil, fmt.Errorf("could not find installed packages; API status code = %d; response body = %s", statusCode, string(respBody))
}

var resp findInstalledPackagesResponse
if err := json.Unmarshal(respBody, &resp); err != nil {
return nil, fmt.Errorf("could not convert installed packages (response) to JSON: %w", err)
}
return &resp, nil
}

// GetDataFromPackageAssetIDs retrieves data, such as title and description, for the given a list of asset IDs
// using the "bulk_assets" Elastic Package Manager API endpoint. Response is sorted by title.
func (c *Client) GetDataFromPackageAssetIDs(ctx context.Context, assets []packages.Asset) ([]PackageAssetResponse, error) {
path := fmt.Sprintf("%s/epm/bulk_assets", FleetAPI)
type request struct {
AssetIDs []packages.Asset `json:"assetIds"`
}
reqBody, err := json.Marshal(request{AssetIDs: assets})
if err != nil {
return nil, fmt.Errorf("could not convert assets (request) to JSON: %w", err)
}
statusCode, respBody, err := c.post(ctx, path, reqBody)
if err != nil {
return nil, fmt.Errorf("could not get assets; API status code = %d; response body = %s: %w", statusCode, string(respBody), err)
}

if statusCode != http.StatusOK {
return nil, fmt.Errorf("could not get assets; API status code = %d; response body = %s", statusCode, string(respBody))
}

type packageAssetsResponse struct {
Items []PackageAssetResponse `json:"items"`
}

var resp packageAssetsResponse
if err := json.Unmarshal(respBody, &resp); err != nil {
return nil, fmt.Errorf("could not convert get assets (response) to JSON: %w", err)
}
sort.Slice(resp.Items, func(i, j int) bool {
return sort.StringsAreSorted([]string{strings.ToLower(resp.Items[i].Attributes.Title), strings.ToLower(resp.Items[j].Attributes.Title)})
})
return resp.Items, nil
}
Loading