-
Notifications
You must be signed in to change notification settings - Fork 139
support for 'compilation' of new dashboards-as-code format via elastic-package build
#3531
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2b2b931
ff79937
d656ec3
bd992b6
4771503
ec0cee9
caf0257
29e55a6
a48735e
b7fabed
63f2e3d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 { | ||
| 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 | ||
| } | ||
| 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am not following the function, sorry. So ImportDashboardAsCode means:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.