Skip to content
Open
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
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ test-stack-command-with-basic-subscription:

test-stack-command: test-stack-command-default test-stack-command-agent-version-flag test-stack-command-7x test-stack-command-800 test-stack-command-8x test-stack-command-9x test-stack-command-with-apm-server

test-check-packages: test-check-packages-with-kind test-check-packages-other test-check-packages-parallel test-check-packages-with-custom-agent test-check-packages-benchmarks test-check-packages-false-positives test-check-packages-with-logstash test-build-install-packages-composable
test-check-packages: test-check-packages-with-kind test-check-packages-other test-check-packages-parallel test-check-packages-with-custom-agent test-check-packages-benchmarks test-check-packages-false-positives test-check-packages-with-logstash test-check-packages-dashboards-as-code test-build-install-packages-composable

test-check-packages-with-kind:
PACKAGE_TEST_TYPE=with-kind ./scripts/test-check-packages.sh
Expand All @@ -127,6 +127,9 @@ test-check-packages-parallel:
test-check-packages-with-custom-agent:
PACKAGE_TEST_TYPE=with-custom-agent ./scripts/test-check-packages.sh

test-check-packages-dashboards-as-code:
./scripts/test-check-packages-dashboards-as-code.sh

test-build-install-packages-composable:
./scripts/test-composable-packages.sh

Expand Down
14 changes: 13 additions & 1 deletion cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/elastic/elastic-package/internal/cobraext"
"github.com/elastic/elastic-package/internal/files"
"github.com/elastic/elastic-package/internal/install"
"github.com/elastic/elastic-package/internal/kibana"
"github.com/elastic/elastic-package/internal/logger"
"github.com/elastic/elastic-package/internal/packages"
"github.com/elastic/elastic-package/internal/profile"
Expand Down Expand Up @@ -45,6 +46,7 @@ func setupBuildCommand() *cobraext.Command {
cmd.Flags().Bool(cobraext.BuildZipFlagName, true, cobraext.BuildZipFlagDescription)
cmd.Flags().Bool(cobraext.SignPackageFlagName, false, cobraext.SignPackageFlagDescription)
cmd.Flags().Bool(cobraext.BuildSkipValidationFlagName, false, cobraext.BuildSkipValidationFlagDescription)
cmd.Flags().Bool(cobraext.BuildCompileDashboardsAsCodeFlagName, false, cobraext.BuildCompileDashboardsAsCodeFlagDescription)
return cobraext.NewCommand(cmd, cobraext.ContextPackage)
}

Expand All @@ -54,6 +56,7 @@ func buildCommandAction(cmd *cobra.Command, args []string) error {
createZip, _ := cmd.Flags().GetBool(cobraext.BuildZipFlagName)
signPackage, _ := cmd.Flags().GetBool(cobraext.SignPackageFlagName)
skipValidation, _ := cmd.Flags().GetBool(cobraext.BuildSkipValidationFlagName)
compileDashboardsAsCode, _ := cmd.Flags().GetBool(cobraext.BuildCompileDashboardsAsCodeFlagName)

if signPackage && !createZip {
return errors.New("can't sign the unzipped package, please use also the --zip switch")
Expand Down Expand Up @@ -102,7 +105,15 @@ func buildCommandAction(cmd *cobra.Command, args []string) error {

requiredInputsResolver := requiredinputs.NewRequiredInputsResolver(eprClient)

target, err := builder.BuildPackage(builder.BuildOptions{
var kibanaClient *kibana.Client
if compileDashboardsAsCode {
kibanaClient, err = stack.NewKibanaClientFromProfile(prof)
if err != nil {
return fmt.Errorf("can't create Kibana client for dashboards-as-code compilation: %w", err)
}
}

target, err := builder.BuildPackage(cmd.Context(), builder.BuildOptions{
PackageRoot: packageRoot,
BuildDir: buildDir,
CreateZip: createZip,
Expand All @@ -112,6 +123,7 @@ func buildCommandAction(cmd *cobra.Command, args []string) error {
UpdateReadmes: true,
SchemaURLs: appConfig.SchemaURLs(),
RequiredInputsResolver: requiredInputsResolver,
KibanaClient: kibanaClient,
})
if err != nil {
return fmt.Errorf("building package failed: %w", err)
Expand Down
123 changes: 123 additions & 0 deletions internal/builder/dashboards_as_code.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// 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 builder

import (
"context"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/Masterminds/semver/v3"

"github.com/elastic/elastic-package/internal/export"
"github.com/elastic/elastic-package/internal/kibana"
"github.com/elastic/elastic-package/internal/logger"
"github.com/elastic/elastic-package/internal/packages"
)

const dashboardsAsCodeDir = "_dev/shared"

// minDashboardsAsCodeKibanaVersion is the first Kibana version that supports
// the dashboards-as-code import API (POST /api/dashboards).
var minDashboardsAsCodeKibanaVersion = semver.MustParse("9.4.0")

// compileDashboardsAsCode compiles each *.json file under
// <sourcePackageRoot>/_dev/shared/ into a saved-object dashboard under
// <sourcePackageRoot>/kibana/dashboard/. Each source file is imported into
// the connected Kibana via PUT /api/dashboards/<id>, the resulting dashboard
// is exported back through the standard dashboards export pipeline, and the
// imported saved object is then deleted from Kibana.
//
// Compilation is opt-in: callers signal intent by passing a non-nil
// kibanaClient. When kibanaClient is nil this function returns immediately,
// even if source files are present, so packages that use _dev/shared/ for
// unrelated content are unaffected by builds that did not request
// dashboards-as-code compilation.
func compileDashboardsAsCode(ctx context.Context, kibanaClient *kibana.Client, sourcePackageRoot string) error {
if kibanaClient == nil {
return nil
}

sourceDir := filepath.Join(sourcePackageRoot, dashboardsAsCodeDir)
files, err := filepath.Glob(filepath.Join(sourceDir, "*.json"))
if err != nil {
return fmt.Errorf("listing dashboards-as-code sources failed: %w", err)
}
if len(files) == 0 {
return nil
}

versionInfo, err := kibanaClient.Version()
if err != nil {
return fmt.Errorf("getting Kibana version information: %w", err)
}
if err := checkDashboardsAsCodeKibanaVersion(versionInfo); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

is this not checked by the package validation? when the version of a package is set with its kibana version?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

the intention here is just to check if the stack being used to build the package supports the API. it won't check vs the manifest version because existing check/build doesn't require a stack at all.

return err
}

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

for _, file := range files {
if err := compileDashboardAsCodeFile(ctx, kibanaClient, manifest.Name, sourcePackageRoot, file); err != nil {
return fmt.Errorf("compiling dashboards-as-code file %s: %w", file, err)
}
}
return nil
}

func checkDashboardsAsCodeKibanaVersion(info kibana.VersionInfo) error {
v, err := semver.NewVersion(info.Number)
if err != nil {
return fmt.Errorf("cannot parse Kibana version %s: %w", info.Number, err)
}
if v.LessThan(minDashboardsAsCodeKibanaVersion) {
return fmt.Errorf("dashboards-as-code requires Kibana %s or later (got %s); the import API at POST /api/dashboards is not available in this version",
minDashboardsAsCodeKibanaVersion, info.Number)
}
return nil
}

func compileDashboardAsCodeFile(ctx context.Context, kibanaClient *kibana.Client, packageName, sourcePackageRoot, file string) error {
logger.Debugf("Compiling dashboards-as-code file: %s", file)

body, err := os.ReadFile(file)
if err != nil {
return fmt.Errorf("reading dashboards-as-code source failed: %w", err)
}

// Use the source filename (without extension) as the saved-object id so the
// compiled output is deterministic. standardizeObjectID will then prefix it
// with the package name during export.
id := strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))
id, err = kibanaClient.ImportDashboardAsCode(ctx, id, body)
if err != nil {
return fmt.Errorf("importing dashboards-as-code failed: %w", err)
}

// Best-effort cleanup of the imported dashboard, regardless of how the
// rest of this function completes. Detach from ctx's cancellation so
// cleanup still runs if the parent context has been cancelled.
cleanupCtx := context.WithoutCancel(ctx)
defer func() {
if cleanupErr := kibanaClient.DeleteDashboard(cleanupCtx, id); cleanupErr != nil {
logger.Warnf("Failed to delete imported dashboard %s during cleanup: %v", id, cleanupErr)
}
}()

objects, err := kibanaClient.Export(ctx, []string{id})
if err != nil {
return fmt.Errorf("exporting dashboard %s failed: %w", id, err)
}

if err := export.TransformAndWriteDashboards(sourcePackageRoot, packageName, objects); err != nil {
return fmt.Errorf("writing exported dashboard %s failed: %w", id, err)
}
return nil
}
16 changes: 15 additions & 1 deletion internal/builder/packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package builder

import (
"context"
"errors"
"fmt"
"os"
Expand All @@ -16,6 +17,7 @@ import (
"github.com/elastic/elastic-package/internal/environment"
"github.com/elastic/elastic-package/internal/fields"
"github.com/elastic/elastic-package/internal/files"
"github.com/elastic/elastic-package/internal/kibana"
"github.com/elastic/elastic-package/internal/logger"
"github.com/elastic/elastic-package/internal/packages"
"github.com/elastic/elastic-package/internal/requiredinputs"
Expand All @@ -38,6 +40,12 @@ type BuildOptions struct {
UpdateReadmes bool
SchemaURLs fields.SchemaURLs
RequiredInputsResolver requiredinputs.Resolver

// KibanaClient is used by the dashboards-as-code build step to import the
// new-format dashboards under _dev/dashboards_as_code/ and re-export them
// in the saved-object format. May be nil when the package does not use the
// dashboards-as-code feature.
KibanaClient *kibana.Client
}

// BuildDirectory function locates the target build directory. If the directory doesn't exist, it will create it.
Expand Down Expand Up @@ -168,7 +176,7 @@ func FindBuildPackagesDirectory() (string, bool, error) {
}

// BuildPackage function builds the package.
func BuildPackage(options BuildOptions) (string, error) {
func BuildPackage(ctx context.Context, options BuildOptions) (string, error) {
// buildPackageRoot is the directory where the built package content is placed
// eg. <buildDir>/packages/<package name>/<package version>
buildPackageRoot, err := BuildPackagesDirectory(options.PackageRoot, options.BuildDir)
Expand All @@ -183,6 +191,12 @@ func BuildPackage(options BuildOptions) (string, error) {
return "", fmt.Errorf("clearing package contents failed: %w", err)
}

logger.Debug("Compile dashboards-as-code")
err = compileDashboardsAsCode(ctx, options.KibanaClient, options.PackageRoot)
if err != nil {
return "", fmt.Errorf("compiling dashboards-as-code failed: %w", err)
}

logger.Debugf("Copy package content (source: %s)", options.PackageRoot)
err = files.CopyWithoutDev(options.PackageRoot, buildPackageRoot)
if err != nil {
Expand Down
3 changes: 3 additions & 0 deletions internal/cobraext/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ const (
BuildZipFlagName = "zip"
BuildZipFlagDescription = "archive the built package"

BuildCompileDashboardsAsCodeFlagName = "compile-dashboards-as-code"
BuildCompileDashboardsAsCodeFlagDescription = "compile dashboards-as-code sources under _dev/shared/*.json into kibana/dashboard/ saved objects (requires a running Kibana 9.4.0+ stack)"

ChangelogAddNextFlagName = "next"
ChangelogAddNextFlagDescription = "changelog entry is added in the next `major`, `minor` or `patch` version"

Expand Down
14 changes: 10 additions & 4 deletions internal/export/dashboards.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,23 @@ func Dashboards(ctx context.Context, kibanaClient *kibana.Client, dashboardsIDs
return fmt.Errorf("exporting dashboards using Kibana client failed: %w", err)
}

return TransformAndWriteDashboards(packageRoot, m.Name, objects)
}

// TransformAndWriteDashboards applies the standard dashboard transformation pipeline
// to a list of Kibana saved objects and writes them under packageRoot/kibana/<type>/<id>.json.
// It is shared by the dashboards export command and the dashboards-as-code build step.
func TransformAndWriteDashboards(packageRoot, packageName string, objects []common.MapStr) error {
transformContext := &transformationContext{
packageName: m.Name,
packageName: packageName,
}

objects, err = applyTransformations(transformContext, objects)
objects, err := applyTransformations(transformContext, objects)
if err != nil {
return fmt.Errorf("can't transform Kibana objects: %w", err)
}

err = saveObjectsToFiles(packageRoot, objects)
if err != nil {
if err := saveObjectsToFiles(packageRoot, objects); err != nil {
return fmt.Errorf("can't save Kibana objects: %w", err)
}
return nil
Expand Down
15 changes: 8 additions & 7 deletions internal/export/ingest_pipelines_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// or more contributor license agreements. Licensed under the Elastic License;
// you may not use this file except in compliance with the Elastic License.

package export
package export_test

import (
"errors"
Expand All @@ -20,6 +20,7 @@ import (
"gopkg.in/dnaeon/go-vcr.v3/cassette"
"gopkg.in/yaml.v3"

"github.com/elastic/elastic-package/internal/export"
"github.com/elastic/elastic-package/internal/stack"

estest "github.com/elastic/elastic-package/internal/elasticsearch/test"
Expand Down Expand Up @@ -76,7 +77,7 @@ func (s *ingestPipelineExportSuite) SetupTest() {

writeAssignments := createTestWriteAssignments(s.PipelineIds, s.ExportDir)

err = IngestPipelines(s.T().Context(), client.API, writeAssignments)
err = export.IngestPipelines(s.T().Context(), client.API, writeAssignments)

s.Require().NoError(err)
} else {
Expand All @@ -89,7 +90,7 @@ func (s *ingestPipelineExportSuite) TestExportPipelines() {

outputDir := s.T().TempDir()
writeAssignments := createTestWriteAssignments(s.PipelineIds, outputDir)
err := IngestPipelines(s.T().Context(), client.API, writeAssignments)
err := export.IngestPipelines(s.T().Context(), client.API, writeAssignments)
s.Require().NoError(err)

filesExpected := countFiles(s.T(), s.ExportDir)
Expand All @@ -102,12 +103,12 @@ func (s *ingestPipelineExportSuite) TestExportPipelines() {
assertEqualExports(s.T(), s.ExportDir, outputDir)
}

func createTestWriteAssignments(pipelineIDs []string, outputDir string) PipelineWriteAssignments {
writeAssignments := make(PipelineWriteAssignments)
func createTestWriteAssignments(pipelineIDs []string, outputDir string) export.PipelineWriteAssignments {
writeAssignments := make(export.PipelineWriteAssignments)

for _, pipelineID := range pipelineIDs {
writeAssignments[pipelineID] = PipelineWriteLocation{
Type: PipelineWriteLocationTypeRoot,
writeAssignments[pipelineID] = export.PipelineWriteLocation{
Type: export.PipelineWriteLocationTypeRoot,
Name: pipelineID,
ParentPath: outputDir,
}
Expand Down
65 changes: 65 additions & 0 deletions internal/kibana/dashboards_as_code.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// 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

import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"

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

// DashboardsAPI is the prefix for the Kibana dashboards-as-code import API.
const DashboardsAPI = "/api/dashboards"

// ImportDashboardAsCode imports a dashboards-as-code JSON document via the
// /api/dashboards Kibana endpoint, storing it under the supplied id (the PUT
// form of the API guarantees the resulting saved object uses the id we choose,
// rather than a freshly generated one). Returns the id reported by Kibana,
// which should match the supplied id.
func (c *Client) ImportDashboardAsCode(ctx context.Context, id string, body []byte) (string, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I am not following the function, sorry. So ImportDashboardAsCode means:

  • attach a body and update whatever dashboard is at kibana? with the given id, if not exists, creates it? from the elastic-package pov, isnt this exporting it to kibana? where is the object saved?
    OR
  • is this sending the body to kibana and responding it with a good-to-go dashboard instead? in that case... can we rename the function, i feel Import semantics feels is a one way road, but is actually 2 ways? although i dont see where it saves it :D

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this creates or updates the dashboard in kibana.

the new dashboard API handles the underlying saved object management. you can create, update, and delete dashboards via the new API without touching saved objects.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

the semantics of import/export in elastic-package follow this naming convention. 'export' is exporting objects from kibana.

if id == "" {
return "", errors.New("dashboards-as-code import requires a non-empty id")
}
logger.Debugf("Import dashboards-as-code via Kibana dashboards API (id: %s)", id)

path := fmt.Sprintf("%s/%s", DashboardsAPI, id)
statusCode, respBody, err := c.put(ctx, path, body)
if err != nil {
return "", fmt.Errorf("could not import dashboards-as-code; API status code = %d; response body = %s: %w", statusCode, string(respBody), err)
}
if statusCode != http.StatusOK && statusCode != http.StatusCreated {
return "", fmt.Errorf("could not import dashboards-as-code; API status code = %d; response body = %s", statusCode, string(respBody))
}

var resp struct {
ID string `json:"id"`
}
if err := json.Unmarshal(respBody, &resp); err != nil {
return "", fmt.Errorf("could not decode dashboards-as-code import response (body: %s): %w", respBody, err)
}
if resp.ID == "" {
return "", errors.New("dashboards-as-code import response did not include an id")
}
return resp.ID, nil
}

// DeleteDashboard removes a dashboard by id via the dashboards-as-code API.
// This is used to clean up dashboards that were imported during the
// dashboards-as-code build step.
func (c *Client) DeleteDashboard(ctx context.Context, id string) error {
path := fmt.Sprintf("%s/%s", DashboardsAPI, id)
statusCode, respBody, err := c.delete(ctx, path)
if err != nil {
return fmt.Errorf("could not delete dashboard %s; API status code = %d; response body = %s: %w", id, statusCode, string(respBody), err)
}
if statusCode != http.StatusOK && statusCode != http.StatusNoContent && statusCode != http.StatusNotFound {
return fmt.Errorf("could not delete dashboard %s; API status code = %d; response body = %s", id, statusCode, string(respBody))
}
return nil
}
Loading