mirror of https://github.com/databricks/cli.git
Add validation for files with a `.(resource-name).yml` extension (#1780)
## Changes We want to encourage a pattern of specifying only a single resource in a YAML file when the `.(resource-type).yml` extension is used (for example, `.job.yml`). This convention could allow us to bijectively map a resource YAML file to its corresponding resource in the Databricks workspace. This PR: 1. Emits a recommendation diagnostic when we detect this convention is being violated. We can promote this to a warning when we want to encourage this pattern more strongly. 2. Visualises the recommendation diagnostics in the `bundle validate` command. **NOTE:** While this PR also shows the recommendation for `.yaml` files, we do not encourage users to use this extension. We only support it here since it's part of the YAML standard and some existing users might already be using `.yaml`. ## Tests Unit tests and manually. Here's what an example output looks like: ``` Recommendation: define a single job in a file with the .job.yml extension. at resources.jobs.bar resources.jobs.foo in foo.job.yml:13:7 foo.job.yml:5:7 The following resources are defined or configured in this file: - bar (job) - foo (job) ``` --------- Co-authored-by: Lennart Kats (databricks) <lennart.kats@databricks.com>
This commit is contained in:
parent
a8cff48c0b
commit
bca9c2eda4
|
@ -18,7 +18,7 @@ func TestEntryPointNoRootPath(t *testing.T) {
|
|||
|
||||
func TestEntryPoint(t *testing.T) {
|
||||
b := &bundle.Bundle{
|
||||
BundleRootPath: "testdata",
|
||||
BundleRootPath: "testdata/basic",
|
||||
}
|
||||
diags := bundle.Apply(context.Background(), b, loader.EntryPoint())
|
||||
require.NoError(t, diags.Error())
|
||||
|
|
|
@ -3,12 +3,135 @@ package loader
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/databricks/cli/bundle"
|
||||
"github.com/databricks/cli/bundle/config"
|
||||
"github.com/databricks/cli/libs/diag"
|
||||
"github.com/databricks/cli/libs/dyn"
|
||||
)
|
||||
|
||||
func validateFileFormat(configRoot dyn.Value, filePath string) diag.Diagnostics {
|
||||
for _, resourceDescription := range config.SupportedResources() {
|
||||
singularName := resourceDescription.SingularName
|
||||
|
||||
for _, yamlExt := range []string{"yml", "yaml"} {
|
||||
ext := fmt.Sprintf(".%s.%s", singularName, yamlExt)
|
||||
if strings.HasSuffix(filePath, ext) {
|
||||
return validateSingleResourceDefined(configRoot, ext, singularName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSingleResourceDefined(configRoot dyn.Value, ext, typ string) diag.Diagnostics {
|
||||
type resource struct {
|
||||
path dyn.Path
|
||||
value dyn.Value
|
||||
typ string
|
||||
key string
|
||||
}
|
||||
|
||||
resources := []resource{}
|
||||
supportedResources := config.SupportedResources()
|
||||
|
||||
// Gather all resources defined in the resources block.
|
||||
_, err := dyn.MapByPattern(
|
||||
configRoot,
|
||||
dyn.NewPattern(dyn.Key("resources"), dyn.AnyKey(), dyn.AnyKey()),
|
||||
func(p dyn.Path, v dyn.Value) (dyn.Value, error) {
|
||||
// The key for the resource, e.g. "my_job" for jobs.my_job.
|
||||
k := p[2].Key()
|
||||
// The type of the resource, e.g. "job" for jobs.my_job.
|
||||
typ := supportedResources[p[1].Key()].SingularName
|
||||
|
||||
resources = append(resources, resource{path: p, value: v, typ: typ, key: k})
|
||||
return v, nil
|
||||
})
|
||||
if err != nil {
|
||||
return diag.FromErr(err)
|
||||
}
|
||||
|
||||
// Gather all resources defined in a target block.
|
||||
_, err = dyn.MapByPattern(
|
||||
configRoot,
|
||||
dyn.NewPattern(dyn.Key("targets"), dyn.AnyKey(), dyn.Key("resources"), dyn.AnyKey(), dyn.AnyKey()),
|
||||
func(p dyn.Path, v dyn.Value) (dyn.Value, error) {
|
||||
// The key for the resource, e.g. "my_job" for jobs.my_job.
|
||||
k := p[4].Key()
|
||||
// The type of the resource, e.g. "job" for jobs.my_job.
|
||||
typ := supportedResources[p[3].Key()].SingularName
|
||||
|
||||
resources = append(resources, resource{path: p, value: v, typ: typ, key: k})
|
||||
return v, nil
|
||||
})
|
||||
if err != nil {
|
||||
return diag.FromErr(err)
|
||||
}
|
||||
|
||||
typeMatch := true
|
||||
seenKeys := map[string]struct{}{}
|
||||
for _, rr := range resources {
|
||||
// case: The resource is not of the correct type.
|
||||
if rr.typ != typ {
|
||||
typeMatch = false
|
||||
break
|
||||
}
|
||||
|
||||
seenKeys[rr.key] = struct{}{}
|
||||
}
|
||||
|
||||
// Format matches. There's at most one resource defined in the file.
|
||||
// The resource is also of the correct type.
|
||||
if typeMatch && len(seenKeys) <= 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
detail := strings.Builder{}
|
||||
detail.WriteString("The following resources are defined or configured in this file:\n")
|
||||
lines := []string{}
|
||||
for _, r := range resources {
|
||||
lines = append(lines, fmt.Sprintf(" - %s (%s)\n", r.key, r.typ))
|
||||
}
|
||||
// Sort the lines to print to make the output deterministic.
|
||||
sort.Strings(lines)
|
||||
// Compact the lines before writing them to the message to remove any duplicate lines.
|
||||
// This is needed because we do not dedup earlier when gathering the resources
|
||||
// and it's valid to define the same resource in both the resources and targets block.
|
||||
lines = slices.Compact(lines)
|
||||
for _, l := range lines {
|
||||
detail.WriteString(l)
|
||||
}
|
||||
|
||||
locations := []dyn.Location{}
|
||||
paths := []dyn.Path{}
|
||||
for _, rr := range resources {
|
||||
locations = append(locations, rr.value.Locations()...)
|
||||
paths = append(paths, rr.path)
|
||||
}
|
||||
// Sort the locations and paths to make the output deterministic.
|
||||
sort.Slice(locations, func(i, j int) bool {
|
||||
return locations[i].String() < locations[j].String()
|
||||
})
|
||||
sort.Slice(paths, func(i, j int) bool {
|
||||
return paths[i].String() < paths[j].String()
|
||||
})
|
||||
|
||||
return diag.Diagnostics{
|
||||
{
|
||||
Severity: diag.Recommendation,
|
||||
Summary: fmt.Sprintf("define a single %s in a file with the %s extension.", strings.ReplaceAll(typ, "_", " "), ext),
|
||||
Detail: detail.String(),
|
||||
Locations: locations,
|
||||
Paths: paths,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type processInclude struct {
|
||||
fullPath string
|
||||
relPath string
|
||||
|
@ -31,6 +154,13 @@ func (m *processInclude) Apply(_ context.Context, b *bundle.Bundle) diag.Diagnos
|
|||
if diags.HasError() {
|
||||
return diags
|
||||
}
|
||||
|
||||
// Add any diagnostics associated with the file format.
|
||||
diags = append(diags, validateFileFormat(this.Value(), m.relPath)...)
|
||||
if diags.HasError() {
|
||||
return diags
|
||||
}
|
||||
|
||||
err := b.Config.Merge(this)
|
||||
if err != nil {
|
||||
diags = diags.Extend(diag.FromErr(err))
|
||||
|
|
|
@ -8,13 +8,15 @@ import (
|
|||
"github.com/databricks/cli/bundle"
|
||||
"github.com/databricks/cli/bundle/config"
|
||||
"github.com/databricks/cli/bundle/config/loader"
|
||||
"github.com/databricks/cli/libs/diag"
|
||||
"github.com/databricks/cli/libs/dyn"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestProcessInclude(t *testing.T) {
|
||||
b := &bundle.Bundle{
|
||||
BundleRootPath: "testdata",
|
||||
BundleRootPath: "testdata/basic",
|
||||
Config: config.Root{
|
||||
Workspace: config.Workspace{
|
||||
Host: "foo",
|
||||
|
@ -33,3 +35,184 @@ func TestProcessInclude(t *testing.T) {
|
|||
require.NoError(t, diags.Error())
|
||||
assert.Equal(t, "bar", b.Config.Workspace.Host)
|
||||
}
|
||||
|
||||
func TestProcessIncludeFormatMatch(t *testing.T) {
|
||||
for _, fileName := range []string{
|
||||
"one_job.job.yml",
|
||||
"one_pipeline.pipeline.yaml",
|
||||
"two_job.yml",
|
||||
"job_and_pipeline.yml",
|
||||
"multiple_resources.yml",
|
||||
} {
|
||||
t.Run(fileName, func(t *testing.T) {
|
||||
b := &bundle.Bundle{
|
||||
BundleRootPath: "testdata/format_match",
|
||||
Config: config.Root{
|
||||
Bundle: config.Bundle{
|
||||
Name: "format_test",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
m := loader.ProcessInclude(filepath.Join(b.BundleRootPath, fileName), fileName)
|
||||
diags := bundle.Apply(context.Background(), b, m)
|
||||
assert.Empty(t, diags)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessIncludeFormatNotMatch(t *testing.T) {
|
||||
for fileName, expectedDiags := range map[string]diag.Diagnostics{
|
||||
"single_job.pipeline.yaml": {
|
||||
{
|
||||
Severity: diag.Recommendation,
|
||||
Summary: "define a single pipeline in a file with the .pipeline.yaml extension.",
|
||||
Detail: "The following resources are defined or configured in this file:\n - job1 (job)\n",
|
||||
Locations: []dyn.Location{
|
||||
{File: filepath.FromSlash("testdata/format_not_match/single_job.pipeline.yaml"), Line: 11, Column: 11},
|
||||
{File: filepath.FromSlash("testdata/format_not_match/single_job.pipeline.yaml"), Line: 4, Column: 7},
|
||||
},
|
||||
Paths: []dyn.Path{
|
||||
dyn.MustPathFromString("resources.jobs.job1"),
|
||||
dyn.MustPathFromString("targets.target1.resources.jobs.job1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"job_and_pipeline.job.yml": {
|
||||
{
|
||||
Severity: diag.Recommendation,
|
||||
Summary: "define a single job in a file with the .job.yml extension.",
|
||||
Detail: "The following resources are defined or configured in this file:\n - job1 (job)\n - pipeline1 (pipeline)\n",
|
||||
Locations: []dyn.Location{
|
||||
{File: filepath.FromSlash("testdata/format_not_match/job_and_pipeline.job.yml"), Line: 11, Column: 11},
|
||||
{File: filepath.FromSlash("testdata/format_not_match/job_and_pipeline.job.yml"), Line: 4, Column: 7},
|
||||
},
|
||||
Paths: []dyn.Path{
|
||||
dyn.MustPathFromString("resources.pipelines.pipeline1"),
|
||||
dyn.MustPathFromString("targets.target1.resources.jobs.job1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"job_and_pipeline.experiment.yml": {
|
||||
{
|
||||
Severity: diag.Recommendation,
|
||||
Summary: "define a single experiment in a file with the .experiment.yml extension.",
|
||||
Detail: "The following resources are defined or configured in this file:\n - job1 (job)\n - pipeline1 (pipeline)\n",
|
||||
Locations: []dyn.Location{
|
||||
{File: filepath.FromSlash("testdata/format_not_match/job_and_pipeline.experiment.yml"), Line: 11, Column: 11},
|
||||
{File: filepath.FromSlash("testdata/format_not_match/job_and_pipeline.experiment.yml"), Line: 4, Column: 7},
|
||||
},
|
||||
Paths: []dyn.Path{
|
||||
dyn.MustPathFromString("resources.pipelines.pipeline1"),
|
||||
dyn.MustPathFromString("targets.target1.resources.jobs.job1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"two_jobs.job.yml": {
|
||||
{
|
||||
Severity: diag.Recommendation,
|
||||
Summary: "define a single job in a file with the .job.yml extension.",
|
||||
Detail: "The following resources are defined or configured in this file:\n - job1 (job)\n - job2 (job)\n",
|
||||
Locations: []dyn.Location{
|
||||
{File: filepath.FromSlash("testdata/format_not_match/two_jobs.job.yml"), Line: 4, Column: 7},
|
||||
{File: filepath.FromSlash("testdata/format_not_match/two_jobs.job.yml"), Line: 7, Column: 7},
|
||||
},
|
||||
Paths: []dyn.Path{
|
||||
dyn.MustPathFromString("resources.jobs.job1"),
|
||||
dyn.MustPathFromString("resources.jobs.job2"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"second_job_in_target.job.yml": {
|
||||
{
|
||||
Severity: diag.Recommendation,
|
||||
Summary: "define a single job in a file with the .job.yml extension.",
|
||||
Detail: "The following resources are defined or configured in this file:\n - job1 (job)\n - job2 (job)\n",
|
||||
Locations: []dyn.Location{
|
||||
{File: filepath.FromSlash("testdata/format_not_match/second_job_in_target.job.yml"), Line: 11, Column: 11},
|
||||
{File: filepath.FromSlash("testdata/format_not_match/second_job_in_target.job.yml"), Line: 4, Column: 7},
|
||||
},
|
||||
Paths: []dyn.Path{
|
||||
dyn.MustPathFromString("resources.jobs.job1"),
|
||||
dyn.MustPathFromString("targets.target1.resources.jobs.job2"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"two_jobs_in_target.job.yml": {
|
||||
{
|
||||
Severity: diag.Recommendation,
|
||||
Summary: "define a single job in a file with the .job.yml extension.",
|
||||
Detail: "The following resources are defined or configured in this file:\n - job1 (job)\n - job2 (job)\n",
|
||||
Locations: []dyn.Location{
|
||||
{File: filepath.FromSlash("testdata/format_not_match/two_jobs_in_target.job.yml"), Line: 6, Column: 11},
|
||||
{File: filepath.FromSlash("testdata/format_not_match/two_jobs_in_target.job.yml"), Line: 8, Column: 11},
|
||||
},
|
||||
Paths: []dyn.Path{
|
||||
dyn.MustPathFromString("targets.target1.resources.jobs.job1"),
|
||||
dyn.MustPathFromString("targets.target1.resources.jobs.job2"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"multiple_resources.model_serving_endpoint.yml": {
|
||||
{
|
||||
Severity: diag.Recommendation,
|
||||
Summary: "define a single model serving endpoint in a file with the .model_serving_endpoint.yml extension.",
|
||||
Detail: `The following resources are defined or configured in this file:
|
||||
- experiment1 (experiment)
|
||||
- job1 (job)
|
||||
- job2 (job)
|
||||
- job3 (job)
|
||||
- model1 (model)
|
||||
- model_serving_endpoint1 (model_serving_endpoint)
|
||||
- pipeline1 (pipeline)
|
||||
- pipeline2 (pipeline)
|
||||
- quality_monitor1 (quality_monitor)
|
||||
- registered_model1 (registered_model)
|
||||
- schema1 (schema)
|
||||
`,
|
||||
Locations: []dyn.Location{
|
||||
{File: filepath.FromSlash("testdata/format_not_match/multiple_resources.model_serving_endpoint.yml"), Line: 12, Column: 7},
|
||||
{File: filepath.FromSlash("testdata/format_not_match/multiple_resources.model_serving_endpoint.yml"), Line: 14, Column: 7},
|
||||
{File: filepath.FromSlash("testdata/format_not_match/multiple_resources.model_serving_endpoint.yml"), Line: 18, Column: 7},
|
||||
{File: filepath.FromSlash("testdata/format_not_match/multiple_resources.model_serving_endpoint.yml"), Line: 22, Column: 7},
|
||||
{File: filepath.FromSlash("testdata/format_not_match/multiple_resources.model_serving_endpoint.yml"), Line: 24, Column: 7},
|
||||
{File: filepath.FromSlash("testdata/format_not_match/multiple_resources.model_serving_endpoint.yml"), Line: 28, Column: 7},
|
||||
{File: filepath.FromSlash("testdata/format_not_match/multiple_resources.model_serving_endpoint.yml"), Line: 35, Column: 11},
|
||||
{File: filepath.FromSlash("testdata/format_not_match/multiple_resources.model_serving_endpoint.yml"), Line: 39, Column: 11},
|
||||
{File: filepath.FromSlash("testdata/format_not_match/multiple_resources.model_serving_endpoint.yml"), Line: 43, Column: 11},
|
||||
{File: filepath.FromSlash("testdata/format_not_match/multiple_resources.model_serving_endpoint.yml"), Line: 4, Column: 7},
|
||||
{File: filepath.FromSlash("testdata/format_not_match/multiple_resources.model_serving_endpoint.yml"), Line: 8, Column: 7},
|
||||
},
|
||||
Paths: []dyn.Path{
|
||||
dyn.MustPathFromString("resources.experiments.experiment1"),
|
||||
dyn.MustPathFromString("resources.jobs.job1"),
|
||||
dyn.MustPathFromString("resources.jobs.job2"),
|
||||
dyn.MustPathFromString("resources.model_serving_endpoints.model_serving_endpoint1"),
|
||||
dyn.MustPathFromString("resources.models.model1"),
|
||||
dyn.MustPathFromString("resources.pipelines.pipeline1"),
|
||||
dyn.MustPathFromString("resources.pipelines.pipeline2"),
|
||||
dyn.MustPathFromString("resources.schemas.schema1"),
|
||||
dyn.MustPathFromString("targets.target1.resources.jobs.job3"),
|
||||
dyn.MustPathFromString("targets.target1.resources.quality_monitors.quality_monitor1"),
|
||||
dyn.MustPathFromString("targets.target1.resources.registered_models.registered_model1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(fileName, func(t *testing.T) {
|
||||
b := &bundle.Bundle{
|
||||
BundleRootPath: "testdata/format_not_match",
|
||||
Config: config.Root{
|
||||
Bundle: config.Bundle{
|
||||
Name: "format_test",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
m := loader.ProcessInclude(filepath.Join(b.BundleRootPath, fileName), fileName)
|
||||
diags := bundle.Apply(context.Background(), b, m)
|
||||
require.Len(t, diags, 1)
|
||||
assert.Equal(t, expectedDiags, diags)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
resources:
|
||||
pipelines:
|
||||
pipeline1:
|
||||
name: pipeline1
|
||||
|
||||
targets:
|
||||
target1:
|
||||
resources:
|
||||
jobs:
|
||||
job1:
|
||||
name: job1
|
|
@ -0,0 +1,43 @@
|
|||
resources:
|
||||
experiments:
|
||||
experiment1:
|
||||
name: experiment1
|
||||
|
||||
model_serving_endpoints:
|
||||
model_serving_endpoint1:
|
||||
name: model_serving_endpoint1
|
||||
|
||||
jobs:
|
||||
job1:
|
||||
name: job1
|
||||
job2:
|
||||
name: job2
|
||||
|
||||
models:
|
||||
model1:
|
||||
name: model1
|
||||
|
||||
pipelines:
|
||||
pipeline1:
|
||||
name: pipeline1
|
||||
pipeline2:
|
||||
name: pipeline2
|
||||
|
||||
schemas:
|
||||
schema1:
|
||||
name: schema1
|
||||
|
||||
targets:
|
||||
target1:
|
||||
resources:
|
||||
quality_monitors:
|
||||
quality_monitor1:
|
||||
baseline_table_name: quality_monitor1
|
||||
|
||||
jobs:
|
||||
job3:
|
||||
name: job3
|
||||
|
||||
registered_models:
|
||||
registered_model1:
|
||||
name: registered_model1
|
|
@ -0,0 +1,11 @@
|
|||
resources:
|
||||
jobs:
|
||||
job1:
|
||||
name: job1
|
||||
|
||||
targets:
|
||||
target1:
|
||||
resources:
|
||||
jobs:
|
||||
job1:
|
||||
description: job1
|
|
@ -0,0 +1,4 @@
|
|||
resources:
|
||||
pipelines:
|
||||
pipeline1:
|
||||
name: pipeline1
|
|
@ -0,0 +1,7 @@
|
|||
resources:
|
||||
jobs:
|
||||
job1:
|
||||
name: job1
|
||||
|
||||
job2:
|
||||
name: job2
|
11
bundle/config/loader/testdata/format_not_match/job_and_pipeline.experiment.yml
vendored
Normal file
11
bundle/config/loader/testdata/format_not_match/job_and_pipeline.experiment.yml
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
resources:
|
||||
pipelines:
|
||||
pipeline1:
|
||||
name: pipeline1
|
||||
|
||||
targets:
|
||||
target1:
|
||||
resources:
|
||||
jobs:
|
||||
job1:
|
||||
name: job1
|
|
@ -0,0 +1,11 @@
|
|||
resources:
|
||||
pipelines:
|
||||
pipeline1:
|
||||
name: pipeline1
|
||||
|
||||
targets:
|
||||
target1:
|
||||
resources:
|
||||
jobs:
|
||||
job1:
|
||||
name: job1
|
43
bundle/config/loader/testdata/format_not_match/multiple_resources.model_serving_endpoint.yml
vendored
Normal file
43
bundle/config/loader/testdata/format_not_match/multiple_resources.model_serving_endpoint.yml
vendored
Normal file
|
@ -0,0 +1,43 @@
|
|||
resources:
|
||||
experiments:
|
||||
experiment1:
|
||||
name: experiment1
|
||||
|
||||
model_serving_endpoints:
|
||||
model_serving_endpoint1:
|
||||
name: model_serving_endpoint1
|
||||
|
||||
jobs:
|
||||
job1:
|
||||
name: job1
|
||||
job2:
|
||||
name: job2
|
||||
|
||||
models:
|
||||
model1:
|
||||
name: model1
|
||||
|
||||
pipelines:
|
||||
pipeline1:
|
||||
name: pipeline1
|
||||
pipeline2:
|
||||
name: pipeline2
|
||||
|
||||
schemas:
|
||||
schema1:
|
||||
name: schema1
|
||||
|
||||
targets:
|
||||
target1:
|
||||
resources:
|
||||
quality_monitors:
|
||||
quality_monitor1:
|
||||
baseline_table_name: quality_monitor1
|
||||
|
||||
jobs:
|
||||
job3:
|
||||
name: job3
|
||||
|
||||
registered_models:
|
||||
registered_model1:
|
||||
name: registered_model1
|
|
@ -0,0 +1,11 @@
|
|||
resources:
|
||||
jobs:
|
||||
job1:
|
||||
name: job1
|
||||
|
||||
targets:
|
||||
target1:
|
||||
resources:
|
||||
jobs:
|
||||
job2:
|
||||
name: job2
|
|
@ -0,0 +1,11 @@
|
|||
resources:
|
||||
jobs:
|
||||
job1:
|
||||
name: job1
|
||||
|
||||
targets:
|
||||
target1:
|
||||
resources:
|
||||
jobs:
|
||||
job1:
|
||||
description: job1
|
|
@ -0,0 +1,7 @@
|
|||
resources:
|
||||
jobs:
|
||||
job1:
|
||||
name: job1
|
||||
|
||||
job2:
|
||||
name: job2
|
|
@ -0,0 +1,8 @@
|
|||
targets:
|
||||
target1:
|
||||
resources:
|
||||
jobs:
|
||||
job1:
|
||||
description: job1
|
||||
job2:
|
||||
description: job2
|
|
@ -59,3 +59,22 @@ func (r *Resources) FindResourceByConfigKey(key string) (ConfigResource, error)
|
|||
|
||||
return found[0], nil
|
||||
}
|
||||
|
||||
type ResourceDescription struct {
|
||||
SingularName 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"},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package config
|
|||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
@ -61,3 +62,18 @@ func TestCustomMarshallerIsImplemented(t *testing.T) {
|
|||
}, "Resource %s does not have a custom unmarshaller", field.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportedResources(t *testing.T) {
|
||||
expected := map[string]ResourceDescription{}
|
||||
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}
|
||||
}
|
||||
|
||||
// Please add your resource to the SupportedResources() function in resources.go
|
||||
// if you are adding a new resource.
|
||||
assert.Equal(t, expected, SupportedResources())
|
||||
}
|
||||
|
|
|
@ -56,6 +56,20 @@ const warningTemplate = `{{ "Warning" | yellow }}: {{ .Summary }}
|
|||
|
||||
`
|
||||
|
||||
const recommendationTemplate = `{{ "Recommendation" | blue }}: {{ .Summary }}
|
||||
{{- range $index, $element := .Paths }}
|
||||
{{ if eq $index 0 }}at {{else}} {{ end}}{{ $element.String | green }}
|
||||
{{- end }}
|
||||
{{- range $index, $element := .Locations }}
|
||||
{{ if eq $index 0 }}in {{else}} {{ end}}{{ $element.String | cyan }}
|
||||
{{- end }}
|
||||
{{- if .Detail }}
|
||||
|
||||
{{ .Detail }}
|
||||
{{- end }}
|
||||
|
||||
`
|
||||
|
||||
const summaryTemplate = `{{- if .Name -}}
|
||||
Name: {{ .Name | bold }}
|
||||
{{- if .Target }}
|
||||
|
@ -94,9 +108,20 @@ func buildTrailer(diags diag.Diagnostics) string {
|
|||
if warnings := len(diags.Filter(diag.Warning)); warnings > 0 {
|
||||
parts = append(parts, color.YellowString(pluralize(warnings, "warning", "warnings")))
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
return fmt.Sprintf("Found %s", strings.Join(parts, " and "))
|
||||
} else {
|
||||
if recommendations := len(diags.Filter(diag.Recommendation)); recommendations > 0 {
|
||||
parts = append(parts, color.BlueString(pluralize(recommendations, "recommendation", "recommendations")))
|
||||
}
|
||||
switch {
|
||||
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)
|
||||
case len(parts) == 2:
|
||||
return fmt.Sprintf("Found %s and %s", parts[0], parts[1])
|
||||
case len(parts) == 1:
|
||||
return fmt.Sprintf("Found %s", parts[0])
|
||||
default:
|
||||
// No diagnostics to print.
|
||||
return color.GreenString("Validation OK!")
|
||||
}
|
||||
}
|
||||
|
@ -130,6 +155,7 @@ func renderSummaryTemplate(out io.Writer, b *bundle.Bundle, diags diag.Diagnosti
|
|||
func renderDiagnostics(out io.Writer, b *bundle.Bundle, diags diag.Diagnostics) error {
|
||||
errorT := template.Must(template.New("error").Funcs(renderFuncMap).Parse(errorTemplate))
|
||||
warningT := template.Must(template.New("warning").Funcs(renderFuncMap).Parse(warningTemplate))
|
||||
recommendationT := template.Must(template.New("recommendation").Funcs(renderFuncMap).Parse(recommendationTemplate))
|
||||
|
||||
// Print errors and warnings.
|
||||
for _, d := range diags {
|
||||
|
@ -139,6 +165,8 @@ func renderDiagnostics(out io.Writer, b *bundle.Bundle, diags diag.Diagnostics)
|
|||
t = errorT
|
||||
case diag.Warning:
|
||||
t = warningT
|
||||
case diag.Recommendation:
|
||||
t = recommendationT
|
||||
}
|
||||
|
||||
for i := range d.Locations {
|
||||
|
|
|
@ -45,6 +45,19 @@ func TestRenderTextOutput(t *testing.T) {
|
|||
"\n" +
|
||||
"Found 1 error\n",
|
||||
},
|
||||
{
|
||||
name: "nil bundle and 1 recommendation",
|
||||
diags: diag.Diagnostics{
|
||||
{
|
||||
Severity: diag.Recommendation,
|
||||
Summary: "recommendation",
|
||||
},
|
||||
},
|
||||
opts: RenderOptions{RenderSummaryTable: true},
|
||||
expected: "Recommendation: recommendation\n" +
|
||||
"\n" +
|
||||
"Found 1 recommendation\n",
|
||||
},
|
||||
{
|
||||
name: "bundle during 'load' and 1 error",
|
||||
bundle: loadingBundle,
|
||||
|
@ -84,7 +97,7 @@ func TestRenderTextOutput(t *testing.T) {
|
|||
"Found 2 warnings\n",
|
||||
},
|
||||
{
|
||||
name: "bundle during 'load' and 2 errors, 1 warning with details",
|
||||
name: "bundle during 'load' and 2 errors, 1 warning and 1 recommendation with details",
|
||||
bundle: loadingBundle,
|
||||
diags: diag.Diagnostics{
|
||||
diag.Diagnostic{
|
||||
|
@ -105,6 +118,12 @@ func TestRenderTextOutput(t *testing.T) {
|
|||
Detail: "detail (3)",
|
||||
Locations: []dyn.Location{{File: "foo.py", Line: 3, Column: 1}},
|
||||
},
|
||||
diag.Diagnostic{
|
||||
Severity: diag.Recommendation,
|
||||
Summary: "recommendation (4)",
|
||||
Detail: "detail (4)",
|
||||
Locations: []dyn.Location{{File: "foo.py", Line: 4, Column: 1}},
|
||||
},
|
||||
},
|
||||
opts: RenderOptions{RenderSummaryTable: true},
|
||||
expected: "Error: error (1)\n" +
|
||||
|
@ -122,10 +141,114 @@ func TestRenderTextOutput(t *testing.T) {
|
|||
"\n" +
|
||||
"detail (3)\n" +
|
||||
"\n" +
|
||||
"Recommendation: recommendation (4)\n" +
|
||||
" in foo.py:4:1\n" +
|
||||
"\n" +
|
||||
"detail (4)\n" +
|
||||
"\n" +
|
||||
"Name: test-bundle\n" +
|
||||
"Target: test-target\n" +
|
||||
"\n" +
|
||||
"Found 2 errors and 1 warning\n",
|
||||
"Found 2 errors, 1 warning, and 1 recommendation\n",
|
||||
},
|
||||
{
|
||||
name: "bundle during 'load' and 1 error and 1 warning",
|
||||
bundle: loadingBundle,
|
||||
diags: diag.Diagnostics{
|
||||
diag.Diagnostic{
|
||||
Severity: diag.Error,
|
||||
Summary: "error (1)",
|
||||
Detail: "detail (1)",
|
||||
Locations: []dyn.Location{{File: "foo.py", Line: 1, Column: 1}},
|
||||
},
|
||||
diag.Diagnostic{
|
||||
Severity: diag.Warning,
|
||||
Summary: "warning (2)",
|
||||
Detail: "detail (2)",
|
||||
Locations: []dyn.Location{{File: "foo.py", Line: 2, Column: 1}},
|
||||
},
|
||||
},
|
||||
opts: RenderOptions{RenderSummaryTable: true},
|
||||
expected: "Error: error (1)\n" +
|
||||
" in foo.py:1:1\n" +
|
||||
"\n" +
|
||||
"detail (1)\n" +
|
||||
"\n" +
|
||||
"Warning: warning (2)\n" +
|
||||
" in foo.py:2:1\n" +
|
||||
"\n" +
|
||||
"detail (2)\n" +
|
||||
"\n" +
|
||||
"Name: test-bundle\n" +
|
||||
"Target: test-target\n" +
|
||||
"\n" +
|
||||
"Found 1 error and 1 warning\n",
|
||||
},
|
||||
{
|
||||
name: "bundle during 'load' and 1 errors, 2 warning and 2 recommendations with details",
|
||||
bundle: loadingBundle,
|
||||
diags: diag.Diagnostics{
|
||||
diag.Diagnostic{
|
||||
Severity: diag.Error,
|
||||
Summary: "error (1)",
|
||||
Detail: "detail (1)",
|
||||
Locations: []dyn.Location{{File: "foo.py", Line: 1, Column: 1}},
|
||||
},
|
||||
diag.Diagnostic{
|
||||
Severity: diag.Warning,
|
||||
Summary: "warning (2)",
|
||||
Detail: "detail (2)",
|
||||
Locations: []dyn.Location{{File: "foo.py", Line: 2, Column: 1}},
|
||||
},
|
||||
diag.Diagnostic{
|
||||
Severity: diag.Warning,
|
||||
Summary: "warning (3)",
|
||||
Detail: "detail (3)",
|
||||
Locations: []dyn.Location{{File: "foo.py", Line: 3, Column: 1}},
|
||||
},
|
||||
diag.Diagnostic{
|
||||
Severity: diag.Recommendation,
|
||||
Summary: "recommendation (4)",
|
||||
Detail: "detail (4)",
|
||||
Locations: []dyn.Location{{File: "foo.py", Line: 4, Column: 1}},
|
||||
},
|
||||
diag.Diagnostic{
|
||||
Severity: diag.Recommendation,
|
||||
Summary: "recommendation (5)",
|
||||
Detail: "detail (5)",
|
||||
Locations: []dyn.Location{{File: "foo.py", Line: 5, Column: 1}},
|
||||
},
|
||||
},
|
||||
opts: RenderOptions{RenderSummaryTable: true},
|
||||
expected: "Error: error (1)\n" +
|
||||
" in foo.py:1:1\n" +
|
||||
"\n" +
|
||||
"detail (1)\n" +
|
||||
"\n" +
|
||||
"Warning: warning (2)\n" +
|
||||
" in foo.py:2:1\n" +
|
||||
"\n" +
|
||||
"detail (2)\n" +
|
||||
"\n" +
|
||||
"Warning: warning (3)\n" +
|
||||
" in foo.py:3:1\n" +
|
||||
"\n" +
|
||||
"detail (3)\n" +
|
||||
"\n" +
|
||||
"Recommendation: recommendation (4)\n" +
|
||||
" in foo.py:4:1\n" +
|
||||
"\n" +
|
||||
"detail (4)\n" +
|
||||
"\n" +
|
||||
"Recommendation: recommendation (5)\n" +
|
||||
" in foo.py:5:1\n" +
|
||||
"\n" +
|
||||
"detail (5)\n" +
|
||||
"\n" +
|
||||
"Name: test-bundle\n" +
|
||||
"Target: test-target\n" +
|
||||
"\n" +
|
||||
"Found 1 error, 2 warnings, and 2 recommendations\n",
|
||||
},
|
||||
{
|
||||
name: "bundle during 'init'",
|
||||
|
@ -158,7 +281,7 @@ func TestRenderTextOutput(t *testing.T) {
|
|||
"Validation OK!\n",
|
||||
},
|
||||
{
|
||||
name: "nil bundle without summary with 1 error and 1 warning",
|
||||
name: "nil bundle without summary with 1 error, 1 warning and 1 recommendation",
|
||||
bundle: nil,
|
||||
diags: diag.Diagnostics{
|
||||
diag.Diagnostic{
|
||||
|
@ -173,6 +296,12 @@ func TestRenderTextOutput(t *testing.T) {
|
|||
Detail: "detail (2)",
|
||||
Locations: []dyn.Location{{File: "foo.py", Line: 3, Column: 1}},
|
||||
},
|
||||
diag.Diagnostic{
|
||||
Severity: diag.Recommendation,
|
||||
Summary: "recommendation (3)",
|
||||
Detail: "detail (3)",
|
||||
Locations: []dyn.Location{{File: "foo.py", Line: 5, Column: 1}},
|
||||
},
|
||||
},
|
||||
opts: RenderOptions{RenderSummaryTable: false},
|
||||
expected: "Error: error (1)\n" +
|
||||
|
@ -184,6 +313,11 @@ func TestRenderTextOutput(t *testing.T) {
|
|||
" in foo.py:3:1\n" +
|
||||
"\n" +
|
||||
"detail (2)\n" +
|
||||
"\n" +
|
||||
"Recommendation: recommendation (3)\n" +
|
||||
" in foo.py:5:1\n" +
|
||||
"\n" +
|
||||
"detail (3)\n" +
|
||||
"\n",
|
||||
},
|
||||
}
|
||||
|
@ -304,6 +438,30 @@ func TestRenderDiagnostics(t *testing.T) {
|
|||
"\n" +
|
||||
"'name' is required\n\n",
|
||||
},
|
||||
{
|
||||
name: "recommendation with multiple paths and locations",
|
||||
diags: diag.Diagnostics{
|
||||
{
|
||||
Severity: diag.Recommendation,
|
||||
Summary: "summary",
|
||||
Detail: "detail",
|
||||
Paths: []dyn.Path{
|
||||
dyn.MustPathFromString("resources.jobs.xxx"),
|
||||
dyn.MustPathFromString("resources.jobs.yyy"),
|
||||
},
|
||||
Locations: []dyn.Location{
|
||||
{File: "foo.yaml", Line: 1, Column: 2},
|
||||
{File: "bar.yaml", Line: 3, Column: 4},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: "Recommendation: summary\n" +
|
||||
" at resources.jobs.xxx\n" +
|
||||
" resources.jobs.yyy\n" +
|
||||
" in foo.yaml:1:2\n" +
|
||||
" bar.yaml:3:4\n\n" +
|
||||
"detail\n\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
|
|
|
@ -6,4 +6,5 @@ const (
|
|||
Error Severity = iota
|
||||
Warning
|
||||
Info
|
||||
Recommendation
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue