Add a textual bundle summary command

This commit is contained in:
Lennart Kats 2024-08-24 20:48:36 +02:00
parent 84b47745e4
commit 869637dc54
No known key found for this signature in database
GPG Key ID: 1EB8B57673197023
17 changed files with 612 additions and 37 deletions

View File

@ -0,0 +1,62 @@
package mutator
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/databricks/cli/bundle"
"github.com/databricks/cli/libs/diag"
)
type initializeUrls struct {
name string
}
// 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 fmt.Sprintf("ConfigureURLs(%s)", m.name)
}
func (m *initializeUrls) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics {
workspaceId, err := b.WorkspaceClient().CurrentWorkspaceID(ctx)
orgId := strconv.FormatInt(workspaceId, 10)
if err != nil {
return diag.FromErr(err)
}
configureForOrgId(b, orgId)
return nil
}
func configureForOrgId(b *bundle.Bundle, orgId string) {
urlPrefix := b.Config.Workspace.Host
if !strings.HasSuffix(urlPrefix, "/") {
urlPrefix += "/"
}
urlSuffix := ""
// 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(urlPrefix, orgId) {
urlSuffix = "?o=" + orgId
}
for _, rs := range b.Config.Resources.AllResources() {
for _, r := range rs {
r.InitializeURL(urlPrefix, urlSuffix)
}
}
}

View File

@ -0,0 +1,124 @@
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/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 TestIntitializeURLs(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: "6",
Model: &ml.Model{Name: "model1"},
},
},
ModelServingEndpoints: map[string]*resources.ModelServingEndpoint{
"servingendpoint1": {
ID: "7",
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",
},
},
},
},
},
}
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/model1?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",
}
configureForOrgId(b, "123456")
for _, rs := range b.Config.Resources.AllResources() {
for key, r := range rs {
require.Equal(t, expectedURLs[key], r.GetURL(), "Unexpected URL for "+key)
}
}
}
func TestIntitializeURLs_NoOrgId(t *testing.T) {
b := &bundle.Bundle{
Config: config.Root{
Workspace: config.Workspace{
// Hostname with org id in URL and no trailing /
Host: "https://adb-123456.azuredatabricks.net",
},
Resources: config.Resources{
Jobs: map[string]*resources.Job{
"job1": {
ID: "1",
JobSettings: &jobs.JobSettings{Name: "job1"},
},
},
},
},
}
configureForOrgId(b, "123456")
require.Equal(t, "https://adb-123456.azuredatabricks.net/jobs/1", b.Config.Resources.Jobs["job1"].URL)
}

View File

@ -29,6 +29,69 @@ type ConfigResource interface {
// Terraform equivalent name of the resource. For example "databricks_job" // Terraform equivalent name of the resource. For example "databricks_job"
// for jobs and "databricks_pipeline" for pipelines. // for jobs and "databricks_pipeline" for pipelines.
TerraformResourceName() string 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(urlPrefix string, urlSuffix string)
}
func (r *Resources) AllResources() map[string]map[string]ConfigResource {
result := make(map[string]map[string]ConfigResource)
jobResources := make(map[string]ConfigResource)
for key, job := range r.Jobs {
jobResources[key] = job
}
result["jobs"] = jobResources
pipelineResources := make(map[string]ConfigResource)
for key, pipeline := range r.Pipelines {
pipelineResources[key] = pipeline
}
result["pipelines"] = pipelineResources
modelResources := make(map[string]ConfigResource)
for key, model := range r.Models {
modelResources[key] = model
}
result["models"] = modelResources
experimentResources := make(map[string]ConfigResource)
for key, experiment := range r.Experiments {
experimentResources[key] = experiment
}
result["experiments"] = experimentResources
modelServingEndpointResources := make(map[string]ConfigResource)
for key, endpoint := range r.ModelServingEndpoints {
modelServingEndpointResources[key] = endpoint
}
result["model_serving_endpoints"] = modelServingEndpointResources
registeredModelResources := make(map[string]ConfigResource)
for key, registeredModel := range r.RegisteredModels {
registeredModelResources[key] = registeredModel
}
result["registered_models"] = registeredModelResources
qualityMonitorResources := make(map[string]ConfigResource)
for key, qualityMonitor := range r.QualityMonitors {
qualityMonitorResources[key] = qualityMonitor
}
result["quality_monitors"] = qualityMonitorResources
schemaResources := make(map[string]ConfigResource)
for key, schema := range r.Schemas {
schemaResources[key] = schema
}
result["schemas"] = schemaResources
return result
} }
func (r *Resources) FindResourceByConfigKey(key string) (ConfigResource, error) { func (r *Resources) FindResourceByConfigKey(key string) (ConfigResource, error) {

View File

@ -14,6 +14,7 @@ type Job struct {
ID string `json:"id,omitempty" bundle:"readonly"` ID string `json:"id,omitempty" bundle:"readonly"`
Permissions []Permission `json:"permissions,omitempty"` Permissions []Permission `json:"permissions,omitempty"`
ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"` ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"`
URL string `json:"url,omitempty" bundle:"internal"`
*jobs.JobSettings *jobs.JobSettings
} }
@ -44,3 +45,18 @@ func (j *Job) Exists(ctx context.Context, w *databricks.WorkspaceClient, id stri
func (j *Job) TerraformResourceName() string { func (j *Job) TerraformResourceName() string {
return "databricks_job" return "databricks_job"
} }
func (j *Job) InitializeURL(urlPrefix string, urlSuffix string) {
if j.ID == "" {
return
}
j.URL = urlPrefix + "jobs/" + j.ID + urlSuffix
}
func (j *Job) GetName() string {
return j.Name
}
func (j *Job) GetURL() string {
return j.URL
}

View File

@ -13,6 +13,7 @@ type MlflowExperiment struct {
ID string `json:"id,omitempty" bundle:"readonly"` ID string `json:"id,omitempty" bundle:"readonly"`
Permissions []Permission `json:"permissions,omitempty"` Permissions []Permission `json:"permissions,omitempty"`
ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"` ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"`
URL string `json:"url,omitempty" bundle:"internal"`
*ml.Experiment *ml.Experiment
} }
@ -39,3 +40,18 @@ func (s *MlflowExperiment) Exists(ctx context.Context, w *databricks.WorkspaceCl
func (s *MlflowExperiment) TerraformResourceName() string { func (s *MlflowExperiment) TerraformResourceName() string {
return "databricks_mlflow_experiment" return "databricks_mlflow_experiment"
} }
func (s *MlflowExperiment) InitializeURL(urlPrefix string, urlSuffix string) {
if s.ID == "" {
return
}
s.URL = urlPrefix + "ml/experiments/" + s.ID + urlSuffix
}
func (s *MlflowExperiment) GetName() string {
return s.Name
}
func (s *MlflowExperiment) GetURL() string {
return s.URL
}

View File

@ -13,6 +13,7 @@ type MlflowModel struct {
ID string `json:"id,omitempty" bundle:"readonly"` ID string `json:"id,omitempty" bundle:"readonly"`
Permissions []Permission `json:"permissions,omitempty"` Permissions []Permission `json:"permissions,omitempty"`
ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"` ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"`
URL string `json:"url,omitempty" bundle:"internal"`
*ml.Model *ml.Model
} }
@ -39,3 +40,18 @@ func (s *MlflowModel) Exists(ctx context.Context, w *databricks.WorkspaceClient,
func (s *MlflowModel) TerraformResourceName() string { func (s *MlflowModel) TerraformResourceName() string {
return "databricks_mlflow_model" return "databricks_mlflow_model"
} }
func (s *MlflowModel) InitializeURL(urlPrefix string, urlSuffix string) {
if s.ID == "" {
return
}
s.URL = urlPrefix + "ml/models/" + s.Name + urlSuffix
}
func (s *MlflowModel) GetName() string {
return s.Name
}
func (s *MlflowModel) GetURL() string {
return s.URL
}

View File

@ -23,6 +23,7 @@ type ModelServingEndpoint struct {
Permissions []Permission `json:"permissions,omitempty"` Permissions []Permission `json:"permissions,omitempty"`
ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"` ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"`
URL string `json:"url,omitempty" bundle:"internal"`
} }
func (s *ModelServingEndpoint) UnmarshalJSON(b []byte) error { func (s *ModelServingEndpoint) UnmarshalJSON(b []byte) error {
@ -47,3 +48,18 @@ func (s *ModelServingEndpoint) Exists(ctx context.Context, w *databricks.Workspa
func (s *ModelServingEndpoint) TerraformResourceName() string { func (s *ModelServingEndpoint) TerraformResourceName() string {
return "databricks_model_serving" return "databricks_model_serving"
} }
func (s *ModelServingEndpoint) InitializeURL(urlPrefix string, urlSuffix string) {
if s.ID == "" {
return
}
s.URL = urlPrefix + "ml/endpoints/" + s.Name + urlSuffix
}
func (s *ModelServingEndpoint) GetName() string {
return s.Name
}
func (s *ModelServingEndpoint) GetURL() string {
return s.URL
}

View File

@ -13,6 +13,7 @@ type Pipeline struct {
ID string `json:"id,omitempty" bundle:"readonly"` ID string `json:"id,omitempty" bundle:"readonly"`
Permissions []Permission `json:"permissions,omitempty"` Permissions []Permission `json:"permissions,omitempty"`
ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"` ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"`
URL string `json:"url,omitempty" bundle:"internal"`
*pipelines.PipelineSpec *pipelines.PipelineSpec
} }
@ -39,3 +40,18 @@ func (p *Pipeline) Exists(ctx context.Context, w *databricks.WorkspaceClient, id
func (p *Pipeline) TerraformResourceName() string { func (p *Pipeline) TerraformResourceName() string {
return "databricks_pipeline" return "databricks_pipeline"
} }
func (p *Pipeline) InitializeURL(urlPrefix string, urlSuffix string) {
if p.ID == "" {
return
}
p.URL = urlPrefix + "pipelines/" + p.ID + urlSuffix
}
func (p *Pipeline) GetName() string {
return p.Name
}
func (s *Pipeline) GetURL() string {
return s.URL
}

View File

@ -2,6 +2,7 @@ package resources
import ( import (
"context" "context"
"strings"
"github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/log"
"github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go"
@ -20,6 +21,7 @@ type QualityMonitor struct {
ID string `json:"id,omitempty" bundle:"readonly"` ID string `json:"id,omitempty" bundle:"readonly"`
ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"` ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"`
URL string `json:"url,omitempty" bundle:"internal"`
} }
func (s *QualityMonitor) UnmarshalJSON(b []byte) error { func (s *QualityMonitor) UnmarshalJSON(b []byte) error {
@ -44,3 +46,18 @@ func (s *QualityMonitor) Exists(ctx context.Context, w *databricks.WorkspaceClie
func (s *QualityMonitor) TerraformResourceName() string { func (s *QualityMonitor) TerraformResourceName() string {
return "databricks_quality_monitor" return "databricks_quality_monitor"
} }
func (s *QualityMonitor) InitializeURL(urlPrefix string, urlSuffix string) {
if s.TableName == "" {
return
}
s.URL = urlPrefix + "explore/data/" + strings.ReplaceAll(s.TableName, ".", "/") + urlSuffix
}
func (s *QualityMonitor) GetName() string {
return s.TableName
}
func (s *QualityMonitor) GetURL() string {
return s.URL
}

View File

@ -2,6 +2,7 @@ package resources
import ( import (
"context" "context"
"strings"
"github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/log"
"github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go"
@ -24,6 +25,7 @@ type RegisteredModel struct {
*catalog.CreateRegisteredModelRequest *catalog.CreateRegisteredModelRequest
ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"` ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"`
URL string `json:"url,omitempty" bundle:"internal"`
} }
func (s *RegisteredModel) UnmarshalJSON(b []byte) error { func (s *RegisteredModel) UnmarshalJSON(b []byte) error {
@ -48,3 +50,18 @@ func (s *RegisteredModel) Exists(ctx context.Context, w *databricks.WorkspaceCli
func (s *RegisteredModel) TerraformResourceName() string { func (s *RegisteredModel) TerraformResourceName() string {
return "databricks_registered_model" return "databricks_registered_model"
} }
func (s *RegisteredModel) InitializeURL(urlPrefix string, urlSuffix string) {
if s.ID == "" {
return
}
s.URL = urlPrefix + "explore/data/models/" + strings.ReplaceAll(s.ID, ".", "/") + urlSuffix
}
func (s *RegisteredModel) GetName() string {
return s.Name
}
func (s *RegisteredModel) GetURL() string {
return s.URL
}

View File

@ -1,6 +1,11 @@
package resources package resources
import ( import (
"context"
"fmt"
"strings"
"github.com/databricks/databricks-sdk-go"
"github.com/databricks/databricks-sdk-go/marshal" "github.com/databricks/databricks-sdk-go/marshal"
"github.com/databricks/databricks-sdk-go/service/catalog" "github.com/databricks/databricks-sdk-go/service/catalog"
) )
@ -16,6 +21,30 @@ type Schema struct {
*catalog.CreateSchema *catalog.CreateSchema
ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"` 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(urlPrefix string, urlSuffix string) {
if s.ID == "" {
return
}
s.URL = urlPrefix + "explore/data/" + strings.ReplaceAll(s.ID, ".", "/") + urlSuffix
}
func (s *Schema) GetURL() string {
return s.URL
}
func (s *Schema) GetName() string {
return s.Name
} }
func (s *Schema) UnmarshalJSON(b []byte) error { func (s *Schema) UnmarshalJSON(b []byte) error {

View File

@ -3,6 +3,7 @@ package config
import ( import (
"encoding/json" "encoding/json"
"reflect" "reflect"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -61,3 +62,22 @@ func TestCustomMarshallerIsImplemented(t *testing.T) {
}, "Resource %s does not have a custom unmarshaller", field.Name) }, "Resource %s does not have a custom unmarshaller", field.Name)
} }
} }
func TestResourcesAllResourcesCompleteness(t *testing.T) {
r := Resources{}
rt := reflect.TypeOf(r)
result := r.AllResources()
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]
}
_, exists := result[jsonTag]
assert.True(t, exists, "Field %s is missing in AllResources map", field.Name)
}
}

View File

@ -1,9 +1,11 @@
package render package render
import ( import (
"context"
"fmt" "fmt"
"io" "io"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"text/template" "text/template"
@ -56,7 +58,7 @@ const warningTemplate = `{{ "Warning" | yellow }}: {{ .Summary }}
` `
const summaryTemplate = `{{- if .Name -}} const summaryHeaderTemplate = `{{- if .Name -}}
Name: {{ .Name | bold }} Name: {{ .Name | bold }}
{{- if .Target }} {{- if .Target }}
Target: {{ .Target | bold }} Target: {{ .Target | bold }}
@ -73,12 +75,30 @@ Workspace:
Path: {{ .Path | bold }} Path: {{ .Path | bold }}
{{- end }} {{- end }}
{{- end }} {{- end }}
{{ end -}}`
{{ end -}} const resourcesTemplate = `Resources:
{{- range . }}
{{ .Trailer }} {{ .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 { func pluralize(n int, singular, plural string) string {
if n == 1 { if n == 1 {
return fmt.Sprintf("%d %s", n, singular) return fmt.Sprintf("%d %s", n, singular)
@ -95,15 +115,15 @@ func buildTrailer(diags diag.Diagnostics) string {
parts = append(parts, color.YellowString(pluralize(warnings, "warning", "warnings"))) parts = append(parts, color.YellowString(pluralize(warnings, "warning", "warnings")))
} }
if len(parts) > 0 { if len(parts) > 0 {
return fmt.Sprintf("Found %s", strings.Join(parts, " and ")) return fmt.Sprintf("Found %s\n", strings.Join(parts, " and "))
} else { } else {
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 { if b == nil {
return renderSummaryTemplate(out, &bundle.Bundle{}, diags) return renderSummaryHeaderTemplate(out, &bundle.Bundle{})
} }
var currentUser = &iam.User{} var currentUser = &iam.User{}
@ -114,20 +134,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{ err := t.Execute(out, map[string]any{
"Name": b.Config.Bundle.Name, "Name": b.Config.Bundle.Name,
"Target": b.Config.Bundle.Target, "Target": b.Config.Bundle.Target,
"User": currentUser.UserName, "User": currentUser.UserName,
"Path": b.Config.Workspace.RootPath, "Path": b.Config.Workspace.RootPath,
"Host": b.Config.Workspace.Host, "Host": b.Config.Workspace.Host,
"Trailer": buildTrailer(diags),
}) })
return err 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 {
errorT := template.Must(template.New("error").Funcs(renderFuncMap).Parse(errorTemplate)) errorT := template.Must(template.New("error").Funcs(renderFuncMap).Parse(errorTemplate))
warningT := template.Must(template.New("warning").Funcs(renderFuncMap).Parse(warningTemplate)) warningT := template.Must(template.New("warning").Funcs(renderFuncMap).Parse(warningTemplate))
@ -173,19 +192,74 @@ type RenderOptions struct {
RenderSummaryTable bool RenderSummaryTable bool
} }
// RenderTextOutput renders the diagnostics in a human-readable format. // RenderDiagnostics renders the diagnostics in a human-readable format.
func RenderTextOutput(out io.Writer, b *bundle.Bundle, diags diag.Diagnostics, opts RenderOptions) error { func RenderDiagnostics(out io.Writer, b *bundle.Bundle, diags diag.Diagnostics, opts RenderOptions) error {
err := renderDiagnostics(out, b, diags) err := renderDiagnosticsOnly(out, b, diags)
if err != nil { if err != nil {
return fmt.Errorf("failed to render diagnostics: %w", err) return fmt.Errorf("failed to render diagnostics: %w", err)
} }
if opts.RenderSummaryTable { if opts.RenderSummaryTable {
err = renderSummaryTemplate(out, b, diags) if b != nil {
if err != nil { err = renderSummaryHeaderTemplate(out, b)
return fmt.Errorf("failed to render summary: %w", err) 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 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, r := range b.Config.Resources.AllResources() {
resources := make([]ResourceInfo, 0, len(r))
for key, resource := range r {
resources = append(resources, ResourceInfo{
Key: key,
Name: resource.GetName(),
URL: resource.GetURL(),
})
}
if len(resources) > 0 {
capitalizedGroup := strings.ToUpper(group[:1]) + group[1:]
resourceGroups = append(resourceGroups, ResourceGroup{
GroupName: capitalizedGroup,
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)
}

View File

@ -2,14 +2,19 @@ package render
import ( import (
"bytes" "bytes"
"context"
"io"
"testing" "testing"
"github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/config" "github.com/databricks/cli/bundle/config"
"github.com/databricks/cli/bundle/config/resources"
"github.com/databricks/cli/libs/diag" "github.com/databricks/cli/libs/diag"
"github.com/databricks/cli/libs/dyn" "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/iam"
"github.com/databricks/databricks-sdk-go/service/jobs"
"github.com/databricks/databricks-sdk-go/service/pipelines"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -192,10 +197,10 @@ func TestRenderTextOutput(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
writer := &bytes.Buffer{} 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) require.NoError(t, err)
assert.Equal(t, tc.expected, writer.String()) require.Equal(t, tc.expected, writer.String())
}) })
} }
} }
@ -310,10 +315,10 @@ func TestRenderDiagnostics(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
writer := &bytes.Buffer{} writer := &bytes.Buffer{}
err := renderDiagnostics(writer, bundle, tc.diags) err := renderDiagnosticsOnly(writer, bundle, tc.diags)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, tc.expected, writer.String()) require.Equal(t, tc.expected, writer.String())
}) })
} }
} }
@ -321,8 +326,92 @@ func TestRenderDiagnostics(t *testing.T) {
func TestRenderSummaryTemplate_nilBundle(t *testing.T) { func TestRenderSummaryTemplate_nilBundle(t *testing.T) {
writer := &bytes.Buffer{} writer := &bytes.Buffer{}
err := renderSummaryTemplate(writer, nil, nil) err := renderSummaryHeaderTemplate(writer, nil)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "Validation OK!\n", writer.String()) io.WriteString(writer, buildTrailer(nil))
require.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
},
},
},
},
}
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
Pipelines:
pipeline1:
Name: pipeline1-name
URL: https://url3
pipeline2:
Name: pipeline2-name
URL: (not deployed)
Schemas:
schema1:
Name: schema
URL: (not deployed)
`
require.Equal(t, expectedSummary, writer.String())
} }

View File

@ -61,7 +61,7 @@ func newDeployCommand() *cobra.Command {
} }
renderOpts := render.RenderOptions{RenderSummaryTable: false} renderOpts := render.RenderOptions{RenderSummaryTable: false}
err := render.RenderTextOutput(cmd.OutOrStdout(), b, diags, renderOpts) err := render.RenderDiagnostics(cmd.OutOrStdout(), b, diags, renderOpts)
if err != nil { if err != nil {
return fmt.Errorf("failed to render output: %w", err) return fmt.Errorf("failed to render output: %w", err)
} }

View File

@ -8,8 +8,10 @@ import (
"path/filepath" "path/filepath"
"github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/config/mutator"
"github.com/databricks/cli/bundle/deploy/terraform" "github.com/databricks/cli/bundle/deploy/terraform"
"github.com/databricks/cli/bundle/phases" "github.com/databricks/cli/bundle/phases"
"github.com/databricks/cli/bundle/render"
"github.com/databricks/cli/cmd/bundle/utils" "github.com/databricks/cli/cmd/bundle/utils"
"github.com/databricks/cli/cmd/root" "github.com/databricks/cli/cmd/root"
"github.com/databricks/cli/libs/flags" "github.com/databricks/cli/libs/flags"
@ -19,11 +21,8 @@ import (
func newSummaryCommand() *cobra.Command { func newSummaryCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "summary", Use: "summary",
Short: "Describe the bundle resources and their deployment states", Short: "Summarize resources deployed by this bundle",
Args: root.NoArgs, Args: root.NoArgs,
// This command is currently intended for the Databricks VSCode extension only
Hidden: true,
} }
var forcePull bool 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 { if err := diags.Error(); err != nil {
return err return err
} }
switch root.OutputType(cmd) { switch root.OutputType(cmd) {
case flags.OutputText: case flags.OutputText:
return fmt.Errorf("%w, only json output is supported", errors.ErrUnsupported) return render.RenderSummary(ctx, cmd.OutOrStdout(), b)
case flags.OutputJSON: case flags.OutputJSON:
buf, err := json.MarshalIndent(b.Config, "", " ") buf, err := json.MarshalIndent(b.Config, "", " ")
if err != nil { if err != nil {

View File

@ -54,7 +54,7 @@ func newValidateCommand() *cobra.Command {
switch root.OutputType(cmd) { switch root.OutputType(cmd) {
case flags.OutputText: case flags.OutputText:
renderOpts := render.RenderOptions{RenderSummaryTable: true} renderOpts := render.RenderOptions{RenderSummaryTable: true}
err := render.RenderTextOutput(cmd.OutOrStdout(), b, diags, renderOpts) err := render.RenderDiagnostics(cmd.OutOrStdout(), b, diags, renderOpts)
if err != nil { if err != nil {
return fmt.Errorf("failed to render output: %w", err) return fmt.Errorf("failed to render output: %w", err)
} }