Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
33 changes: 32 additions & 1 deletion cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ package cmd
import (
"errors"
"fmt"
"os"
"path/filepath"

"github.com/spf13/cobra"

"github.com/elastic/elastic-package/internal/builder"
"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 @@ -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 hasDashboardsAsCode(packageRoot) {
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 All @@ -122,3 +134,22 @@ func buildCommandAction(cmd *cobra.Command, args []string) error {
cmd.Println("Done")
return nil
}

// hasDashboardsAsCode reports whether the package source contains any
// dashboards-as-code JSON files that would require Kibana to compile.
func hasDashboardsAsCode(packageRoot string) bool {
Comment thread
tommyers-elastic marked this conversation as resolved.
Outdated
matches, err := filepath.Glob(filepath.Join(packageRoot, "_dev", "build", "dashboards_as_code", "*.json"))
if err != nil {
return false
}
if len(matches) == 0 {
return false
}
for _, m := range matches {
info, err := os.Stat(m)
if err == nil && !info.IsDir() {
return true
}
}
return false
}
125 changes: 125 additions & 0 deletions internal/builder/dashboards_as_code.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// 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"
"errors"
"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/build/dashboards_as_code"

// 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/dashboards_as_code/ into a saved-object dashboard
// under <sourcePackageRoot>/kibana/dashboard/. Each source file is imported
// into the connected Kibana via POST /api/dashboards, the resulting dashboard
// is exported back through the standard dashboards export pipeline, and the
// imported saved object is then deleted from Kibana.
//
// If the source directory does not exist or contains no JSON files, this
// function is a no-op and no Kibana connection is attempted. When source
// files are present and kibanaClient is nil, returns an error.
func compileDashboardsAsCode(ctx context.Context, kibanaClient *kibana.Client, sourcePackageRoot string) error {
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
}

if kibanaClient == nil {
return fmt.Errorf("package contains %s but no Kibana client is configured; "+
"set ELASTIC_PACKAGE_KIBANA_HOST or run 'elastic-package stack up' first", dashboardsAsCodeDir)
}

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. Use a fresh context so cleanup runs
// even if ctx has been cancelled by the time we reach this point.
defer func() {
if cleanupErr := kibanaClient.DeleteDashboard(context.Background(), id); cleanupErr != nil {
if errors.Is(cleanupErr, context.Canceled) {
Comment thread
tommyers-elastic marked this conversation as resolved.
Outdated
return
}
logger.Debugf("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
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
Loading