From 2b2b931619a00a80349603cc121c3faf17937c13 Mon Sep 17 00:00:00 2001 From: Tom Myers Date: Thu, 7 May 2026 15:23:42 +0100 Subject: [PATCH 01/11] Compile dashboards-as-code during package build Adds support for authoring dashboards in the new dashboards-as-code JSON format under _dev/dashboards_as_code/. During elastic-package build, each source file is POSTed to Kibana's /api/dashboards endpoint, the returned dashboard id is exported back through the standard dashboards export pipeline, and the imported saved object is deleted from Kibana. The compile step runs before CopyWithoutDev so the existing build pipeline (encodeDashboards, validation) handles the resulting saved objects unchanged. BuildPackage now takes a context.Context and an optional KibanaClient on BuildOptions; the client is constructed lazily in cmd/build.go only when _dev/dashboards_as_code/ is present, so packages that do not use the feature still build offline. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/build.go | 33 ++++++- internal/builder/dashboards_as_code.go | 102 ++++++++++++++++++++ internal/builder/packages.go | 16 ++- internal/export/dashboards.go | 20 +++- internal/export/ingest_pipelines_test.go | 15 +-- internal/kibana/dashboards_as_code.go | 59 +++++++++++ internal/packages/archetype/package_test.go | 3 +- internal/packages/installer/factory.go | 3 +- 8 files changed, 236 insertions(+), 15 deletions(-) create mode 100644 internal/builder/dashboards_as_code.go create mode 100644 internal/kibana/dashboards_as_code.go diff --git a/cmd/build.go b/cmd/build.go index 29d577c479..59aef0502f 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -7,6 +7,8 @@ package cmd import ( "errors" "fmt" + "os" + "path/filepath" "github.com/spf13/cobra" @@ -14,6 +16,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" @@ -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, @@ -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) @@ -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 { + matches, err := filepath.Glob(filepath.Join(packageRoot, "_dev", "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 +} diff --git a/internal/builder/dashboards_as_code.go b/internal/builder/dashboards_as_code.go new file mode 100644 index 0000000000..b07b8592e0 --- /dev/null +++ b/internal/builder/dashboards_as_code.go @@ -0,0 +1,102 @@ +// 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" + + "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/dashboards_as_code" + +// compileDashboardsAsCode compiles each *.json file under +// /_dev/dashboards_as_code/ into a saved-object dashboard +// under /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 := export.CheckKibanaVersion(versionInfo); err != nil { + return fmt.Errorf("cannot compile dashboards-as-code on this Kibana version: %w", 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 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) + } + + id, err := kibanaClient.ImportDashboardAsCode(ctx, 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) { + 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 +} diff --git a/internal/builder/packages.go b/internal/builder/packages.go index 9784e7742e..dd092cc600 100644 --- a/internal/builder/packages.go +++ b/internal/builder/packages.go @@ -5,6 +5,7 @@ package builder import ( + "context" "errors" "fmt" "os" @@ -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" @@ -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. @@ -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. /packages// buildPackageRoot, err := BuildPackagesDirectory(options.PackageRoot, options.BuildDir) @@ -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 { diff --git a/internal/export/dashboards.go b/internal/export/dashboards.go index bf178043c6..5f069e97b5 100644 --- a/internal/export/dashboards.go +++ b/internal/export/dashboards.go @@ -46,22 +46,34 @@ 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//.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 } +// CheckKibanaVersion verifies that the connected Kibana version is supported for +// dashboards export and dashboards-as-code compilation. +func CheckKibanaVersion(info kibana.VersionInfo) error { + return checkKibanaVersion(info) +} + func checkKibanaVersion(info kibana.VersionInfo) error { version, err := semver.NewVersion(info.Number) if err != nil { diff --git a/internal/export/ingest_pipelines_test.go b/internal/export/ingest_pipelines_test.go index bc9404c3a4..951b21298a 100644 --- a/internal/export/ingest_pipelines_test.go +++ b/internal/export/ingest_pipelines_test.go @@ -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" @@ -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" @@ -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 { @@ -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) @@ -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, } diff --git a/internal/kibana/dashboards_as_code.go b/internal/kibana/dashboards_as_code.go new file mode 100644 index 0000000000..bb1d6d0488 --- /dev/null +++ b/internal/kibana/dashboards_as_code.go @@ -0,0 +1,59 @@ +// 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 and returns the saved-object id of the +// resulting dashboard. The same id is used to subsequently export and clean up +// the imported dashboard. +func (c *Client) ImportDashboardAsCode(ctx context.Context, body []byte) (string, error) { + logger.Debug("Import dashboards-as-code via Kibana dashboards API") + + statusCode, respBody, err := c.post(ctx, DashboardsAPI, 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 saved object by id. 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/dashboard/%s", SavedObjectsAPI, 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.StatusNotFound { + return fmt.Errorf("could not delete dashboard %s; API status code = %d; response body = %s", id, statusCode, string(respBody)) + } + return nil +} diff --git a/internal/packages/archetype/package_test.go b/internal/packages/archetype/package_test.go index 965d9c7544..b3740ef0c7 100644 --- a/internal/packages/archetype/package_test.go +++ b/internal/packages/archetype/package_test.go @@ -5,6 +5,7 @@ package archetype import ( + "context" "os" "path/filepath" "testing" @@ -101,7 +102,7 @@ func buildPackage(t *testing.T, repositoryRoot *os.Root, packageRoot string) err err := os.MkdirAll(buildDir, 0o755) require.NoError(t, err) - _, err = builder.BuildPackage(builder.BuildOptions{ + _, err = builder.BuildPackage(context.Background(), builder.BuildOptions{ PackageRoot: packageRoot, BuildDir: buildDir, RepositoryRoot: repositoryRoot, diff --git a/internal/packages/installer/factory.go b/internal/packages/installer/factory.go index d7a3a7bbec..63d9cf8b57 100644 --- a/internal/packages/installer/factory.go +++ b/internal/packages/installer/factory.go @@ -91,7 +91,7 @@ func NewForPackage(options Options) (Installer, error) { return CreateForZip(options.Kibana, options.ZipPath) } - target, err := builder.BuildPackage(builder.BuildOptions{ + target, err := builder.BuildPackage(context.TODO(), builder.BuildOptions{ PackageRoot: options.PackageRoot, CreateZip: supportsUploadZip, SignPackage: false, @@ -100,6 +100,7 @@ func NewForPackage(options Options) (Installer, error) { UpdateReadmes: false, SchemaURLs: options.SchemaURLs, RequiredInputsResolver: options.RequiredInputsResolver, + KibanaClient: options.Kibana, }) if err != nil { return nil, fmt.Errorf("failed to build package: %w", err) From ff79937d4cee1392681a09f3f4fbfe9eac4c6dd7 Mon Sep 17 00:00:00 2001 From: Tom Myers Date: Thu, 7 May 2026 15:30:09 +0100 Subject: [PATCH 02/11] Gate dashboards-as-code build on Kibana >= 9.4.0 The /api/dashboards import endpoint that backs dashboards-as-code is only available in Kibana 9.4.0 and later. The previous version check reused the dashboards export gate (which exists for an unrelated 8.8-8.10 export bug); replace it with a dedicated minimum-version check and drop the no-longer-needed CheckKibanaVersion export from the export package. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/builder/dashboards_as_code.go | 27 ++++++++++++++++++++++++-- internal/export/dashboards.go | 6 ------ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/internal/builder/dashboards_as_code.go b/internal/builder/dashboards_as_code.go index b07b8592e0..9311bb3f10 100644 --- a/internal/builder/dashboards_as_code.go +++ b/internal/builder/dashboards_as_code.go @@ -11,6 +11,8 @@ import ( "os" "path/filepath" + "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" @@ -19,6 +21,10 @@ import ( const dashboardsAsCodeDir = "_dev/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 // /_dev/dashboards_as_code/ into a saved-object dashboard // under /kibana/dashboard/. Each source file is imported @@ -48,8 +54,8 @@ func compileDashboardsAsCode(ctx context.Context, kibanaClient *kibana.Client, s if err != nil { return fmt.Errorf("getting Kibana version information: %w", err) } - if err := export.CheckKibanaVersion(versionInfo); err != nil { - return fmt.Errorf("cannot compile dashboards-as-code on this Kibana version: %w", err) + if err := checkDashboardsAsCodeKibanaVersion(versionInfo); err != nil { + return err } manifest, err := packages.ReadPackageManifestFromPackageRoot(sourcePackageRoot) @@ -65,6 +71,23 @@ func compileDashboardsAsCode(ctx context.Context, kibanaClient *kibana.Client, s return nil } +func checkDashboardsAsCodeKibanaVersion(info kibana.VersionInfo) error { + // Managed Kibana instances may not expose a version number; fall through + // and let the API surface any incompatibility at request time. + if info.Number == "" { + return nil + } + 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) diff --git a/internal/export/dashboards.go b/internal/export/dashboards.go index 5f069e97b5..08b39080a0 100644 --- a/internal/export/dashboards.go +++ b/internal/export/dashboards.go @@ -68,12 +68,6 @@ func TransformAndWriteDashboards(packageRoot, packageName string, objects []comm return nil } -// CheckKibanaVersion verifies that the connected Kibana version is supported for -// dashboards export and dashboards-as-code compilation. -func CheckKibanaVersion(info kibana.VersionInfo) error { - return checkKibanaVersion(info) -} - func checkKibanaVersion(info kibana.VersionInfo) error { version, err := semver.NewVersion(info.Number) if err != nil { From d656ec30de59c389e2770159f51c6aed187bed8b Mon Sep 17 00:00:00 2001 From: Tom Myers Date: Thu, 7 May 2026 15:31:25 +0100 Subject: [PATCH 03/11] Drop empty-version short-circuit in dashboards-as-code check Be consistent with the existing dashboards-export version check, which just lets semver parsing fail on an empty version string. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/builder/dashboards_as_code.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/internal/builder/dashboards_as_code.go b/internal/builder/dashboards_as_code.go index 9311bb3f10..b3dceaa1b9 100644 --- a/internal/builder/dashboards_as_code.go +++ b/internal/builder/dashboards_as_code.go @@ -72,11 +72,6 @@ func compileDashboardsAsCode(ctx context.Context, kibanaClient *kibana.Client, s } func checkDashboardsAsCodeKibanaVersion(info kibana.VersionInfo) error { - // Managed Kibana instances may not expose a version number; fall through - // and let the API surface any incompatibility at request time. - if info.Number == "" { - return nil - } v, err := semver.NewVersion(info.Number) if err != nil { return fmt.Errorf("cannot parse Kibana version %s: %w", info.Number, err) From bd992b6019bb4ae93407ac56eb2dab84f61075d6 Mon Sep 17 00:00:00 2001 From: Tom Myers Date: Thu, 7 May 2026 15:43:33 +0100 Subject: [PATCH 04/11] Use dashboards-as-code DELETE endpoint for cleanup Switch the imported-dashboard cleanup from DELETE /api/saved_objects/dashboard/ to DELETE /api/dashboards/, the companion of the import endpoint we already use. Accept 204 No Content in addition to 200 since the dashboards-as-code DELETE returns 204 on success. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/kibana/dashboards_as_code.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/kibana/dashboards_as_code.go b/internal/kibana/dashboards_as_code.go index bb1d6d0488..e3183aadb2 100644 --- a/internal/kibana/dashboards_as_code.go +++ b/internal/kibana/dashboards_as_code.go @@ -44,15 +44,16 @@ func (c *Client) ImportDashboardAsCode(ctx context.Context, body []byte) (string return resp.ID, nil } -// DeleteDashboard removes a dashboard saved object by id. This is used to clean -// up dashboards that were imported during the dashboards-as-code build step. +// 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/dashboard/%s", SavedObjectsAPI, id) + 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.StatusNotFound { + 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 From 4771503440bc27f694b298ccb2739bd09d70185a Mon Sep 17 00:00:00 2001 From: Tom Myers Date: Thu, 7 May 2026 15:58:56 +0100 Subject: [PATCH 05/11] Use source filename as the dashboard id for stable output POST /api/dashboards generates a fresh UUID on every import, which made the compiled kibana/dashboard/.json filename non-deterministic and caused old artifacts to accumulate in the source tree on each rebuild. Switch to PUT /api/dashboards/ and derive the id from the source filename (sans .json extension), so _dev/dashboards_as_code/foo.json always compiles to kibana/dashboard/-foo.json after standardizeObjectID prefixes the package name. The compiled output is now stable across builds and reviewable in PRs. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/builder/dashboards_as_code.go | 7 ++++++- internal/kibana/dashboards_as_code.go | 17 +++++++++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/internal/builder/dashboards_as_code.go b/internal/builder/dashboards_as_code.go index b3dceaa1b9..542f28a43d 100644 --- a/internal/builder/dashboards_as_code.go +++ b/internal/builder/dashboards_as_code.go @@ -10,6 +10,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/Masterminds/semver/v3" @@ -91,7 +92,11 @@ func compileDashboardAsCodeFile(ctx context.Context, kibanaClient *kibana.Client return fmt.Errorf("reading dashboards-as-code source failed: %w", err) } - id, err := kibanaClient.ImportDashboardAsCode(ctx, body) + // 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) } diff --git a/internal/kibana/dashboards_as_code.go b/internal/kibana/dashboards_as_code.go index e3183aadb2..b9c5dd48fb 100644 --- a/internal/kibana/dashboards_as_code.go +++ b/internal/kibana/dashboards_as_code.go @@ -18,13 +18,18 @@ import ( const DashboardsAPI = "/api/dashboards" // ImportDashboardAsCode imports a dashboards-as-code JSON document via the -// /api/dashboards Kibana endpoint and returns the saved-object id of the -// resulting dashboard. The same id is used to subsequently export and clean up -// the imported dashboard. -func (c *Client) ImportDashboardAsCode(ctx context.Context, body []byte) (string, error) { - logger.Debug("Import dashboards-as-code via Kibana dashboards API") +// /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) { + 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) - statusCode, respBody, err := c.post(ctx, DashboardsAPI, body) + 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) } From ec0cee99b059ca488f071ea54beeb672cf992ddf Mon Sep 17 00:00:00 2001 From: Tom Myers Date: Thu, 7 May 2026 16:15:21 +0100 Subject: [PATCH 06/11] Move dashboards-as-code source under _dev/build/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group with other build-time inputs (build.yml, docs/) under _dev/build/ rather than introducing a top-level _dev/ category. Dashboards-as-code files now live at _dev/build/dashboards_as_code/.json. Note: enabling this in real packages requires elastic/package-spec to allow _dev/build/dashboards_as_code/ — the spec change must land and the package-spec dependency be bumped before the feature is usable end to end (today the linter rejects the unknown folder). Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/build.go | 2 +- internal/builder/dashboards_as_code.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/build.go b/cmd/build.go index 59aef0502f..9cb3322c4b 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -138,7 +138,7 @@ func buildCommandAction(cmd *cobra.Command, args []string) error { // hasDashboardsAsCode reports whether the package source contains any // dashboards-as-code JSON files that would require Kibana to compile. func hasDashboardsAsCode(packageRoot string) bool { - matches, err := filepath.Glob(filepath.Join(packageRoot, "_dev", "dashboards_as_code", "*.json")) + matches, err := filepath.Glob(filepath.Join(packageRoot, "_dev", "build", "dashboards_as_code", "*.json")) if err != nil { return false } diff --git a/internal/builder/dashboards_as_code.go b/internal/builder/dashboards_as_code.go index 542f28a43d..bf583ef06f 100644 --- a/internal/builder/dashboards_as_code.go +++ b/internal/builder/dashboards_as_code.go @@ -20,7 +20,7 @@ import ( "github.com/elastic/elastic-package/internal/packages" ) -const dashboardsAsCodeDir = "_dev/dashboards_as_code" +const dashboardsAsCodeDir = "_dev/build/dashboards_as_code" // minDashboardsAsCodeKibanaVersion is the first Kibana version that supports // the dashboards-as-code import API (POST /api/dashboards). From caf0257c8cfba34de3add83d87a4db04914c1c9a Mon Sep 17 00:00:00 2001 From: Tom Myers Date: Thu, 7 May 2026 16:31:39 +0100 Subject: [PATCH 07/11] Add CI fixture and dedicated test script for dashboards-as-code Adds test/packages/dashboards_as_code/sample/ with a single source under _dev/build/dashboards_as_code/overview.json. The compiled saved object is intentionally absent so the test can prove the build wrote it. Adds scripts/test-check-packages-dashboards-as-code.sh (modeled on the composable-packages script): brings up the stack, runs `elastic-package check` on each fixture, then for every .json source asserts that kibana/dashboard/-.json was written by the build. Pre-flight guards against fixtures that already contain compiled SO files. Cleanup trap removes generated artefacts and brings the stack down. Wires the new target into the umbrella test-check-packages Makefile rule. The fixture and test require a package-spec release that allows _dev/build/dashboards_as_code/ and a corresponding bump of the github.com/elastic/package-spec/v3 dependency before this branch can pass CI. Co-Authored-By: Claude Opus 4.7 (1M context) --- Makefile | 5 +- .../test-check-packages-dashboards-as-code.sh | 92 +++++++++++++++++++ .../build/dashboards_as_code/overview.json | 37 ++++++++ .../dashboards_as_code/sample/changelog.yml | 6 ++ .../dashboards_as_code/sample/docs/README.md | 6 ++ .../dashboards_as_code/sample/manifest.yml | 14 +++ 6 files changed, 159 insertions(+), 1 deletion(-) create mode 100755 scripts/test-check-packages-dashboards-as-code.sh create mode 100644 test/packages/dashboards_as_code/sample/_dev/build/dashboards_as_code/overview.json create mode 100644 test/packages/dashboards_as_code/sample/changelog.yml create mode 100644 test/packages/dashboards_as_code/sample/docs/README.md create mode 100644 test/packages/dashboards_as_code/sample/manifest.yml diff --git a/Makefile b/Makefile index 8e69bd9a76..f9cff57049 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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 diff --git a/scripts/test-check-packages-dashboards-as-code.sh b/scripts/test-check-packages-dashboards-as-code.sh new file mode 100755 index 0000000000..2ab9224495 --- /dev/null +++ b/scripts/test-check-packages-dashboards-as-code.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +# Tests the dashboards-as-code build flow against a real Kibana. +# +# For each fixture under test/packages/dashboards_as_code/, this script: +# 1. Brings up the stack (the build step needs Kibana to compile dashboards). +# 2. Asserts the fixture has no compiled saved object yet (the build must +# produce it, not just copy a pre-existing one). +# 3. Runs `elastic-package check` (lint + build). +# 4. Asserts that for every .json source under +# _dev/build/dashboards_as_code/ the build wrote +# kibana/dashboard/-.json. +# +# The compiled dashboard files are not committed: any kibana/dashboard +# content that survives a run is removed in the cleanup trap. + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + +source "${SCRIPT_DIR}/stack_parameters.sh" +source "${SCRIPT_DIR}/stack_helpers.sh" + +set -euxo pipefail + +PACKAGES_PATH="test/packages/dashboards_as_code" + +cleanup() { + local r=$? + if [ "${r}" -ne 0 ]; then + echo "^^^ +++" + fi + echo "~~~ elastic-package cleanup" + + if is_stack_created; then + elastic-package stack dump -v \ + --output "build/elastic-stack-dump/check-dashboards-as-code" || true + elastic-package stack down -v + fi + + for d in "${PACKAGES_PATH}"/*/; do + elastic-package clean -C "$d" -v || true + # Remove freshly-compiled dashboard artifacts so the working tree stays clean. + rm -rf "${d}kibana/dashboard" + if [ -d "${d}kibana" ] && [ -z "$(ls -A "${d}kibana")" ]; then + rmdir "${d}kibana" + fi + done + + exit $r +} + +trap cleanup EXIT + +ELASTIC_PACKAGE_LINKS_FILE_PATH="$(pwd)/scripts/links_table.yml" +export ELASTIC_PACKAGE_LINKS_FILE_PATH + +echo "--- Prepare Elastic stack" +stack_args=$(set +x; stack_version_args) +stack_args="${stack_args} $(set +x; stack_provider_args)" +elastic-package stack update -v ${stack_args} +elastic-package stack up -d -v ${stack_args} +elastic-package stack status + +# Read package name from manifest.yml without a YAML parser. +package_name() { + local manifest="$1/manifest.yml" + awk -F': ' '$1 == "name" { gsub(/[" ]/, "", $2); print $2; exit }' "$manifest" +} + +for d in "${PACKAGES_PATH}"/*/; do + package_name=$(package_name "$d") + echo "--- Checking package ${d} (name: ${package_name})" + + # Sanity-check the fixture: the compiled saved object must NOT exist yet. + # If it does, the test would not actually be exercising the build step. + if compgen -G "${d}kibana/dashboard/*.json" > /dev/null; then + echo "Fixture ${d} already contains compiled dashboards; remove them so the test exercises the build step." + exit 1 + fi + + elastic-package check -C "$d" -v + + # For every source, the build must have written the standardised SO. + for source in "${d}_dev/build/dashboards_as_code"/*.json; do + source_id=$(basename "${source}" .json) + expected="${d}kibana/dashboard/${package_name}-${source_id}.json" + if [ ! -f "${expected}" ]; then + echo "Build did not produce expected dashboard: ${expected}" + exit 1 + fi + echo " OK: ${expected}" + done +done diff --git a/test/packages/dashboards_as_code/sample/_dev/build/dashboards_as_code/overview.json b/test/packages/dashboards_as_code/sample/_dev/build/dashboards_as_code/overview.json new file mode 100644 index 0000000000..b9c05e8d78 --- /dev/null +++ b/test/packages/dashboards_as_code/sample/_dev/build/dashboards_as_code/overview.json @@ -0,0 +1,37 @@ +{ + "options": { + "hide_panel_titles": false, + "hide_panel_borders": false, + "use_margins": true, + "auto_apply_filters": true, + "sync_colors": false, + "sync_cursor": true, + "sync_tooltips": false + }, + "query": { + "expression": "", + "language": "kql" + }, + "title": "Dashboard [2026-05-07T16:07:44.633+01:00]", + "panels": [ + { + "grid": { + "y": 0, + "x": 0, + "w": 6, + "h": 2 + }, + "config": { + "hide_title": true, + "hide_border": true, + "content": "test dashboard", + "settings": { + "open_links_in_new_tab": true + } + }, + "id": "87c99aa8-295a-4d09-aad9-8e45d1c2ee1b", + "type": "markdown" + } + ], + "pinned_panels": [] +} diff --git a/test/packages/dashboards_as_code/sample/changelog.yml b/test/packages/dashboards_as_code/sample/changelog.yml new file mode 100644 index 0000000000..e00f881335 --- /dev/null +++ b/test/packages/dashboards_as_code/sample/changelog.yml @@ -0,0 +1,6 @@ +# newer versions go on top +- version: "0.0.1" + changes: + - description: Initial draft of the package + type: enhancement + link: https://github.com/elastic/integrations/pull/1 diff --git a/test/packages/dashboards_as_code/sample/docs/README.md b/test/packages/dashboards_as_code/sample/docs/README.md new file mode 100644 index 0000000000..3949729018 --- /dev/null +++ b/test/packages/dashboards_as_code/sample/docs/README.md @@ -0,0 +1,6 @@ +# Dashboards-as-code Sample + +Fixture exercising the dashboards-as-code build step. The dashboard +source lives in `_dev/build/dashboards_as_code/` and is compiled into +`kibana/dashboard/` during `elastic-package build` against a running +Kibana 9.4.0+ stack. diff --git a/test/packages/dashboards_as_code/sample/manifest.yml b/test/packages/dashboards_as_code/sample/manifest.yml new file mode 100644 index 0000000000..8326f1fe99 --- /dev/null +++ b/test/packages/dashboards_as_code/sample/manifest.yml @@ -0,0 +1,14 @@ +format_version: 3.6.0 +name: sample +title: "Dashboards-as-code Sample" +version: 0.0.1 +description: "Fixture exercising the dashboards-as-code build step." +type: integration +categories: + - custom +conditions: + kibana: + version: "^9.4.0" +owner: + github: elastic/integrations + type: elastic From 29e55a69a858cd9683aceb831567bda26995b03a Mon Sep 17 00:00:00 2001 From: Tom Myers Date: Fri, 8 May 2026 10:06:58 +0100 Subject: [PATCH 08/11] Move dashboards-as-code sources to _dev/shared The package-spec already exposes _dev/shared as a generic shared-files folder (with additionalContents: true on integration packages, and the content-package spec explicitly mentioning dashboards-as-YML files there). Putting our sources in _dev/shared lets the feature ship without an upstream package-spec change. Convention: any *.json file directly under _dev/shared/ is treated as a dashboards-as-code source; the filename (sans .json) becomes the id. Other files in _dev/shared remain untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/build.go | 2 +- internal/builder/dashboards_as_code.go | 2 +- scripts/test-check-packages-dashboards-as-code.sh | 2 +- .../_dev/{build/dashboards_as_code => shared}/overview.json | 0 test/packages/dashboards_as_code/sample/docs/README.md | 5 ++--- 5 files changed, 5 insertions(+), 6 deletions(-) rename test/packages/dashboards_as_code/sample/_dev/{build/dashboards_as_code => shared}/overview.json (100%) diff --git a/cmd/build.go b/cmd/build.go index 9cb3322c4b..453ae08bae 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -138,7 +138,7 @@ func buildCommandAction(cmd *cobra.Command, args []string) error { // hasDashboardsAsCode reports whether the package source contains any // dashboards-as-code JSON files that would require Kibana to compile. func hasDashboardsAsCode(packageRoot string) bool { - matches, err := filepath.Glob(filepath.Join(packageRoot, "_dev", "build", "dashboards_as_code", "*.json")) + matches, err := filepath.Glob(filepath.Join(packageRoot, "_dev", "shared", "*.json")) if err != nil { return false } diff --git a/internal/builder/dashboards_as_code.go b/internal/builder/dashboards_as_code.go index bf583ef06f..7e1dbb0ce1 100644 --- a/internal/builder/dashboards_as_code.go +++ b/internal/builder/dashboards_as_code.go @@ -20,7 +20,7 @@ import ( "github.com/elastic/elastic-package/internal/packages" ) -const dashboardsAsCodeDir = "_dev/build/dashboards_as_code" +const dashboardsAsCodeDir = "_dev/shared" // minDashboardsAsCodeKibanaVersion is the first Kibana version that supports // the dashboards-as-code import API (POST /api/dashboards). diff --git a/scripts/test-check-packages-dashboards-as-code.sh b/scripts/test-check-packages-dashboards-as-code.sh index 2ab9224495..4077934207 100755 --- a/scripts/test-check-packages-dashboards-as-code.sh +++ b/scripts/test-check-packages-dashboards-as-code.sh @@ -80,7 +80,7 @@ for d in "${PACKAGES_PATH}"/*/; do elastic-package check -C "$d" -v # For every source, the build must have written the standardised SO. - for source in "${d}_dev/build/dashboards_as_code"/*.json; do + for source in "${d}_dev/shared"/*.json; do source_id=$(basename "${source}" .json) expected="${d}kibana/dashboard/${package_name}-${source_id}.json" if [ ! -f "${expected}" ]; then diff --git a/test/packages/dashboards_as_code/sample/_dev/build/dashboards_as_code/overview.json b/test/packages/dashboards_as_code/sample/_dev/shared/overview.json similarity index 100% rename from test/packages/dashboards_as_code/sample/_dev/build/dashboards_as_code/overview.json rename to test/packages/dashboards_as_code/sample/_dev/shared/overview.json diff --git a/test/packages/dashboards_as_code/sample/docs/README.md b/test/packages/dashboards_as_code/sample/docs/README.md index 3949729018..45478b368c 100644 --- a/test/packages/dashboards_as_code/sample/docs/README.md +++ b/test/packages/dashboards_as_code/sample/docs/README.md @@ -1,6 +1,5 @@ # Dashboards-as-code Sample Fixture exercising the dashboards-as-code build step. The dashboard -source lives in `_dev/build/dashboards_as_code/` and is compiled into -`kibana/dashboard/` during `elastic-package build` against a running -Kibana 9.4.0+ stack. +source lives in `_dev/shared/` and is compiled into `kibana/dashboard/` +during `elastic-package build` against a running Kibana 9.4.0+ stack. From a48735e7a9c92f840d58fcce57126c6713868d9b Mon Sep 17 00:00:00 2001 From: Tom Myers Date: Fri, 8 May 2026 10:24:35 +0100 Subject: [PATCH 09/11] Make dashboards-as-code compilation an explicit build flag Replace the auto-detection (any *.json under _dev/shared/) with an opt-in --compile-dashboards-as-code flag on elastic-package build. This avoids interpreting unrelated *.json files in _dev/shared/ as dashboards-as-code sources, and makes the intent (and the resulting dependency on a running Kibana) explicit at the command line. The builder treats the kibanaClient pointer as the gate: callers that want compilation pass a non-nil client; otherwise compileDashboardsAsCode returns immediately. `elastic-package check` doesn't forward flags to its composed `build` sub-command, so the test script now runs `lint` and `build --compile-dashboards-as-code` separately to exercise the full path. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/build.go | 25 +++---------------- internal/builder/dashboards_as_code.go | 23 +++++++++-------- internal/cobraext/flags.go | 3 +++ .../test-check-packages-dashboards-as-code.sh | 7 ++++-- 4 files changed, 23 insertions(+), 35 deletions(-) diff --git a/cmd/build.go b/cmd/build.go index 453ae08bae..13bf1f30d8 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -7,8 +7,6 @@ package cmd import ( "errors" "fmt" - "os" - "path/filepath" "github.com/spf13/cobra" @@ -48,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) } @@ -57,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") @@ -106,7 +106,7 @@ func buildCommandAction(cmd *cobra.Command, args []string) error { requiredInputsResolver := requiredinputs.NewRequiredInputsResolver(eprClient) var kibanaClient *kibana.Client - if hasDashboardsAsCode(packageRoot) { + 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) @@ -134,22 +134,3 @@ 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 { - matches, err := filepath.Glob(filepath.Join(packageRoot, "_dev", "shared", "*.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 -} diff --git a/internal/builder/dashboards_as_code.go b/internal/builder/dashboards_as_code.go index 7e1dbb0ce1..8adbea0eb0 100644 --- a/internal/builder/dashboards_as_code.go +++ b/internal/builder/dashboards_as_code.go @@ -27,16 +27,22 @@ const dashboardsAsCodeDir = "_dev/shared" var minDashboardsAsCodeKibanaVersion = semver.MustParse("9.4.0") // compileDashboardsAsCode compiles each *.json file under -// /_dev/dashboards_as_code/ into a saved-object dashboard -// under /kibana/dashboard/. Each source file is imported -// into the connected Kibana via POST /api/dashboards, the resulting dashboard +// /_dev/shared/ into a saved-object dashboard under +// /kibana/dashboard/. Each source file is imported into +// the connected Kibana via PUT /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. +// 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 { @@ -46,11 +52,6 @@ func compileDashboardsAsCode(ctx context.Context, kibanaClient *kibana.Client, s 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) diff --git a/internal/cobraext/flags.go b/internal/cobraext/flags.go index e8c93e1348..ee84df54f6 100644 --- a/internal/cobraext/flags.go +++ b/internal/cobraext/flags.go @@ -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" diff --git a/scripts/test-check-packages-dashboards-as-code.sh b/scripts/test-check-packages-dashboards-as-code.sh index 4077934207..73925f9148 100755 --- a/scripts/test-check-packages-dashboards-as-code.sh +++ b/scripts/test-check-packages-dashboards-as-code.sh @@ -6,7 +6,9 @@ # 1. Brings up the stack (the build step needs Kibana to compile dashboards). # 2. Asserts the fixture has no compiled saved object yet (the build must # produce it, not just copy a pre-existing one). -# 3. Runs `elastic-package check` (lint + build). +# 3. Runs `elastic-package lint` then `elastic-package build +# --compile-dashboards-as-code` (the equivalent of `check` but with +# the opt-in flag wired to build). # 4. Asserts that for every .json source under # _dev/build/dashboards_as_code/ the build wrote # kibana/dashboard/-.json. @@ -77,7 +79,8 @@ for d in "${PACKAGES_PATH}"/*/; do exit 1 fi - elastic-package check -C "$d" -v + elastic-package lint -C "$d" -v + elastic-package build -C "$d" -v --compile-dashboards-as-code # For every source, the build must have written the standardised SO. for source in "${d}_dev/shared"/*.json; do From b7fabedd471a951f2217b8e3d121cc4cd002ae5c Mon Sep 17 00:00:00 2001 From: Tom Myers Date: Fri, 8 May 2026 10:27:18 +0100 Subject: [PATCH 10/11] remove check for cancelled context in dashboard cleanup and log the error as WARN not DEBUG --- internal/builder/dashboards_as_code.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/internal/builder/dashboards_as_code.go b/internal/builder/dashboards_as_code.go index 8adbea0eb0..d0ae6c5e43 100644 --- a/internal/builder/dashboards_as_code.go +++ b/internal/builder/dashboards_as_code.go @@ -6,7 +6,6 @@ package builder import ( "context" - "errors" "fmt" "os" "path/filepath" @@ -107,10 +106,7 @@ func compileDashboardAsCodeFile(ctx context.Context, kibanaClient *kibana.Client // 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) { - return - } - logger.Debugf("Failed to delete imported dashboard %s during cleanup: %v", id, cleanupErr) + logger.Warnf("Failed to delete imported dashboard %s during cleanup: %v", id, cleanupErr) } }() From 63f2e3de20783caddfc4d4e6b79086ba51351ce1 Mon Sep 17 00:00:00 2001 From: Tom Myers Date: Fri, 8 May 2026 11:05:46 +0100 Subject: [PATCH 11/11] Pass context.WithoutCancel(ctx) to dashboard cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contextcheck linter flags passing context.Background() inside a function that already has a ctx parameter. Use context.WithoutCancel instead — it preserves any values/tracing on the parent ctx while detaching from its cancellation, so cleanup still runs if the build's context has been cancelled by the time the defer fires. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/builder/dashboards_as_code.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/builder/dashboards_as_code.go b/internal/builder/dashboards_as_code.go index d0ae6c5e43..2ffdaf3b93 100644 --- a/internal/builder/dashboards_as_code.go +++ b/internal/builder/dashboards_as_code.go @@ -102,10 +102,11 @@ func compileDashboardAsCodeFile(ctx context.Context, kibanaClient *kibana.Client } // 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. + // 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(context.Background(), id); cleanupErr != nil { + if cleanupErr := kibanaClient.DeleteDashboard(cleanupCtx, id); cleanupErr != nil { logger.Warnf("Failed to delete imported dashboard %s during cleanup: %v", id, cleanupErr) } }()