mirror of https://github.com/databricks/cli.git
Add `bundle summary` to display URLs for deployed resources (#1731)
## Changes Adds a textual output to the `databricks bundle summary` command, which includes URLs of deployed resources. Example usage: ``` $ databricks bundle summary Name: my_pipeline Target: dev Workspace: Host: https://domain.databricks.com User: user@databricks.com Path: /Users/user@databricks.com/.bundle/my_pipeline/dev Resources: Jobs: my_project_job: Name: [dev lennart] my_project_job URL: https://domain.databricks.com/jobs/206899209187287?o=6051921418418893 Pipelines: my_project_pipeline: Name: [dev lennart] my_project_pipeline URL: https://domain.databricks.com/pipelines/3f849fd5-ba7d-47fa-a34c-c6bf034b4f58?o=6051921418418893 ``` Notes: * The top headers of the output are the same as those from the existing `bundle validate` command * URLs are colored light blue in the output * For resources that haven't been deployed yet, we show `(not deployed)` in place of the URL --------- Co-authored-by: Pieter Noordhuis <pieter.noordhuis@databricks.com> Co-authored-by: Pieter Noordhuis <pcnoordhuis@gmail.com>
This commit is contained in:
parent
dedec58e41
commit
c5043c3d9d
|
@ -0,0 +1,65 @@
|
|||
package mutator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/databricks/cli/bundle"
|
||||
"github.com/databricks/cli/libs/diag"
|
||||
)
|
||||
|
||||
type initializeURLs struct {
|
||||
}
|
||||
|
||||
// InitializeURLs makes sure the URL field of each resource is configured.
|
||||
// NOTE: since this depends on an extra API call, this mutator adds some extra
|
||||
// latency. As such, it should only be used when needed.
|
||||
// This URL field is used for the output of the 'bundle summary' CLI command.
|
||||
func InitializeURLs() bundle.Mutator {
|
||||
return &initializeURLs{}
|
||||
}
|
||||
|
||||
func (m *initializeURLs) Name() string {
|
||||
return "InitializeURLs"
|
||||
}
|
||||
|
||||
func (m *initializeURLs) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics {
|
||||
workspaceId, err := b.WorkspaceClient().CurrentWorkspaceID(ctx)
|
||||
if err != nil {
|
||||
return diag.FromErr(err)
|
||||
}
|
||||
orgId := strconv.FormatInt(workspaceId, 10)
|
||||
host := b.WorkspaceClient().Config.CanonicalHostName()
|
||||
initializeForWorkspace(b, orgId, host)
|
||||
return nil
|
||||
}
|
||||
|
||||
func initializeForWorkspace(b *bundle.Bundle, orgId string, host string) error {
|
||||
baseURL, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add ?o=<workspace id> only if <workspace id> wasn't in the subdomain already.
|
||||
// The ?o= is needed when vanity URLs / legacy workspace URLs are used.
|
||||
// If it's not needed we prefer to leave it out since these URLs are rather
|
||||
// long for most terminals.
|
||||
//
|
||||
// See https://docs.databricks.com/en/workspace/workspace-details.html for
|
||||
// further reading about the '?o=' suffix.
|
||||
if !strings.Contains(baseURL.Hostname(), orgId) {
|
||||
values := baseURL.Query()
|
||||
values.Add("o", orgId)
|
||||
baseURL.RawQuery = values.Encode()
|
||||
}
|
||||
|
||||
for _, group := range b.Config.Resources.AllResources() {
|
||||
for _, r := range group.Resources {
|
||||
r.InitializeURL(*baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,130 @@
|
|||
package mutator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/databricks/cli/bundle"
|
||||
"github.com/databricks/cli/bundle/config"
|
||||
"github.com/databricks/cli/bundle/config/resources"
|
||||
"github.com/databricks/databricks-sdk-go/service/catalog"
|
||||
"github.com/databricks/databricks-sdk-go/service/compute"
|
||||
"github.com/databricks/databricks-sdk-go/service/jobs"
|
||||
"github.com/databricks/databricks-sdk-go/service/ml"
|
||||
"github.com/databricks/databricks-sdk-go/service/pipelines"
|
||||
"github.com/databricks/databricks-sdk-go/service/serving"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInitializeURLs(t *testing.T) {
|
||||
b := &bundle.Bundle{
|
||||
Config: config.Root{
|
||||
Workspace: config.Workspace{
|
||||
Host: "https://mycompany.databricks.com/",
|
||||
},
|
||||
Resources: config.Resources{
|
||||
Jobs: map[string]*resources.Job{
|
||||
"job1": {
|
||||
ID: "1",
|
||||
JobSettings: &jobs.JobSettings{Name: "job1"},
|
||||
},
|
||||
},
|
||||
Pipelines: map[string]*resources.Pipeline{
|
||||
"pipeline1": {
|
||||
ID: "3",
|
||||
PipelineSpec: &pipelines.PipelineSpec{Name: "pipeline1"},
|
||||
},
|
||||
},
|
||||
Experiments: map[string]*resources.MlflowExperiment{
|
||||
"experiment1": {
|
||||
ID: "4",
|
||||
Experiment: &ml.Experiment{Name: "experiment1"},
|
||||
},
|
||||
},
|
||||
Models: map[string]*resources.MlflowModel{
|
||||
"model1": {
|
||||
ID: "a model uses its name for identifier",
|
||||
Model: &ml.Model{Name: "a model uses its name for identifier"},
|
||||
},
|
||||
},
|
||||
ModelServingEndpoints: map[string]*resources.ModelServingEndpoint{
|
||||
"servingendpoint1": {
|
||||
ID: "my_serving_endpoint",
|
||||
CreateServingEndpoint: &serving.CreateServingEndpoint{
|
||||
Name: "my_serving_endpoint",
|
||||
},
|
||||
},
|
||||
},
|
||||
RegisteredModels: map[string]*resources.RegisteredModel{
|
||||
"registeredmodel1": {
|
||||
ID: "8",
|
||||
CreateRegisteredModelRequest: &catalog.CreateRegisteredModelRequest{
|
||||
Name: "my_registered_model",
|
||||
},
|
||||
},
|
||||
},
|
||||
QualityMonitors: map[string]*resources.QualityMonitor{
|
||||
"qualityMonitor1": {
|
||||
CreateMonitor: &catalog.CreateMonitor{
|
||||
TableName: "catalog.schema.qualityMonitor1",
|
||||
},
|
||||
},
|
||||
},
|
||||
Schemas: map[string]*resources.Schema{
|
||||
"schema1": {
|
||||
ID: "catalog.schema",
|
||||
CreateSchema: &catalog.CreateSchema{
|
||||
Name: "schema",
|
||||
},
|
||||
},
|
||||
},
|
||||
Clusters: map[string]*resources.Cluster{
|
||||
"cluster1": {
|
||||
ID: "1017-103929-vlr7jzcf",
|
||||
ClusterSpec: &compute.ClusterSpec{
|
||||
ClusterName: "cluster1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expectedURLs := map[string]string{
|
||||
"job1": "https://mycompany.databricks.com/jobs/1?o=123456",
|
||||
"pipeline1": "https://mycompany.databricks.com/pipelines/3?o=123456",
|
||||
"experiment1": "https://mycompany.databricks.com/ml/experiments/4?o=123456",
|
||||
"model1": "https://mycompany.databricks.com/ml/models/a%20model%20uses%20its%20name%20for%20identifier?o=123456",
|
||||
"servingendpoint1": "https://mycompany.databricks.com/ml/endpoints/my_serving_endpoint?o=123456",
|
||||
"registeredmodel1": "https://mycompany.databricks.com/explore/data/models/8?o=123456",
|
||||
"qualityMonitor1": "https://mycompany.databricks.com/explore/data/catalog/schema/qualityMonitor1?o=123456",
|
||||
"schema1": "https://mycompany.databricks.com/explore/data/catalog/schema?o=123456",
|
||||
"cluster1": "https://mycompany.databricks.com/compute/clusters/1017-103929-vlr7jzcf?o=123456",
|
||||
}
|
||||
|
||||
initializeForWorkspace(b, "123456", "https://mycompany.databricks.com/")
|
||||
|
||||
for _, group := range b.Config.Resources.AllResources() {
|
||||
for key, r := range group.Resources {
|
||||
require.Equal(t, expectedURLs[key], r.GetURL(), "Unexpected URL for "+key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializeURLsWithoutOrgId(t *testing.T) {
|
||||
b := &bundle.Bundle{
|
||||
Config: config.Root{
|
||||
Resources: config.Resources{
|
||||
Jobs: map[string]*resources.Job{
|
||||
"job1": {
|
||||
ID: "1",
|
||||
JobSettings: &jobs.JobSettings{Name: "job1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
initializeForWorkspace(b, "123456", "https://adb-123456.azuredatabricks.net/")
|
||||
|
||||
require.Equal(t, "https://adb-123456.azuredatabricks.net/jobs/1", b.Config.Resources.Jobs["job1"].URL)
|
||||
}
|
|
@ -3,6 +3,7 @@ package config
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/databricks/cli/bundle/config/resources"
|
||||
"github.com/databricks/databricks-sdk-go"
|
||||
|
@ -30,6 +31,53 @@ type ConfigResource interface {
|
|||
// Terraform equivalent name of the resource. For example "databricks_job"
|
||||
// for jobs and "databricks_pipeline" for pipelines.
|
||||
TerraformResourceName() string
|
||||
|
||||
// GetName returns the in-product name of the resource.
|
||||
GetName() string
|
||||
|
||||
// GetURL returns the URL of the resource.
|
||||
GetURL() string
|
||||
|
||||
// InitializeURL initializes the URL field of the resource.
|
||||
InitializeURL(baseURL url.URL)
|
||||
}
|
||||
|
||||
// ResourceGroup represents a group of resources of the same type.
|
||||
// It includes a description of the resource type and a map of resources.
|
||||
type ResourceGroup struct {
|
||||
Description ResourceDescription
|
||||
Resources map[string]ConfigResource
|
||||
}
|
||||
|
||||
// collectResourceMap collects resources of a specific type into a ResourceGroup.
|
||||
func collectResourceMap[T ConfigResource](
|
||||
description ResourceDescription,
|
||||
input map[string]T,
|
||||
) ResourceGroup {
|
||||
resources := make(map[string]ConfigResource)
|
||||
for key, resource := range input {
|
||||
resources[key] = resource
|
||||
}
|
||||
return ResourceGroup{
|
||||
Description: description,
|
||||
Resources: resources,
|
||||
}
|
||||
}
|
||||
|
||||
// AllResources returns all resources in the bundle grouped by their resource type.
|
||||
func (r *Resources) AllResources() []ResourceGroup {
|
||||
descriptions := SupportedResources()
|
||||
return []ResourceGroup{
|
||||
collectResourceMap(descriptions["jobs"], r.Jobs),
|
||||
collectResourceMap(descriptions["pipelines"], r.Pipelines),
|
||||
collectResourceMap(descriptions["models"], r.Models),
|
||||
collectResourceMap(descriptions["experiments"], r.Experiments),
|
||||
collectResourceMap(descriptions["model_serving_endpoints"], r.ModelServingEndpoints),
|
||||
collectResourceMap(descriptions["registered_models"], r.RegisteredModels),
|
||||
collectResourceMap(descriptions["quality_monitors"], r.QualityMonitors),
|
||||
collectResourceMap(descriptions["schemas"], r.Schemas),
|
||||
collectResourceMap(descriptions["clusters"], r.Clusters),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Resources) FindResourceByConfigKey(key string) (ConfigResource, error) {
|
||||
|
@ -61,20 +109,71 @@ func (r *Resources) FindResourceByConfigKey(key string) (ConfigResource, error)
|
|||
}
|
||||
|
||||
type ResourceDescription struct {
|
||||
// Singular and plural name when used to refer to the configuration.
|
||||
SingularName string
|
||||
PluralName string
|
||||
|
||||
// Singular and plural title when used in summaries / terminal UI.
|
||||
SingularTitle string
|
||||
PluralTitle string
|
||||
}
|
||||
|
||||
// The keys of the map corresponds to the resource key in the bundle configuration.
|
||||
func SupportedResources() map[string]ResourceDescription {
|
||||
return map[string]ResourceDescription{
|
||||
"jobs": {SingularName: "job"},
|
||||
"pipelines": {SingularName: "pipeline"},
|
||||
"models": {SingularName: "model"},
|
||||
"experiments": {SingularName: "experiment"},
|
||||
"model_serving_endpoints": {SingularName: "model_serving_endpoint"},
|
||||
"registered_models": {SingularName: "registered_model"},
|
||||
"quality_monitors": {SingularName: "quality_monitor"},
|
||||
"schemas": {SingularName: "schema"},
|
||||
"clusters": {SingularName: "cluster"},
|
||||
"jobs": {
|
||||
SingularName: "job",
|
||||
PluralName: "jobs",
|
||||
SingularTitle: "Job",
|
||||
PluralTitle: "Jobs",
|
||||
},
|
||||
"pipelines": {
|
||||
SingularName: "pipeline",
|
||||
PluralName: "pipelines",
|
||||
SingularTitle: "Pipeline",
|
||||
PluralTitle: "Pipelines",
|
||||
},
|
||||
"models": {
|
||||
SingularName: "model",
|
||||
PluralName: "models",
|
||||
SingularTitle: "Model",
|
||||
PluralTitle: "Models",
|
||||
},
|
||||
"experiments": {
|
||||
SingularName: "experiment",
|
||||
PluralName: "experiments",
|
||||
SingularTitle: "Experiment",
|
||||
PluralTitle: "Experiments",
|
||||
},
|
||||
"model_serving_endpoints": {
|
||||
SingularName: "model_serving_endpoint",
|
||||
PluralName: "model_serving_endpoints",
|
||||
SingularTitle: "Model Serving Endpoint",
|
||||
PluralTitle: "Model Serving Endpoints",
|
||||
},
|
||||
"registered_models": {
|
||||
SingularName: "registered_model",
|
||||
PluralName: "registered_models",
|
||||
SingularTitle: "Registered Model",
|
||||
PluralTitle: "Registered Models",
|
||||
},
|
||||
"quality_monitors": {
|
||||
SingularName: "quality_monitor",
|
||||
PluralName: "quality_monitors",
|
||||
SingularTitle: "Quality Monitor",
|
||||
PluralTitle: "Quality Monitors",
|
||||
},
|
||||
"schemas": {
|
||||
SingularName: "schema",
|
||||
PluralName: "schemas",
|
||||
SingularTitle: "Schema",
|
||||
PluralTitle: "Schemas",
|
||||
},
|
||||
"clusters": {
|
||||
SingularName: "cluster",
|
||||
PluralName: "clusters",
|
||||
SingularTitle: "Cluster",
|
||||
PluralTitle: "Clusters",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ package resources
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/databricks/cli/libs/log"
|
||||
"github.com/databricks/databricks-sdk-go"
|
||||
|
@ -13,6 +15,7 @@ type Cluster struct {
|
|||
ID string `json:"id,omitempty" bundle:"readonly"`
|
||||
Permissions []Permission `json:"permissions,omitempty"`
|
||||
ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"`
|
||||
URL string `json:"url,omitempty" bundle:"internal"`
|
||||
|
||||
*compute.ClusterSpec
|
||||
}
|
||||
|
@ -37,3 +40,19 @@ func (s *Cluster) Exists(ctx context.Context, w *databricks.WorkspaceClient, id
|
|||
func (s *Cluster) TerraformResourceName() string {
|
||||
return "databricks_cluster"
|
||||
}
|
||||
|
||||
func (s *Cluster) InitializeURL(baseURL url.URL) {
|
||||
if s.ID == "" {
|
||||
return
|
||||
}
|
||||
baseURL.Path = fmt.Sprintf("compute/clusters/%s", s.ID)
|
||||
s.URL = baseURL.String()
|
||||
}
|
||||
|
||||
func (s *Cluster) GetName() string {
|
||||
return s.ClusterName
|
||||
}
|
||||
|
||||
func (s *Cluster) GetURL() string {
|
||||
return s.URL
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ package resources
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/databricks/cli/libs/log"
|
||||
|
@ -14,6 +16,7 @@ type Job struct {
|
|||
ID string `json:"id,omitempty" bundle:"readonly"`
|
||||
Permissions []Permission `json:"permissions,omitempty"`
|
||||
ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"`
|
||||
URL string `json:"url,omitempty" bundle:"internal"`
|
||||
|
||||
*jobs.JobSettings
|
||||
}
|
||||
|
@ -44,3 +47,19 @@ func (j *Job) Exists(ctx context.Context, w *databricks.WorkspaceClient, id stri
|
|||
func (j *Job) TerraformResourceName() string {
|
||||
return "databricks_job"
|
||||
}
|
||||
|
||||
func (j *Job) InitializeURL(baseURL url.URL) {
|
||||
if j.ID == "" {
|
||||
return
|
||||
}
|
||||
baseURL.Path = fmt.Sprintf("jobs/%s", j.ID)
|
||||
j.URL = baseURL.String()
|
||||
}
|
||||
|
||||
func (j *Job) GetName() string {
|
||||
return j.Name
|
||||
}
|
||||
|
||||
func (j *Job) GetURL() string {
|
||||
return j.URL
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ package resources
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/databricks/cli/libs/log"
|
||||
"github.com/databricks/databricks-sdk-go"
|
||||
|
@ -13,6 +15,7 @@ type MlflowExperiment struct {
|
|||
ID string `json:"id,omitempty" bundle:"readonly"`
|
||||
Permissions []Permission `json:"permissions,omitempty"`
|
||||
ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"`
|
||||
URL string `json:"url,omitempty" bundle:"internal"`
|
||||
|
||||
*ml.Experiment
|
||||
}
|
||||
|
@ -39,3 +42,19 @@ func (s *MlflowExperiment) Exists(ctx context.Context, w *databricks.WorkspaceCl
|
|||
func (s *MlflowExperiment) TerraformResourceName() string {
|
||||
return "databricks_mlflow_experiment"
|
||||
}
|
||||
|
||||
func (s *MlflowExperiment) InitializeURL(baseURL url.URL) {
|
||||
if s.ID == "" {
|
||||
return
|
||||
}
|
||||
baseURL.Path = fmt.Sprintf("ml/experiments/%s", s.ID)
|
||||
s.URL = baseURL.String()
|
||||
}
|
||||
|
||||
func (s *MlflowExperiment) GetName() string {
|
||||
return s.Name
|
||||
}
|
||||
|
||||
func (s *MlflowExperiment) GetURL() string {
|
||||
return s.URL
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ package resources
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/databricks/cli/libs/log"
|
||||
"github.com/databricks/databricks-sdk-go"
|
||||
|
@ -13,6 +15,7 @@ type MlflowModel struct {
|
|||
ID string `json:"id,omitempty" bundle:"readonly"`
|
||||
Permissions []Permission `json:"permissions,omitempty"`
|
||||
ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"`
|
||||
URL string `json:"url,omitempty" bundle:"internal"`
|
||||
|
||||
*ml.Model
|
||||
}
|
||||
|
@ -39,3 +42,19 @@ func (s *MlflowModel) Exists(ctx context.Context, w *databricks.WorkspaceClient,
|
|||
func (s *MlflowModel) TerraformResourceName() string {
|
||||
return "databricks_mlflow_model"
|
||||
}
|
||||
|
||||
func (s *MlflowModel) InitializeURL(baseURL url.URL) {
|
||||
if s.ID == "" {
|
||||
return
|
||||
}
|
||||
baseURL.Path = fmt.Sprintf("ml/models/%s", s.ID)
|
||||
s.URL = baseURL.String()
|
||||
}
|
||||
|
||||
func (s *MlflowModel) GetName() string {
|
||||
return s.Name
|
||||
}
|
||||
|
||||
func (s *MlflowModel) GetURL() string {
|
||||
return s.URL
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ package resources
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/databricks/cli/libs/log"
|
||||
"github.com/databricks/databricks-sdk-go"
|
||||
|
@ -23,6 +25,7 @@ type ModelServingEndpoint struct {
|
|||
Permissions []Permission `json:"permissions,omitempty"`
|
||||
|
||||
ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"`
|
||||
URL string `json:"url,omitempty" bundle:"internal"`
|
||||
}
|
||||
|
||||
func (s *ModelServingEndpoint) UnmarshalJSON(b []byte) error {
|
||||
|
@ -47,3 +50,19 @@ func (s *ModelServingEndpoint) Exists(ctx context.Context, w *databricks.Workspa
|
|||
func (s *ModelServingEndpoint) TerraformResourceName() string {
|
||||
return "databricks_model_serving"
|
||||
}
|
||||
|
||||
func (s *ModelServingEndpoint) InitializeURL(baseURL url.URL) {
|
||||
if s.ID == "" {
|
||||
return
|
||||
}
|
||||
baseURL.Path = fmt.Sprintf("ml/endpoints/%s", s.ID)
|
||||
s.URL = baseURL.String()
|
||||
}
|
||||
|
||||
func (s *ModelServingEndpoint) GetName() string {
|
||||
return s.Name
|
||||
}
|
||||
|
||||
func (s *ModelServingEndpoint) GetURL() string {
|
||||
return s.URL
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ package resources
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/databricks/cli/libs/log"
|
||||
"github.com/databricks/databricks-sdk-go"
|
||||
|
@ -13,6 +15,7 @@ type Pipeline struct {
|
|||
ID string `json:"id,omitempty" bundle:"readonly"`
|
||||
Permissions []Permission `json:"permissions,omitempty"`
|
||||
ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"`
|
||||
URL string `json:"url,omitempty" bundle:"internal"`
|
||||
|
||||
*pipelines.PipelineSpec
|
||||
}
|
||||
|
@ -39,3 +42,19 @@ func (p *Pipeline) Exists(ctx context.Context, w *databricks.WorkspaceClient, id
|
|||
func (p *Pipeline) TerraformResourceName() string {
|
||||
return "databricks_pipeline"
|
||||
}
|
||||
|
||||
func (p *Pipeline) InitializeURL(baseURL url.URL) {
|
||||
if p.ID == "" {
|
||||
return
|
||||
}
|
||||
baseURL.Path = fmt.Sprintf("pipelines/%s", p.ID)
|
||||
p.URL = baseURL.String()
|
||||
}
|
||||
|
||||
func (p *Pipeline) GetName() string {
|
||||
return p.Name
|
||||
}
|
||||
|
||||
func (s *Pipeline) GetURL() string {
|
||||
return s.URL
|
||||
}
|
||||
|
|
|
@ -2,6 +2,9 @@ package resources
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/databricks/cli/libs/log"
|
||||
"github.com/databricks/databricks-sdk-go"
|
||||
|
@ -20,6 +23,7 @@ type QualityMonitor struct {
|
|||
ID string `json:"id,omitempty" bundle:"readonly"`
|
||||
|
||||
ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"`
|
||||
URL string `json:"url,omitempty" bundle:"internal"`
|
||||
}
|
||||
|
||||
func (s *QualityMonitor) UnmarshalJSON(b []byte) error {
|
||||
|
@ -44,3 +48,19 @@ func (s *QualityMonitor) Exists(ctx context.Context, w *databricks.WorkspaceClie
|
|||
func (s *QualityMonitor) TerraformResourceName() string {
|
||||
return "databricks_quality_monitor"
|
||||
}
|
||||
|
||||
func (s *QualityMonitor) InitializeURL(baseURL url.URL) {
|
||||
if s.TableName == "" {
|
||||
return
|
||||
}
|
||||
baseURL.Path = fmt.Sprintf("explore/data/%s", strings.ReplaceAll(s.TableName, ".", "/"))
|
||||
s.URL = baseURL.String()
|
||||
}
|
||||
|
||||
func (s *QualityMonitor) GetName() string {
|
||||
return s.TableName
|
||||
}
|
||||
|
||||
func (s *QualityMonitor) GetURL() string {
|
||||
return s.URL
|
||||
}
|
||||
|
|
|
@ -2,6 +2,9 @@ package resources
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/databricks/cli/libs/log"
|
||||
"github.com/databricks/databricks-sdk-go"
|
||||
|
@ -24,6 +27,7 @@ type RegisteredModel struct {
|
|||
*catalog.CreateRegisteredModelRequest
|
||||
|
||||
ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"`
|
||||
URL string `json:"url,omitempty" bundle:"internal"`
|
||||
}
|
||||
|
||||
func (s *RegisteredModel) UnmarshalJSON(b []byte) error {
|
||||
|
@ -48,3 +52,19 @@ func (s *RegisteredModel) Exists(ctx context.Context, w *databricks.WorkspaceCli
|
|||
func (s *RegisteredModel) TerraformResourceName() string {
|
||||
return "databricks_registered_model"
|
||||
}
|
||||
|
||||
func (s *RegisteredModel) InitializeURL(baseURL url.URL) {
|
||||
if s.ID == "" {
|
||||
return
|
||||
}
|
||||
baseURL.Path = fmt.Sprintf("explore/data/models/%s", strings.ReplaceAll(s.ID, ".", "/"))
|
||||
s.URL = baseURL.String()
|
||||
}
|
||||
|
||||
func (s *RegisteredModel) GetName() string {
|
||||
return s.Name
|
||||
}
|
||||
|
||||
func (s *RegisteredModel) GetURL() string {
|
||||
return s.URL
|
||||
}
|
||||
|
|
|
@ -1,6 +1,12 @@
|
|||
package resources
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/databricks/databricks-sdk-go"
|
||||
"github.com/databricks/databricks-sdk-go/marshal"
|
||||
"github.com/databricks/databricks-sdk-go/service/catalog"
|
||||
)
|
||||
|
@ -16,6 +22,31 @@ type Schema struct {
|
|||
*catalog.CreateSchema
|
||||
|
||||
ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"`
|
||||
URL string `json:"url,omitempty" bundle:"internal"`
|
||||
}
|
||||
|
||||
func (s *Schema) Exists(ctx context.Context, w *databricks.WorkspaceClient, id string) (bool, error) {
|
||||
return false, fmt.Errorf("schema.Exists() is not supported")
|
||||
}
|
||||
|
||||
func (s *Schema) TerraformResourceName() string {
|
||||
return "databricks_schema"
|
||||
}
|
||||
|
||||
func (s *Schema) InitializeURL(baseURL url.URL) {
|
||||
if s.ID == "" {
|
||||
return
|
||||
}
|
||||
baseURL.Path = fmt.Sprintf("explore/data/%s", strings.ReplaceAll(s.ID, ".", "/"))
|
||||
s.URL = baseURL.String()
|
||||
}
|
||||
|
||||
func (s *Schema) GetURL() string {
|
||||
return s.URL
|
||||
}
|
||||
|
||||
func (s *Schema) GetName() string {
|
||||
return s.Name
|
||||
}
|
||||
|
||||
func (s *Schema) UnmarshalJSON(b []byte) error {
|
||||
|
|
|
@ -63,17 +63,37 @@ func TestCustomMarshallerIsImplemented(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestResourcesAllResourcesCompleteness(t *testing.T) {
|
||||
r := Resources{}
|
||||
rt := reflect.TypeOf(r)
|
||||
|
||||
// Collect set of includes resource types
|
||||
var types []string
|
||||
for _, group := range r.AllResources() {
|
||||
types = append(types, group.Description.PluralName)
|
||||
}
|
||||
|
||||
for i := 0; i < rt.NumField(); i++ {
|
||||
field := rt.Field(i)
|
||||
jsonTag := field.Tag.Get("json")
|
||||
|
||||
if idx := strings.Index(jsonTag, ","); idx != -1 {
|
||||
jsonTag = jsonTag[:idx]
|
||||
}
|
||||
|
||||
assert.Contains(t, types, jsonTag, "Field %s is missing in AllResources", field.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportedResources(t *testing.T) {
|
||||
expected := map[string]ResourceDescription{}
|
||||
// Please add your resource to the SupportedResources() function in resources.go if you add a new resource.
|
||||
actual := SupportedResources()
|
||||
|
||||
typ := reflect.TypeOf(Resources{})
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
jsonTags := strings.Split(field.Tag.Get("json"), ",")
|
||||
singularName := strings.TrimSuffix(jsonTags[0], "s")
|
||||
expected[jsonTags[0]] = ResourceDescription{SingularName: singularName}
|
||||
pluralName := jsonTags[0]
|
||||
assert.Equal(t, actual[pluralName].PluralName, pluralName)
|
||||
}
|
||||
|
||||
// Please add your resource to the SupportedResources() function in resources.go
|
||||
// if you are adding a new resource.
|
||||
assert.Equal(t, expected, SupportedResources())
|
||||
}
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
package render
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
|
@ -29,7 +31,7 @@ var renderFuncMap = template.FuncMap{
|
|||
},
|
||||
}
|
||||
|
||||
const summaryTemplate = `{{- if .Name -}}
|
||||
const summaryHeaderTemplate = `{{- if .Name -}}
|
||||
Name: {{ .Name | bold }}
|
||||
{{- if .Target }}
|
||||
Target: {{ .Target | bold }}
|
||||
|
@ -46,12 +48,30 @@ Workspace:
|
|||
Path: {{ .Path | bold }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{ end -}}`
|
||||
|
||||
{{ end -}}
|
||||
|
||||
{{ .Trailer }}
|
||||
const resourcesTemplate = `Resources:
|
||||
{{- range . }}
|
||||
{{ .GroupName }}:
|
||||
{{- range .Resources }}
|
||||
{{ .Key | bold }}:
|
||||
Name: {{ .Name }}
|
||||
URL: {{ if .URL }}{{ .URL | cyan }}{{ else }}{{ "(not deployed)" | cyan }}{{ end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
`
|
||||
|
||||
type ResourceGroup struct {
|
||||
GroupName string
|
||||
Resources []ResourceInfo
|
||||
}
|
||||
|
||||
type ResourceInfo struct {
|
||||
Key string
|
||||
Name string
|
||||
URL string
|
||||
}
|
||||
|
||||
func pluralize(n int, singular, plural string) string {
|
||||
if n == 1 {
|
||||
return fmt.Sprintf("%d %s", n, singular)
|
||||
|
@ -74,20 +94,20 @@ func buildTrailer(diags diag.Diagnostics) string {
|
|||
case len(parts) >= 3:
|
||||
first := strings.Join(parts[:len(parts)-1], ", ")
|
||||
last := parts[len(parts)-1]
|
||||
return fmt.Sprintf("Found %s, and %s", first, last)
|
||||
return fmt.Sprintf("Found %s, and %s\n", first, last)
|
||||
case len(parts) == 2:
|
||||
return fmt.Sprintf("Found %s and %s", parts[0], parts[1])
|
||||
return fmt.Sprintf("Found %s and %s\n", parts[0], parts[1])
|
||||
case len(parts) == 1:
|
||||
return fmt.Sprintf("Found %s", parts[0])
|
||||
return fmt.Sprintf("Found %s\n", parts[0])
|
||||
default:
|
||||
// No diagnostics to print.
|
||||
return color.GreenString("Validation OK!")
|
||||
return color.GreenString("Validation OK!\n")
|
||||
}
|
||||
}
|
||||
|
||||
func renderSummaryTemplate(out io.Writer, b *bundle.Bundle, diags diag.Diagnostics) error {
|
||||
func renderSummaryHeaderTemplate(out io.Writer, b *bundle.Bundle) error {
|
||||
if b == nil {
|
||||
return renderSummaryTemplate(out, &bundle.Bundle{}, diags)
|
||||
return renderSummaryHeaderTemplate(out, &bundle.Bundle{})
|
||||
}
|
||||
|
||||
var currentUser = &iam.User{}
|
||||
|
@ -98,20 +118,19 @@ func renderSummaryTemplate(out io.Writer, b *bundle.Bundle, diags diag.Diagnosti
|
|||
}
|
||||
}
|
||||
|
||||
t := template.Must(template.New("summary").Funcs(renderFuncMap).Parse(summaryTemplate))
|
||||
t := template.Must(template.New("summary").Funcs(renderFuncMap).Parse(summaryHeaderTemplate))
|
||||
err := t.Execute(out, map[string]any{
|
||||
"Name": b.Config.Bundle.Name,
|
||||
"Target": b.Config.Bundle.Target,
|
||||
"User": currentUser.UserName,
|
||||
"Path": b.Config.Workspace.RootPath,
|
||||
"Host": b.Config.Workspace.Host,
|
||||
"Trailer": buildTrailer(diags),
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func renderDiagnostics(out io.Writer, b *bundle.Bundle, diags diag.Diagnostics) error {
|
||||
func renderDiagnosticsOnly(out io.Writer, b *bundle.Bundle, diags diag.Diagnostics) error {
|
||||
for _, d := range diags {
|
||||
for i := range d.Locations {
|
||||
if b == nil {
|
||||
|
@ -139,19 +158,73 @@ type RenderOptions struct {
|
|||
RenderSummaryTable bool
|
||||
}
|
||||
|
||||
// RenderTextOutput renders the diagnostics in a human-readable format.
|
||||
func RenderTextOutput(out io.Writer, b *bundle.Bundle, diags diag.Diagnostics, opts RenderOptions) error {
|
||||
err := renderDiagnostics(out, b, diags)
|
||||
// RenderDiagnostics renders the diagnostics in a human-readable format.
|
||||
func RenderDiagnostics(out io.Writer, b *bundle.Bundle, diags diag.Diagnostics, opts RenderOptions) error {
|
||||
err := renderDiagnosticsOnly(out, b, diags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render diagnostics: %w", err)
|
||||
}
|
||||
|
||||
if opts.RenderSummaryTable {
|
||||
err = renderSummaryTemplate(out, b, diags)
|
||||
if b != nil {
|
||||
err = renderSummaryHeaderTemplate(out, b)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render summary: %w", err)
|
||||
}
|
||||
io.WriteString(out, "\n")
|
||||
}
|
||||
trailer := buildTrailer(diags)
|
||||
io.WriteString(out, trailer)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func RenderSummary(ctx context.Context, out io.Writer, b *bundle.Bundle) error {
|
||||
if err := renderSummaryHeaderTemplate(out, b); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var resourceGroups []ResourceGroup
|
||||
|
||||
for _, group := range b.Config.Resources.AllResources() {
|
||||
resources := make([]ResourceInfo, 0, len(group.Resources))
|
||||
for key, resource := range group.Resources {
|
||||
resources = append(resources, ResourceInfo{
|
||||
Key: key,
|
||||
Name: resource.GetName(),
|
||||
URL: resource.GetURL(),
|
||||
})
|
||||
}
|
||||
|
||||
if len(resources) > 0 {
|
||||
resourceGroups = append(resourceGroups, ResourceGroup{
|
||||
GroupName: group.Description.PluralTitle,
|
||||
Resources: resources,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if err := renderResourcesTemplate(out, resourceGroups); err != nil {
|
||||
return fmt.Errorf("failed to render resources template: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper function to sort and render resource groups using the template
|
||||
func renderResourcesTemplate(out io.Writer, resourceGroups []ResourceGroup) error {
|
||||
// Sort everything to ensure consistent output
|
||||
sort.Slice(resourceGroups, func(i, j int) bool {
|
||||
return resourceGroups[i].GroupName < resourceGroups[j].GroupName
|
||||
})
|
||||
for _, group := range resourceGroups {
|
||||
sort.Slice(group.Resources, func(i, j int) bool {
|
||||
return group.Resources[i].Key < group.Resources[j].Key
|
||||
})
|
||||
}
|
||||
|
||||
t := template.Must(template.New("resources").Funcs(renderFuncMap).Parse(resourcesTemplate))
|
||||
|
||||
return t.Execute(out, resourceGroups)
|
||||
}
|
||||
|
|
|
@ -2,14 +2,21 @@ package render
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/databricks/cli/bundle"
|
||||
"github.com/databricks/cli/bundle/config"
|
||||
"github.com/databricks/cli/bundle/config/resources"
|
||||
"github.com/databricks/cli/libs/diag"
|
||||
"github.com/databricks/cli/libs/dyn"
|
||||
assert "github.com/databricks/cli/libs/dyn/dynassert"
|
||||
"github.com/databricks/databricks-sdk-go/service/catalog"
|
||||
"github.com/databricks/databricks-sdk-go/service/iam"
|
||||
"github.com/databricks/databricks-sdk-go/service/jobs"
|
||||
"github.com/databricks/databricks-sdk-go/service/pipelines"
|
||||
"github.com/databricks/databricks-sdk-go/service/serving"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
|
@ -326,7 +333,7 @@ func TestRenderTextOutput(t *testing.T) {
|
|||
t.Run(tc.name, func(t *testing.T) {
|
||||
writer := &bytes.Buffer{}
|
||||
|
||||
err := RenderTextOutput(writer, tc.bundle, tc.diags, tc.opts)
|
||||
err := RenderDiagnostics(writer, tc.bundle, tc.diags, tc.opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tc.expected, writer.String())
|
||||
|
@ -468,7 +475,7 @@ func TestRenderDiagnostics(t *testing.T) {
|
|||
t.Run(tc.name, func(t *testing.T) {
|
||||
writer := &bytes.Buffer{}
|
||||
|
||||
err := renderDiagnostics(writer, bundle, tc.diags)
|
||||
err := renderDiagnosticsOnly(writer, bundle, tc.diags)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tc.expected, writer.String())
|
||||
|
@ -479,8 +486,105 @@ func TestRenderDiagnostics(t *testing.T) {
|
|||
func TestRenderSummaryTemplate_nilBundle(t *testing.T) {
|
||||
writer := &bytes.Buffer{}
|
||||
|
||||
err := renderSummaryTemplate(writer, nil, nil)
|
||||
err := renderSummaryHeaderTemplate(writer, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
io.WriteString(writer, buildTrailer(nil))
|
||||
|
||||
assert.Equal(t, "Validation OK!\n", writer.String())
|
||||
}
|
||||
|
||||
func TestRenderSummary(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a mock bundle with various resources
|
||||
b := &bundle.Bundle{
|
||||
Config: config.Root{
|
||||
Bundle: config.Bundle{
|
||||
Name: "test-bundle",
|
||||
Target: "test-target",
|
||||
},
|
||||
Workspace: config.Workspace{
|
||||
Host: "https://mycompany.databricks.com/",
|
||||
},
|
||||
Resources: config.Resources{
|
||||
Jobs: map[string]*resources.Job{
|
||||
"job1": {
|
||||
ID: "1",
|
||||
URL: "https://url1",
|
||||
JobSettings: &jobs.JobSettings{Name: "job1-name"},
|
||||
},
|
||||
"job2": {
|
||||
ID: "2",
|
||||
URL: "https://url2",
|
||||
JobSettings: &jobs.JobSettings{Name: "job2-name"},
|
||||
},
|
||||
},
|
||||
Pipelines: map[string]*resources.Pipeline{
|
||||
"pipeline2": {
|
||||
ID: "4",
|
||||
// no URL
|
||||
PipelineSpec: &pipelines.PipelineSpec{Name: "pipeline2-name"},
|
||||
},
|
||||
"pipeline1": {
|
||||
ID: "3",
|
||||
URL: "https://url3",
|
||||
PipelineSpec: &pipelines.PipelineSpec{Name: "pipeline1-name"},
|
||||
},
|
||||
},
|
||||
Schemas: map[string]*resources.Schema{
|
||||
"schema1": {
|
||||
ID: "catalog.schema",
|
||||
CreateSchema: &catalog.CreateSchema{
|
||||
Name: "schema",
|
||||
},
|
||||
// no URL
|
||||
},
|
||||
},
|
||||
ModelServingEndpoints: map[string]*resources.ModelServingEndpoint{
|
||||
"endpoint1": {
|
||||
ID: "7",
|
||||
CreateServingEndpoint: &serving.CreateServingEndpoint{
|
||||
Name: "my_serving_endpoint",
|
||||
},
|
||||
URL: "https://url4",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
writer := &bytes.Buffer{}
|
||||
err := RenderSummary(ctx, writer, b)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedSummary := `Name: test-bundle
|
||||
Target: test-target
|
||||
Workspace:
|
||||
Host: https://mycompany.databricks.com/
|
||||
Resources:
|
||||
Jobs:
|
||||
job1:
|
||||
Name: job1-name
|
||||
URL: https://url1
|
||||
job2:
|
||||
Name: job2-name
|
||||
URL: https://url2
|
||||
Model Serving Endpoints:
|
||||
endpoint1:
|
||||
Name: my_serving_endpoint
|
||||
URL: https://url4
|
||||
Pipelines:
|
||||
pipeline1:
|
||||
Name: pipeline1-name
|
||||
URL: https://url3
|
||||
pipeline2:
|
||||
Name: pipeline2-name
|
||||
URL: (not deployed)
|
||||
Schemas:
|
||||
schema1:
|
||||
Name: schema
|
||||
URL: (not deployed)
|
||||
`
|
||||
assert.Equal(t, expectedSummary, writer.String())
|
||||
}
|
||||
|
|
|
@ -78,7 +78,7 @@ func newDeployCommand() *cobra.Command {
|
|||
}
|
||||
|
||||
renderOpts := render.RenderOptions{RenderSummaryTable: false}
|
||||
err := render.RenderTextOutput(cmd.OutOrStdout(), b, diags, renderOpts)
|
||||
err := render.RenderDiagnostics(cmd.OutOrStdout(), b, diags, renderOpts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render output: %w", err)
|
||||
}
|
||||
|
|
|
@ -8,8 +8,10 @@ import (
|
|||
"path/filepath"
|
||||
|
||||
"github.com/databricks/cli/bundle"
|
||||
"github.com/databricks/cli/bundle/config/mutator"
|
||||
"github.com/databricks/cli/bundle/deploy/terraform"
|
||||
"github.com/databricks/cli/bundle/phases"
|
||||
"github.com/databricks/cli/bundle/render"
|
||||
"github.com/databricks/cli/cmd/bundle/utils"
|
||||
"github.com/databricks/cli/cmd/root"
|
||||
"github.com/databricks/cli/libs/flags"
|
||||
|
@ -19,11 +21,8 @@ import (
|
|||
func newSummaryCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "summary",
|
||||
Short: "Describe the bundle resources and their deployment states",
|
||||
Short: "Summarize resources deployed by this bundle",
|
||||
Args: root.NoArgs,
|
||||
|
||||
// This command is currently intended for the Databricks VSCode extension only
|
||||
Hidden: true,
|
||||
}
|
||||
|
||||
var forcePull bool
|
||||
|
@ -60,14 +59,15 @@ func newSummaryCommand() *cobra.Command {
|
|||
}
|
||||
}
|
||||
|
||||
diags = bundle.Apply(ctx, b, terraform.Load())
|
||||
diags = bundle.Apply(ctx, b,
|
||||
bundle.Seq(terraform.Load(), mutator.InitializeURLs()))
|
||||
if err := diags.Error(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch root.OutputType(cmd) {
|
||||
case flags.OutputText:
|
||||
return fmt.Errorf("%w, only json output is supported", errors.ErrUnsupported)
|
||||
return render.RenderSummary(ctx, cmd.OutOrStdout(), b)
|
||||
case flags.OutputJSON:
|
||||
buf, err := json.MarshalIndent(b.Config, "", " ")
|
||||
if err != nil {
|
||||
|
|
|
@ -54,7 +54,7 @@ func newValidateCommand() *cobra.Command {
|
|||
switch root.OutputType(cmd) {
|
||||
case flags.OutputText:
|
||||
renderOpts := render.RenderOptions{RenderSummaryTable: true}
|
||||
err := render.RenderTextOutput(cmd.OutOrStdout(), b, diags, renderOpts)
|
||||
err := render.RenderDiagnostics(cmd.OutOrStdout(), b, diags, renderOpts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render output: %w", err)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue