Compare commits

...

57 Commits

Author SHA1 Message Date
shreyas-goenka ece4725328
Merge 039057fdd7 into 75b09ff230 2024-11-20 15:53:31 +05:30
Pieter Noordhuis 75b09ff230
Use `filer.Filer` to write template instantiation (#1911)
## Changes

Prior to this change, the output directory was part of the `renderer`
type and passed down to every `file` it produced. Every file knew its
absolute destination path. This is incompatible with the use of a filer,
where all operations are automatically anchored to some base path.

To make this compatible, this change updates:
* the `file` type to only know its own path relative to the instantiation root,
* the `renderer` type to no longer require or pass along the output directory,
* the `persistToDisk` function to take a context and filer argument,
* the `filer.WriteMode` to represent permission bits

## Tests

* Existing tests pass.
* Manually confirmed template initialization works as expected.
2024-11-20 11:11:31 +01:00
Pieter Noordhuis 4fea0219fd
Use `fs.FS` interface to read template (#1910)
## Changes

While working on the v2 of #1744, I found that:
* Template initialization first copies built-in templates to a temporary
directory before initializing them
* Reading a template's contents goes through a `filer.Filer` but is
hardcoded to a local one

This change updates the interface for reading templates to be `fs.FS`.
This is compatible with the `embed.FS` type for the built-in templates,
so they no longer have to be copied to a temporary directory before
being used.

The alternative is to use a `filer.Filer` throughout, but this would
have required even more plumbing, and we don't need to _read_ templates,
including notebooks, from the workspace filesystem (yet?).

As part of making `template.Materialize` take an `fs.FS` argument, the
logic to match a given argument to a particular built-in template in the
`init` command has moved to sit next to its implementation.

## Tests

Existing tests pass.
2024-11-20 09:28:35 +00:00
Shreyas Goenka 039057fdd7
fix renaming test 2024-11-18 18:11:55 +01:00
Shreyas Goenka 76092ccaa7
remove todo 2024-11-18 17:58:07 +01:00
Shreyas Goenka ea6906e88c
add support for initializeUrl 2024-11-18 17:52:17 +01:00
Shreyas Goenka f5ea8dac26
remove other mutator 2024-11-18 17:35:03 +01:00
Shreyas Goenka e6723deb9d
undo containsUsername 2024-11-18 17:10:28 +01:00
Shreyas Goenka b0e527efe8
- 2024-11-18 17:07:54 +01:00
Shreyas Goenka 040626589a
- 2024-11-18 17:07:24 +01:00
Shreyas Goenka 4cc2790300
remove prefixing for uc volumes 2024-11-18 17:03:22 +01:00
Shreyas Goenka 68dc6c1ce4
Merge remote-tracking branch 'origin' into feature/uc-volumes 2024-11-18 16:56:37 +01:00
Shreyas Goenka 1218178e64
- 2024-10-31 18:01:37 +01:00
Shreyas Goenka 250d4265ce
rename to volume 2024-10-31 18:00:31 +01:00
Shreyas Goenka f9287e0101
address comments 2024-10-31 17:52:45 +01:00
Shreyas Goenka 6b122348ad
better message 2024-10-31 15:57:45 +01:00
Shreyas Goenka e32ebd0b48
use IsVolumesPath 2024-10-31 15:24:51 +01:00
Shreyas Goenka 49b2cf2723
Merge remote-tracking branch 'origin' into feature/uc-volumes 2024-10-31 15:10:29 +01:00
Shreyas Goenka 8a2fe4969c
merge 2024-10-29 11:38:42 +01:00
Shreyas Goenka 1a961eb19c
- 2024-10-16 13:46:45 +02:00
Shreyas Goenka 810da66809
- 2024-10-16 13:36:55 +02:00
Shreyas Goenka 701b1786a8
fmt 2024-10-15 22:03:29 +02:00
Shreyas Goenka 6192835d63
add custom prefixing behaviour for volumes 2024-10-15 18:04:20 +02:00
Shreyas Goenka d241c2b39c
add integration test for grant on volume 2024-10-15 16:05:23 +02:00
Shreyas Goenka 3e3ddfd0cb
fix test 2024-10-15 15:29:24 +02:00
Shreyas Goenka eb94cd6717
- 2024-10-15 15:27:58 +02:00
Shreyas Goenka c5a02ef8fb
split into filer files 2024-10-15 15:22:41 +02:00
Shreyas Goenka a9b8575bc3
fmt and fix test 2024-10-14 15:49:19 +02:00
Shreyas Goenka 266c26ce09
fix tesT 2024-10-14 15:41:47 +02:00
Shreyas Goenka 9921263928
separate GetFilerForLibraries tests 2024-10-14 15:18:58 +02:00
Shreyas Goenka f919e94bce
typo fix 2024-10-14 15:05:33 +02:00
Shreyas Goenka e43f566579
Merge remote-tracking branch 'origin' into feature/uc-volumes 2024-10-14 15:04:40 +02:00
Shreyas Goenka 227dfe95ca
fixes 2024-09-16 04:07:37 +02:00
Shreyas Goenka d3d5d4c0d6
- 2024-09-16 03:54:15 +02:00
Shreyas Goenka 274fd636e0
iter 2024-09-16 03:50:40 +02:00
Shreyas Goenka bdecd08206
- 2024-09-16 03:17:05 +02:00
Shreyas Goenka 13748f177d
cleanup todos 2024-09-16 03:14:30 +02:00
Shreyas Goenka 39cb5e8471
fix test on windows 2024-09-16 03:08:42 +02:00
Shreyas Goenka a90eb57a5b
fix unit tests 2024-09-16 03:00:22 +02:00
Shreyas Goenka aa2e16d757
cleanup and add test 2024-09-16 02:28:58 +02:00
Shreyas Goenka aeab4efda1
unit test for comparision of locatoin 2024-09-16 00:57:38 +02:00
Shreyas Goenka df3bbad70b
add integration tests for artifacts portion: 2024-09-16 00:50:12 +02:00
Shreyas Goenka f10038a20e
- 2024-09-15 23:45:01 +02:00
Shreyas Goenka de7eb94e45
- 2024-09-15 23:37:28 +02:00
Shreyas Goenka 73826acb2f
fix test and improve error 2024-09-15 23:31:04 +02:00
Shreyas Goenka fa545777bd
Merge remote-tracking branch 'origin' into feature/uc-volumes 2024-09-15 23:21:27 +02:00
Shreyas Goenka d180bab15d
add filer 2024-09-15 23:19:40 +02:00
Shreyas Goenka d47b0d6f47
Merge remote-tracking branch 'origin' into feature/uc-volumes 2024-09-10 17:24:25 +02:00
Shreyas Goenka 6f9817e194
add prompt and crud test for volumes 2024-09-10 12:52:31 +02:00
Shreyas Goenka 88d0402f44
add inteprolation for volumes fields 2024-09-09 15:41:03 +02:00
Shreyas Goenka 4b22e2d658
add conversion tests 2024-09-09 15:36:29 +02:00
Shreyas Goenka 9b66cd523b
add apply target mode prefix functionality 2024-09-09 15:11:57 +02:00
Shreyas Goenka d04b6b08ea
- 2024-09-09 14:52:25 +02:00
Shreyas Goenka 7c7abeff81
run as support 2024-09-09 14:41:42 +02:00
Shreyas Goenka ce5792c256
fix convertor and add unit test 2024-09-09 14:40:10 +02:00
Shreyas Goenka 8f4f3ae9c6
Merge remote-tracking branch 'origin' into feature/uc-volumes 2024-09-09 14:18:55 +02:00
Shreyas Goenka f772ce4259
first comment 2024-09-09 13:10:36 +02:00
59 changed files with 1436 additions and 458 deletions

View File

@ -7,8 +7,8 @@ import (
"testing"
"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/bundletest"
"github.com/databricks/cli/bundle/config"
"github.com/databricks/cli/bundle/internal/bundletest"
"github.com/databricks/cli/internal/testutil"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"

View File

@ -21,18 +21,13 @@ func (m *cleanUp) Name() string {
}
func (m *cleanUp) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics {
uploadPath, err := libraries.GetUploadBasePath(b)
if err != nil {
return diag.FromErr(err)
}
client, err := libraries.GetFilerForLibraries(b.WorkspaceClient(), uploadPath)
if err != nil {
return diag.FromErr(err)
client, uploadPath, diags := libraries.GetFilerForLibraries(ctx, b)
if diags.HasError() {
return diags
}
// We intentionally ignore the error because it is not critical to the deployment
err = client.Delete(ctx, ".", filer.DeleteRecursively)
err := client.Delete(ctx, ".", filer.DeleteRecursively)
if err != nil {
log.Errorf(ctx, "failed to delete %s: %v", uploadPath, err)
}

View File

@ -125,6 +125,36 @@ func TestApplyPresetsPrefixForUcSchema(t *testing.T) {
}
}
func TestApplyPresetsUCVolumesShouldNotBePrefixed(t *testing.T) {
b := &bundle.Bundle{
Config: config.Root{
Resources: config.Resources{
Volumes: map[string]*resources.Volume{
"volume1": {
CreateVolumeRequestContent: &catalog.CreateVolumeRequestContent{
Name: "volume1",
CatalogName: "catalog1",
SchemaName: "schema1",
},
},
},
},
Presets: config.Presets{
NamePrefix: "[prefix]",
},
},
}
ctx := context.Background()
diag := bundle.Apply(ctx, b, mutator.ApplyPresets())
if diag.HasError() {
t.Fatalf("unexpected error: %v", diag)
}
require.Equal(t, "volume1", b.Config.Resources.Volumes["volume1"].Name)
}
func TestApplyPresetsTags(t *testing.T) {
tests := []struct {
name string

View File

@ -5,10 +5,10 @@ import (
"testing"
"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/bundletest"
"github.com/databricks/cli/bundle/config"
"github.com/databricks/cli/bundle/config/mutator"
"github.com/databricks/cli/bundle/config/resources"
"github.com/databricks/cli/bundle/internal/bundletest"
"github.com/databricks/cli/libs/dyn"
"github.com/databricks/databricks-sdk-go/service/dashboards"
"github.com/stretchr/testify/assert"

View File

@ -7,9 +7,9 @@ import (
"testing"
"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/bundletest"
"github.com/databricks/cli/bundle/config"
"github.com/databricks/cli/bundle/config/resources"
"github.com/databricks/cli/bundle/internal/bundletest"
"github.com/databricks/cli/libs/dyn"
"github.com/databricks/databricks-sdk-go/service/compute"
"github.com/databricks/databricks-sdk-go/service/pipelines"

View File

@ -3,7 +3,7 @@ package mutator
import (
"context"
"reflect"
"strings"
"slices"
"testing"
"github.com/databricks/cli/bundle"
@ -128,6 +128,9 @@ func mockBundle(mode config.Mode) *bundle.Bundle {
Schemas: map[string]*resources.Schema{
"schema1": {CreateSchema: &catalog.CreateSchema{Name: "schema1"}},
},
Volumes: map[string]*resources.Volume{
"volume1": {CreateVolumeRequestContent: &catalog.CreateVolumeRequestContent{Name: "volume1"}},
},
Clusters: map[string]*resources.Cluster{
"cluster1": {ClusterSpec: &compute.ClusterSpec{ClusterName: "cluster1", SparkVersion: "13.2.x", NumWorkers: 1}},
},
@ -307,6 +310,8 @@ func TestProcessTargetModeDefault(t *testing.T) {
assert.Equal(t, "servingendpoint1", b.Config.Resources.ModelServingEndpoints["servingendpoint1"].Name)
assert.Equal(t, "registeredmodel1", b.Config.Resources.RegisteredModels["registeredmodel1"].Name)
assert.Equal(t, "qualityMonitor1", b.Config.Resources.QualityMonitors["qualityMonitor1"].TableName)
assert.Equal(t, "schema1", b.Config.Resources.Schemas["schema1"].Name)
assert.Equal(t, "volume1", b.Config.Resources.Volumes["volume1"].Name)
assert.Equal(t, "cluster1", b.Config.Resources.Clusters["cluster1"].ClusterName)
}
@ -351,6 +356,8 @@ func TestProcessTargetModeProduction(t *testing.T) {
assert.Equal(t, "servingendpoint1", b.Config.Resources.ModelServingEndpoints["servingendpoint1"].Name)
assert.Equal(t, "registeredmodel1", b.Config.Resources.RegisteredModels["registeredmodel1"].Name)
assert.Equal(t, "qualityMonitor1", b.Config.Resources.QualityMonitors["qualityMonitor1"].TableName)
assert.Equal(t, "schema1", b.Config.Resources.Schemas["schema1"].Name)
assert.Equal(t, "volume1", b.Config.Resources.Volumes["volume1"].Name)
assert.Equal(t, "cluster1", b.Config.Resources.Clusters["cluster1"].ClusterName)
}
@ -384,10 +391,17 @@ func TestAllResourcesMocked(t *testing.T) {
}
}
// Make sure that we at least rename all resources
func TestAllResourcesRenamed(t *testing.T) {
// Make sure that we at rename all non UC resources
func TestAllNonUCResourcesAreRenamed(t *testing.T) {
b := mockBundle(config.Development)
// UC resources should not have a prefix added to their name. Right now
// this list only contains the Volume resource since we have yet to remove
// prefixing support for UC schemas and registered models.
ucFields := []reflect.Type{
reflect.TypeOf(&resources.Volume{}),
}
m := bundle.Seq(ProcessTargetMode(), ApplyPresets())
diags := bundle.Apply(context.Background(), b, m)
require.NoError(t, diags.Error())
@ -400,14 +414,14 @@ func TestAllResourcesRenamed(t *testing.T) {
for _, key := range field.MapKeys() {
resource := field.MapIndex(key)
nameField := resource.Elem().FieldByName("Name")
if nameField.IsValid() && nameField.Kind() == reflect.String {
assert.True(
t,
strings.Contains(nameField.String(), "dev"),
"process_target_mode should rename '%s' in '%s'",
key,
resources.Type().Field(i).Name,
)
if !nameField.IsValid() || nameField.Kind() != reflect.String {
continue
}
if slices.Contains(ucFields, resource.Type()) {
assert.NotContains(t, nameField.String(), "dev", "process_target_mode should not rename '%s' in '%s'", key, resources.Type().Field(i).Name)
} else {
assert.Contains(t, nameField.String(), "dev", "process_target_mode should rename '%s' in '%s'", key, resources.Type().Field(i).Name)
}
}
}

View File

@ -6,9 +6,9 @@ import (
"testing"
"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/bundletest"
"github.com/databricks/cli/bundle/config"
"github.com/databricks/cli/bundle/config/mutator"
"github.com/databricks/cli/bundle/internal/bundletest"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
)

View File

@ -42,6 +42,7 @@ func allResourceTypes(t *testing.T) []string {
"quality_monitors",
"registered_models",
"schemas",
"volumes",
},
resourceTypes,
)
@ -141,6 +142,7 @@ func TestRunAsErrorForUnsupportedResources(t *testing.T) {
"registered_models",
"experiments",
"schemas",
"volumes",
}
base := config.Root{

View File

@ -6,9 +6,9 @@ import (
"testing"
"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/bundletest"
"github.com/databricks/cli/bundle/config"
"github.com/databricks/cli/bundle/config/mutator"
"github.com/databricks/cli/bundle/internal/bundletest"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

View File

@ -6,10 +6,10 @@ import (
"testing"
"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/bundletest"
"github.com/databricks/cli/bundle/config"
"github.com/databricks/cli/bundle/config/mutator"
"github.com/databricks/cli/bundle/config/resources"
"github.com/databricks/cli/bundle/internal/bundletest"
"github.com/databricks/cli/libs/dyn"
"github.com/databricks/cli/libs/vfs"
"github.com/databricks/databricks-sdk-go/service/dashboards"

View File

@ -8,11 +8,11 @@ import (
"testing"
"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/bundletest"
"github.com/databricks/cli/bundle/config"
"github.com/databricks/cli/bundle/config/mutator"
"github.com/databricks/cli/bundle/config/resources"
"github.com/databricks/cli/bundle/config/variable"
"github.com/databricks/cli/bundle/internal/bundletest"
"github.com/databricks/cli/libs/diag"
"github.com/databricks/cli/libs/dyn"
"github.com/databricks/cli/libs/vfs"

View File

@ -20,6 +20,7 @@ type Resources struct {
RegisteredModels map[string]*resources.RegisteredModel `json:"registered_models,omitempty"`
QualityMonitors map[string]*resources.QualityMonitor `json:"quality_monitors,omitempty"`
Schemas map[string]*resources.Schema `json:"schemas,omitempty"`
Volumes map[string]*resources.Volume `json:"volumes,omitempty"`
Clusters map[string]*resources.Cluster `json:"clusters,omitempty"`
Dashboards map[string]*resources.Dashboard `json:"dashboards,omitempty"`
}
@ -79,6 +80,7 @@ func (r *Resources) AllResources() []ResourceGroup {
collectResourceMap(descriptions["schemas"], r.Schemas),
collectResourceMap(descriptions["clusters"], r.Clusters),
collectResourceMap(descriptions["dashboards"], r.Dashboards),
collectResourceMap(descriptions["volumes"], r.Volumes),
}
}
@ -183,5 +185,11 @@ func SupportedResources() map[string]ResourceDescription {
SingularTitle: "Dashboard",
PluralTitle: "Dashboards",
},
"volumes": {
SingularName: "volume",
PluralName: "volumes",
SingularTitle: "Volume",
PluralTitle: "Volumes",
},
}
}

View File

@ -0,0 +1,58 @@
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"
)
type Volume struct {
// List of grants to apply on this volume.
Grants []Grant `json:"grants,omitempty"`
// Full name of the volume (catalog_name.schema_name.volume_name). This value is read from
// the terraform state after deployment succeeds.
ID string `json:"id,omitempty" bundle:"readonly"`
*catalog.CreateVolumeRequestContent
ModifiedStatus ModifiedStatus `json:"modified_status,omitempty" bundle:"internal"`
URL string `json:"url,omitempty" bundle:"internal"`
}
func (v *Volume) UnmarshalJSON(b []byte) error {
return marshal.Unmarshal(b, v)
}
func (v Volume) MarshalJSON() ([]byte, error) {
return marshal.Marshal(v)
}
func (v *Volume) Exists(ctx context.Context, w *databricks.WorkspaceClient, id string) (bool, error) {
return false, fmt.Errorf("volume.Exists() is not supported")
}
func (v *Volume) TerraformResourceName() string {
return "databricks_volume"
}
func (v *Volume) InitializeURL(baseURL url.URL) {
if v.ID == "" {
return
}
baseURL.Path = fmt.Sprintf("explore/data/volumes/%s", strings.ReplaceAll(v.ID, ".", "/"))
v.URL = baseURL.String()
}
func (v *Volume) GetURL() string {
return v.URL
}
func (v *Volume) GetName() string {
return v.Name
}

View File

@ -5,9 +5,9 @@ import (
"testing"
"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/bundletest"
"github.com/databricks/cli/bundle/config"
"github.com/databricks/cli/bundle/config/resources"
"github.com/databricks/cli/bundle/internal/bundletest"
"github.com/databricks/cli/bundle/metadata"
"github.com/databricks/cli/libs/dyn"
"github.com/databricks/databricks-sdk-go/service/jobs"

View File

@ -166,6 +166,16 @@ func TerraformToBundle(state *resourcesState, config *config.Root) error {
}
cur.ID = instance.Attributes.ID
config.Resources.Schemas[resource.Name] = cur
case "databricks_volume":
if config.Resources.Volumes == nil {
config.Resources.Volumes = make(map[string]*resources.Volume)
}
cur := config.Resources.Volumes[resource.Name]
if cur == nil {
cur = &resources.Volume{ModifiedStatus: resources.ModifiedStatusDeleted}
}
cur.ID = instance.Attributes.ID
config.Resources.Volumes[resource.Name] = cur
case "databricks_cluster":
if config.Resources.Clusters == nil {
config.Resources.Clusters = make(map[string]*resources.Cluster)
@ -235,6 +245,11 @@ func TerraformToBundle(state *resourcesState, config *config.Root) error {
src.ModifiedStatus = resources.ModifiedStatusCreated
}
}
for _, src := range config.Resources.Volumes {
if src.ModifiedStatus == "" && src.ID == "" {
src.ModifiedStatus = resources.ModifiedStatusCreated
}
}
for _, src := range config.Resources.Clusters {
if src.ModifiedStatus == "" && src.ID == "" {
src.ModifiedStatus = resources.ModifiedStatusCreated

View File

@ -670,6 +670,14 @@ func TestTerraformToBundleEmptyLocalResources(t *testing.T) {
{Attributes: stateInstanceAttributes{ID: "1"}},
},
},
{
Type: "databricks_volume",
Mode: "managed",
Name: "test_volume",
Instances: []stateResourceInstance{
{Attributes: stateInstanceAttributes{ID: "1"}},
},
},
{
Type: "databricks_cluster",
Mode: "managed",
@ -715,6 +723,9 @@ func TestTerraformToBundleEmptyLocalResources(t *testing.T) {
assert.Equal(t, "1", config.Resources.Schemas["test_schema"].ID)
assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.Schemas["test_schema"].ModifiedStatus)
assert.Equal(t, "1", config.Resources.Volumes["test_volume"].ID)
assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.Volumes["test_volume"].ModifiedStatus)
assert.Equal(t, "1", config.Resources.Clusters["test_cluster"].ID)
assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.Clusters["test_cluster"].ModifiedStatus)
@ -783,6 +794,13 @@ func TestTerraformToBundleEmptyRemoteResources(t *testing.T) {
},
},
},
Volumes: map[string]*resources.Volume{
"test_volume": {
CreateVolumeRequestContent: &catalog.CreateVolumeRequestContent{
Name: "test_volume",
},
},
},
Clusters: map[string]*resources.Cluster{
"test_cluster": {
ClusterSpec: &compute.ClusterSpec{
@ -829,6 +847,9 @@ func TestTerraformToBundleEmptyRemoteResources(t *testing.T) {
assert.Equal(t, "", config.Resources.Schemas["test_schema"].ID)
assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.Schemas["test_schema"].ModifiedStatus)
assert.Equal(t, "", config.Resources.Volumes["test_volume"].ID)
assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.Volumes["test_volume"].ModifiedStatus)
assert.Equal(t, "", config.Resources.Clusters["test_cluster"].ID)
assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.Clusters["test_cluster"].ModifiedStatus)
@ -937,6 +958,18 @@ func TestTerraformToBundleModifiedResources(t *testing.T) {
},
},
},
Volumes: map[string]*resources.Volume{
"test_volume": {
CreateVolumeRequestContent: &catalog.CreateVolumeRequestContent{
Name: "test_volume",
},
},
"test_volume_new": {
CreateVolumeRequestContent: &catalog.CreateVolumeRequestContent{
Name: "test_volume_new",
},
},
},
Clusters: map[string]*resources.Cluster{
"test_cluster": {
ClusterSpec: &compute.ClusterSpec{
@ -1093,6 +1126,14 @@ func TestTerraformToBundleModifiedResources(t *testing.T) {
{Attributes: stateInstanceAttributes{ID: "2"}},
},
},
{
Type: "databricks_volume",
Mode: "managed",
Name: "test_volume",
Instances: []stateResourceInstance{
{Attributes: stateInstanceAttributes{ID: "1"}},
},
},
{
Type: "databricks_cluster",
Mode: "managed",
@ -1101,6 +1142,14 @@ func TestTerraformToBundleModifiedResources(t *testing.T) {
{Attributes: stateInstanceAttributes{ID: "1"}},
},
},
{
Type: "databricks_volume",
Mode: "managed",
Name: "test_volume_old",
Instances: []stateResourceInstance{
{Attributes: stateInstanceAttributes{ID: "2"}},
},
},
{
Type: "databricks_cluster",
Mode: "managed",
@ -1186,6 +1235,13 @@ func TestTerraformToBundleModifiedResources(t *testing.T) {
assert.Equal(t, "", config.Resources.Schemas["test_schema_new"].ID)
assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.Schemas["test_schema_new"].ModifiedStatus)
assert.Equal(t, "1", config.Resources.Volumes["test_volume"].ID)
assert.Equal(t, "", config.Resources.Volumes["test_volume"].ModifiedStatus)
assert.Equal(t, "2", config.Resources.Volumes["test_volume_old"].ID)
assert.Equal(t, resources.ModifiedStatusDeleted, config.Resources.Volumes["test_volume_old"].ModifiedStatus)
assert.Equal(t, "", config.Resources.Volumes["test_volume_new"].ID)
assert.Equal(t, resources.ModifiedStatusCreated, config.Resources.Volumes["test_volume_new"].ModifiedStatus)
assert.Equal(t, "1", config.Resources.Clusters["test_cluster"].ID)
assert.Equal(t, "", config.Resources.Clusters["test_cluster"].ModifiedStatus)
assert.Equal(t, "2", config.Resources.Clusters["test_cluster_old"].ID)

View File

@ -58,6 +58,8 @@ func (m *interpolateMutator) Apply(ctx context.Context, b *bundle.Bundle) diag.D
path = dyn.NewPath(dyn.Key("databricks_quality_monitor")).Append(path[2:]...)
case dyn.Key("schemas"):
path = dyn.NewPath(dyn.Key("databricks_schema")).Append(path[2:]...)
case dyn.Key("volumes"):
path = dyn.NewPath(dyn.Key("databricks_volume")).Append(path[2:]...)
case dyn.Key("clusters"):
path = dyn.NewPath(dyn.Key("databricks_cluster")).Append(path[2:]...)
case dyn.Key("dashboards"):

View File

@ -31,6 +31,7 @@ func TestInterpolate(t *testing.T) {
"other_model_serving": "${resources.model_serving_endpoints.other_model_serving.id}",
"other_registered_model": "${resources.registered_models.other_registered_model.id}",
"other_schema": "${resources.schemas.other_schema.id}",
"other_volume": "${resources.volumes.other_volume.id}",
"other_cluster": "${resources.clusters.other_cluster.id}",
"other_dashboard": "${resources.dashboards.other_dashboard.id}",
},
@ -69,6 +70,7 @@ func TestInterpolate(t *testing.T) {
assert.Equal(t, "${databricks_model_serving.other_model_serving.id}", j.Tags["other_model_serving"])
assert.Equal(t, "${databricks_registered_model.other_registered_model.id}", j.Tags["other_registered_model"])
assert.Equal(t, "${databricks_schema.other_schema.id}", j.Tags["other_schema"])
assert.Equal(t, "${databricks_volume.other_volume.id}", j.Tags["other_volume"])
assert.Equal(t, "${databricks_cluster.other_cluster.id}", j.Tags["other_cluster"])
assert.Equal(t, "${databricks_dashboard.other_dashboard.id}", j.Tags["other_dashboard"])

View File

@ -0,0 +1,45 @@
package tfdyn
import (
"context"
"fmt"
"github.com/databricks/cli/bundle/internal/tf/schema"
"github.com/databricks/cli/libs/dyn"
"github.com/databricks/cli/libs/dyn/convert"
"github.com/databricks/cli/libs/log"
)
func convertVolumeResource(ctx context.Context, vin dyn.Value) (dyn.Value, error) {
// Normalize the output value to the target schema.
vout, diags := convert.Normalize(schema.ResourceVolume{}, vin)
for _, diag := range diags {
log.Debugf(ctx, "volume normalization diagnostic: %s", diag.Summary)
}
return vout, nil
}
type volumeConverter struct{}
func (volumeConverter) Convert(ctx context.Context, key string, vin dyn.Value, out *schema.Resources) error {
vout, err := convertVolumeResource(ctx, vin)
if err != nil {
return err
}
// Add the converted resource to the output.
out.Volume[key] = vout.AsAny()
// Configure grants for this resource.
if grants := convertGrantsResource(ctx, vin); grants != nil {
grants.Volume = fmt.Sprintf("${databricks_volume.%s.id}", key)
out.Grants["volume_"+key] = grants
}
return nil
}
func init() {
registerConverter("volumes", volumeConverter{})
}

View File

@ -0,0 +1,70 @@
package tfdyn
import (
"context"
"testing"
"github.com/databricks/cli/bundle/config/resources"
"github.com/databricks/cli/bundle/internal/tf/schema"
"github.com/databricks/cli/libs/dyn"
"github.com/databricks/cli/libs/dyn/convert"
"github.com/databricks/databricks-sdk-go/service/catalog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConvertVolume(t *testing.T) {
var src = resources.Volume{
CreateVolumeRequestContent: &catalog.CreateVolumeRequestContent{
CatalogName: "catalog",
Comment: "comment",
Name: "name",
SchemaName: "schema",
StorageLocation: "s3://bucket/path",
VolumeType: "EXTERNAL",
},
Grants: []resources.Grant{
{
Privileges: []string{"READ_VOLUME"},
Principal: "jack@gmail.com",
},
{
Privileges: []string{"WRITE_VOLUME"},
Principal: "jane@gmail.com",
},
},
}
vin, err := convert.FromTyped(src, dyn.NilValue)
require.NoError(t, err)
ctx := context.Background()
out := schema.NewResources()
err = volumeConverter{}.Convert(ctx, "my_volume", vin, out)
require.NoError(t, err)
// Assert equality on the volume
require.Equal(t, map[string]any{
"catalog_name": "catalog",
"comment": "comment",
"name": "name",
"schema_name": "schema",
"storage_location": "s3://bucket/path",
"volume_type": "EXTERNAL",
}, out.Volume["my_volume"])
// Assert equality on the grants
assert.Equal(t, &schema.ResourceGrants{
Volume: "${databricks_volume.my_volume.id}",
Grant: []schema.ResourceGrantsGrant{
{
Privileges: []string{"READ_VOLUME"},
Principal: "jack@gmail.com",
},
{
Privileges: []string{"WRITE_VOLUME"},
Principal: "jane@gmail.com",
},
},
}, out.Grants["volume_my_volume"])
}

View File

@ -6,9 +6,9 @@ import (
"testing"
"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/bundletest"
"github.com/databricks/cli/bundle/config"
"github.com/databricks/cli/bundle/config/resources"
"github.com/databricks/cli/bundle/internal/bundletest"
"github.com/databricks/cli/internal/testutil"
"github.com/databricks/cli/libs/dyn"
"github.com/databricks/databricks-sdk-go/service/compute"

28
bundle/libraries/filer.go Normal file
View File

@ -0,0 +1,28 @@
package libraries
import (
"context"
"github.com/databricks/cli/bundle"
"github.com/databricks/cli/libs/diag"
"github.com/databricks/cli/libs/filer"
)
// This function returns the right filer to use, to upload artifacts to the configured location.
// Supported locations:
// 1. WSFS
// 2. UC volumes
func GetFilerForLibraries(ctx context.Context, b *bundle.Bundle) (filer.Filer, string, diag.Diagnostics) {
artifactPath := b.Config.Workspace.ArtifactPath
if artifactPath == "" {
return nil, "", diag.Errorf("remote artifact path not configured")
}
switch {
case IsVolumesPath(artifactPath):
return filerForVolume(ctx, b)
default:
return filerForWorkspace(b)
}
}

View File

@ -0,0 +1,63 @@
package libraries
import (
"context"
"testing"
"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/config"
"github.com/databricks/cli/libs/filer"
sdkconfig "github.com/databricks/databricks-sdk-go/config"
"github.com/databricks/databricks-sdk-go/experimental/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestGetFilerForLibrariesValidWsfs(t *testing.T) {
b := &bundle.Bundle{
Config: config.Root{
Workspace: config.Workspace{
ArtifactPath: "/foo/bar/artifacts",
},
},
}
client, uploadPath, diags := GetFilerForLibraries(context.Background(), b)
require.NoError(t, diags.Error())
assert.Equal(t, "/foo/bar/artifacts/.internal", uploadPath)
assert.IsType(t, &filer.WorkspaceFilesClient{}, client)
}
func TestGetFilerForLibrariesValidUcVolume(t *testing.T) {
b := &bundle.Bundle{
Config: config.Root{
Workspace: config.Workspace{
ArtifactPath: "/Volumes/main/my_schema/my_volume",
},
},
}
m := mocks.NewMockWorkspaceClient(t)
m.WorkspaceClient.Config = &sdkconfig.Config{}
m.GetMockFilesAPI().EXPECT().GetDirectoryMetadataByDirectoryPath(mock.Anything, "/Volumes/main/my_schema/my_volume").Return(nil)
b.SetWorkpaceClient(m.WorkspaceClient)
client, uploadPath, diags := GetFilerForLibraries(context.Background(), b)
require.NoError(t, diags.Error())
assert.Equal(t, "/Volumes/main/my_schema/my_volume/.internal", uploadPath)
assert.IsType(t, &filer.FilesClient{}, client)
}
func TestGetFilerForLibrariesRemotePathNotSet(t *testing.T) {
b := &bundle.Bundle{
Config: config.Root{
Workspace: config.Workspace{},
},
}
_, _, diags := GetFilerForLibraries(context.Background(), b)
require.EqualError(t, diags.Error(), "remote artifact path not configured")
}

View File

@ -0,0 +1,101 @@
package libraries
import (
"context"
"fmt"
"path"
"strings"
"github.com/databricks/cli/bundle"
"github.com/databricks/cli/libs/diag"
"github.com/databricks/cli/libs/dyn"
"github.com/databricks/cli/libs/dyn/dynvar"
"github.com/databricks/cli/libs/filer"
)
// This function returns a filer for ".internal" folder inside the directory configured
// at `workspace.artifact_path`.
// This function also checks if the UC volume exists in the workspace and then:
// 1. If the UC volume exists in the workspace:
// Returns a filer for the UC volume.
// 2. If the UC volume does not exist in the workspace but is (with high confidence) defined in
// the bundle configuration:
// Returns an error and a warning that instructs the user to deploy the
// UC volume before using it in the artifact path.
// 3. If the UC volume does not exist in the workspace and is not defined in the bundle configuration:
// Returns an error.
func filerForVolume(ctx context.Context, b *bundle.Bundle) (filer.Filer, string, diag.Diagnostics) {
artifactPath := b.Config.Workspace.ArtifactPath
w := b.WorkspaceClient()
if !strings.HasPrefix(artifactPath, "/Volumes/") {
return nil, "", diag.Errorf("expected artifact_path to start with /Volumes/, got %s", artifactPath)
}
parts := strings.Split(artifactPath, "/")
volumeFormatErr := fmt.Errorf("expected UC volume path to be in the format /Volumes/<catalog>/<schema>/<volume>/..., got %s", artifactPath)
// Incorrect format.
if len(parts) < 5 {
return nil, "", diag.FromErr(volumeFormatErr)
}
catalogName := parts[2]
schemaName := parts[3]
volumeName := parts[4]
// Incorrect format.
if catalogName == "" || schemaName == "" || volumeName == "" {
return nil, "", diag.FromErr(volumeFormatErr)
}
// Check if the UC volume exists in the workspace.
volumePath := fmt.Sprintf("/Volumes/%s/%s/%s", catalogName, schemaName, volumeName)
err := w.Files.GetDirectoryMetadataByDirectoryPath(ctx, volumePath)
// If the volume exists already, directly return the filer for the path to
// upload the artifacts to.
if err == nil {
uploadPath := path.Join(artifactPath, ".internal")
f, err := filer.NewFilesClient(w, uploadPath)
return f, uploadPath, diag.FromErr(err)
}
diags := diag.Errorf("failed to fetch metadata for the UC volume %s that is configured in the artifact_path: %s", volumePath, err)
path, locations, ok := findVolumeInBundle(b, catalogName, schemaName, volumeName)
if !ok {
return nil, "", diags
}
warning := diag.Diagnostic{
Severity: diag.Warning,
Summary: `You might be using a UC volume in your artifact_path that is managed by this bundle but which has not been deployed yet. Please deploy the UC volume in a separate bundle deploy before using it in the artifact_path.`,
Locations: locations,
Paths: []dyn.Path{path},
}
return nil, "", diags.Append(warning)
}
func findVolumeInBundle(b *bundle.Bundle, catalogName, schemaName, volumeName string) (dyn.Path, []dyn.Location, bool) {
volumes := b.Config.Resources.Volumes
for k, v := range volumes {
if v.CatalogName != catalogName || v.Name != volumeName {
continue
}
// UC schemas can be defined in the bundle itself, and thus might be interpolated
// at runtime via the ${resources.schemas.<name>} syntax. Thus we match the volume
// definition if the schema name is the same as the one in the bundle, or if the
// schema name is interpolated.
// We only have to check for ${resources.schemas...} references because any
// other valid reference (like ${var.foo}) would have been interpolated by this point.
p, ok := dynvar.PureReferenceToPath(v.SchemaName)
isSchemaDefinedInBundle := ok && p.HasPrefix(dyn.Path{dyn.Key("resources"), dyn.Key("schemas")})
if v.SchemaName != schemaName && !isSchemaDefinedInBundle {
continue
}
pathString := fmt.Sprintf("resources.volumes.%s", k)
return dyn.MustPathFromString(pathString), b.Config.GetLocations(pathString), true
}
return nil, nil, false
}

View File

@ -0,0 +1,223 @@
package libraries
import (
"context"
"fmt"
"path"
"testing"
"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/bundletest"
"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"
"github.com/databricks/cli/libs/filer"
sdkconfig "github.com/databricks/databricks-sdk-go/config"
"github.com/databricks/databricks-sdk-go/experimental/mocks"
"github.com/databricks/databricks-sdk-go/service/catalog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestFindVolumeInBundle(t *testing.T) {
b := &bundle.Bundle{
Config: config.Root{
Resources: config.Resources{
Volumes: map[string]*resources.Volume{
"foo": {
CreateVolumeRequestContent: &catalog.CreateVolumeRequestContent{
CatalogName: "main",
Name: "my_volume",
SchemaName: "my_schema",
},
},
},
},
},
}
bundletest.SetLocation(b, "resources.volumes.foo", []dyn.Location{
{
File: "volume.yml",
Line: 1,
Column: 2,
},
})
// volume is in DAB.
path, locations, ok := findVolumeInBundle(b, "main", "my_schema", "my_volume")
assert.True(t, ok)
assert.Equal(t, []dyn.Location{{
File: "volume.yml",
Line: 1,
Column: 2,
}}, locations)
assert.Equal(t, dyn.MustPathFromString("resources.volumes.foo"), path)
// wrong volume name
_, _, ok = findVolumeInBundle(b, "main", "my_schema", "doesnotexist")
assert.False(t, ok)
// wrong schema name
_, _, ok = findVolumeInBundle(b, "main", "doesnotexist", "my_volume")
assert.False(t, ok)
// wrong catalog name
_, _, ok = findVolumeInBundle(b, "doesnotexist", "my_schema", "my_volume")
assert.False(t, ok)
// schema name is interpolated but does not have the right prefix. In this case
// we should not match the volume.
b.Config.Resources.Volumes["foo"].SchemaName = "${foo.bar.baz}"
_, _, ok = findVolumeInBundle(b, "main", "my_schema", "my_volume")
assert.False(t, ok)
// schema name is interpolated.
b.Config.Resources.Volumes["foo"].SchemaName = "${resources.schemas.my_schema.name}"
path, locations, ok = findVolumeInBundle(b, "main", "valuedoesnotmatter", "my_volume")
assert.True(t, ok)
assert.Equal(t, []dyn.Location{{
File: "volume.yml",
Line: 1,
Column: 2,
}}, locations)
assert.Equal(t, dyn.MustPathFromString("resources.volumes.foo"), path)
}
func TestFilerForVolumeNotInBundle(t *testing.T) {
b := &bundle.Bundle{
Config: config.Root{
Workspace: config.Workspace{
ArtifactPath: "/Volumes/main/my_schema/doesnotexist",
},
},
}
m := mocks.NewMockWorkspaceClient(t)
m.WorkspaceClient.Config = &sdkconfig.Config{}
m.GetMockFilesAPI().EXPECT().GetDirectoryMetadataByDirectoryPath(mock.Anything, "/Volumes/main/my_schema/doesnotexist").Return(fmt.Errorf("error from API"))
b.SetWorkpaceClient(m.WorkspaceClient)
_, _, diags := filerForVolume(context.Background(), b)
assert.EqualError(t, diags.Error(), "failed to fetch metadata for the UC volume /Volumes/main/my_schema/doesnotexist that is configured in the artifact_path: error from API")
assert.Len(t, diags, 1)
}
func TestFilerForVolumeInBundle(t *testing.T) {
b := &bundle.Bundle{
Config: config.Root{
Workspace: config.Workspace{
ArtifactPath: "/Volumes/main/my_schema/my_volume",
},
Resources: config.Resources{
Volumes: map[string]*resources.Volume{
"foo": {
CreateVolumeRequestContent: &catalog.CreateVolumeRequestContent{
CatalogName: "main",
Name: "my_volume",
VolumeType: "MANAGED",
SchemaName: "my_schema",
},
},
},
},
},
}
bundletest.SetLocation(b, "resources.volumes.foo", []dyn.Location{
{
File: "volume.yml",
Line: 1,
Column: 2,
},
})
m := mocks.NewMockWorkspaceClient(t)
m.WorkspaceClient.Config = &sdkconfig.Config{}
m.GetMockFilesAPI().EXPECT().GetDirectoryMetadataByDirectoryPath(mock.Anything, "/Volumes/main/my_schema/my_volume").Return(fmt.Errorf("error from API"))
b.SetWorkpaceClient(m.WorkspaceClient)
_, _, diags := GetFilerForLibraries(context.Background(), b)
assert.EqualError(t, diags.Error(), "failed to fetch metadata for the UC volume /Volumes/main/my_schema/my_volume that is configured in the artifact_path: error from API")
assert.Contains(t, diags, diag.Diagnostic{
Severity: diag.Warning,
Summary: "You might be using a UC volume in your artifact_path that is managed by this bundle but which has not been deployed yet. Please deploy the UC volume in a separate bundle deploy before using it in the artifact_path.",
Locations: []dyn.Location{{
File: "volume.yml",
Line: 1,
Column: 2,
}},
Paths: []dyn.Path{dyn.MustPathFromString("resources.volumes.foo")},
})
}
func TestFilerForVolumeWithInvalidVolumePaths(t *testing.T) {
invalidPaths := []string{
"/Volumes/",
"/Volumes/main",
"/Volumes/main/",
"/Volumes/main//",
"/Volumes/main//my_schema",
"/Volumes/main/my_schema",
"/Volumes/main/my_schema/",
"/Volumes/main/my_schema//",
"/Volumes//my_schema/my_volume",
}
for _, p := range invalidPaths {
b := &bundle.Bundle{
Config: config.Root{
Workspace: config.Workspace{
ArtifactPath: p,
},
},
}
_, _, diags := GetFilerForLibraries(context.Background(), b)
require.EqualError(t, diags.Error(), fmt.Sprintf("expected UC volume path to be in the format /Volumes/<catalog>/<schema>/<volume>/..., got %s", p))
}
}
func TestFilerForVolumeWithInvalidPrefix(t *testing.T) {
b := &bundle.Bundle{
Config: config.Root{
Workspace: config.Workspace{
ArtifactPath: "/Volume/main/my_schema/my_volume",
},
},
}
_, _, diags := filerForVolume(context.Background(), b)
require.EqualError(t, diags.Error(), "expected artifact_path to start with /Volumes/, got /Volume/main/my_schema/my_volume")
}
func TestFilerForVolumeWithValidlVolumePaths(t *testing.T) {
validPaths := []string{
"/Volumes/main/my_schema/my_volume",
"/Volumes/main/my_schema/my_volume/",
"/Volumes/main/my_schema/my_volume/a/b/c",
"/Volumes/main/my_schema/my_volume/a/a/a",
}
for _, p := range validPaths {
b := &bundle.Bundle{
Config: config.Root{
Workspace: config.Workspace{
ArtifactPath: p,
},
},
}
m := mocks.NewMockWorkspaceClient(t)
m.WorkspaceClient.Config = &sdkconfig.Config{}
m.GetMockFilesAPI().EXPECT().GetDirectoryMetadataByDirectoryPath(mock.Anything, "/Volumes/main/my_schema/my_volume").Return(nil)
b.SetWorkpaceClient(m.WorkspaceClient)
client, uploadPath, diags := filerForVolume(context.Background(), b)
require.NoError(t, diags.Error())
assert.Equal(t, path.Join(p, ".internal"), uploadPath)
assert.IsType(t, &filer.FilesClient{}, client)
}
}

View File

@ -0,0 +1,15 @@
package libraries
import (
"path"
"github.com/databricks/cli/bundle"
"github.com/databricks/cli/libs/diag"
"github.com/databricks/cli/libs/filer"
)
func filerForWorkspace(b *bundle.Bundle) (filer.Filer, string, diag.Diagnostics) {
uploadPath := path.Join(b.Config.Workspace.ArtifactPath, ".internal")
f, err := filer.NewWorkspaceFilesClient(b.WorkspaceClient(), uploadPath)
return f, uploadPath, diag.FromErr(err)
}

View File

@ -16,8 +16,6 @@ import (
"github.com/databricks/cli/libs/filer"
"github.com/databricks/cli/libs/log"
"github.com/databricks/databricks-sdk-go"
"golang.org/x/sync/errgroup"
)
@ -130,24 +128,17 @@ func collectLocalLibraries(b *bundle.Bundle) (map[string][]configLocation, error
}
func (u *upload) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics {
uploadPath, err := GetUploadBasePath(b)
if err != nil {
return diag.FromErr(err)
client, uploadPath, diags := GetFilerForLibraries(ctx, b)
if diags.HasError() {
return diags
}
// If the client is not initialized, initialize it
// We use client field in mutator to allow for mocking client in testing
// Only set the filer client if it's not already set. We use the client field
// in the mutator to mock the filer client in testing
if u.client == nil {
filer, err := GetFilerForLibraries(b.WorkspaceClient(), uploadPath)
if err != nil {
return diag.FromErr(err)
}
u.client = filer
u.client = client
}
var diags diag.Diagnostics
libs, err := collectLocalLibraries(b)
if err != nil {
return diag.FromErr(err)
@ -197,17 +188,6 @@ func (u *upload) Name() string {
return "libraries.Upload"
}
func GetFilerForLibraries(w *databricks.WorkspaceClient, uploadPath string) (filer.Filer, error) {
if isVolumesPath(uploadPath) {
return filer.NewFilesClient(w, uploadPath)
}
return filer.NewWorkspaceFilesClient(w, uploadPath)
}
func isVolumesPath(path string) bool {
return strings.HasPrefix(path, "/Volumes/")
}
// Function to upload file (a library, artifact and etc) to Workspace or UC volume
func UploadFile(ctx context.Context, file string, client filer.Filer) error {
filename := filepath.Base(file)
@ -227,12 +207,3 @@ func UploadFile(ctx context.Context, file string, client filer.Filer) error {
log.Infof(ctx, "Upload succeeded")
return nil
}
func GetUploadBasePath(b *bundle.Bundle) (string, error) {
artifactPath := b.Config.Workspace.ArtifactPath
if artifactPath == "" {
return "", fmt.Errorf("remote artifact path not configured")
}
return path.Join(artifactPath, ".internal"), nil
}

View File

@ -11,6 +11,8 @@ import (
mockfiler "github.com/databricks/cli/internal/mocks/libs/filer"
"github.com/databricks/cli/internal/testutil"
"github.com/databricks/cli/libs/filer"
sdkconfig "github.com/databricks/databricks-sdk-go/config"
"github.com/databricks/databricks-sdk-go/experimental/mocks"
"github.com/databricks/databricks-sdk-go/service/compute"
"github.com/databricks/databricks-sdk-go/service/jobs"
"github.com/stretchr/testify/mock"
@ -181,6 +183,11 @@ func TestArtifactUploadForVolumes(t *testing.T) {
filer.CreateParentDirectories,
).Return(nil)
m := mocks.NewMockWorkspaceClient(t)
m.WorkspaceClient.Config = &sdkconfig.Config{}
m.GetMockFilesAPI().EXPECT().GetDirectoryMetadataByDirectoryPath(mock.Anything, "/Volumes/foo/bar/artifacts").Return(nil)
b.SetWorkpaceClient(m.WorkspaceClient)
diags := bundle.Apply(context.Background(), b, bundle.Seq(ExpandGlobReferences(), UploadWithClient(mockFiler)))
require.NoError(t, diags.Error())

View File

@ -23,10 +23,10 @@ import (
tfjson "github.com/hashicorp/terraform-json"
)
func parseTerraformActions(changes []*tfjson.ResourceChange, toInclude func(typ string, actions tfjson.Actions) bool) []terraformlib.Action {
func filterDeleteOrRecreateActions(changes []*tfjson.ResourceChange, resourceType string) []terraformlib.Action {
res := make([]terraformlib.Action, 0)
for _, rc := range changes {
if !toInclude(rc.Type, rc.Change.Actions) {
if rc.Type != resourceType {
continue
}
@ -37,7 +37,7 @@ func parseTerraformActions(changes []*tfjson.ResourceChange, toInclude func(typ
case rc.Change.Actions.Replace():
actionType = terraformlib.ActionTypeRecreate
default:
// No use case for other action types yet.
// Filter other action types..
continue
}
@ -63,30 +63,12 @@ func approvalForDeploy(ctx context.Context, b *bundle.Bundle) (bool, error) {
return false, err
}
schemaActions := parseTerraformActions(plan.ResourceChanges, func(typ string, actions tfjson.Actions) bool {
// Filter in only UC schema resources.
if typ != "databricks_schema" {
return false
}
// We only display prompts for destructive actions like deleting or
// recreating a schema.
return actions.Delete() || actions.Replace()
})
dltActions := parseTerraformActions(plan.ResourceChanges, func(typ string, actions tfjson.Actions) bool {
// Filter in only DLT pipeline resources.
if typ != "databricks_pipeline" {
return false
}
// Recreating DLT pipeline leads to metadata loss and for a transient period
// the underling tables will be unavailable.
return actions.Replace() || actions.Delete()
})
schemaActions := filterDeleteOrRecreateActions(plan.ResourceChanges, "databricks_schema")
dltActions := filterDeleteOrRecreateActions(plan.ResourceChanges, "databricks_pipeline")
volumeActions := filterDeleteOrRecreateActions(plan.ResourceChanges, "databricks_volume")
// We don't need to display any prompts in this case.
if len(dltActions) == 0 && len(schemaActions) == 0 {
if len(schemaActions) == 0 && len(dltActions) == 0 && len(volumeActions) == 0 {
return true, nil
}
@ -111,6 +93,19 @@ properties such as the 'catalog' or 'storage' are changed:`
}
}
// One or more volumes is being recreated.
if len(volumeActions) != 0 {
msg := `
This action will result in the deletion or recreation of the following Volumes.
For managed volumes, the files stored in the volume are also deleted from your
cloud tenant within 30 days. For external volumes, the metadata about the volume
is removed from the catalog, but the underlying files are not deleted:`
cmdio.LogString(ctx, msg)
for _, action := range volumeActions {
cmdio.Log(ctx, action)
}
}
if b.AutoApprove {
return true, nil
}

View File

@ -40,17 +40,7 @@ func TestParseTerraformActions(t *testing.T) {
},
}
res := parseTerraformActions(changes, func(typ string, actions tfjson.Actions) bool {
if typ != "databricks_pipeline" {
return false
}
if actions.Delete() || actions.Replace() {
return true
}
return false
})
res := filterDeleteOrRecreateActions(changes, "databricks_pipeline")
assert.Equal(t, []terraformlib.Action{
{

View File

@ -3,6 +3,7 @@ package bundle
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"slices"
@ -109,6 +110,24 @@ func getUrlForNativeTemplate(name string) string {
return ""
}
func getFsForNativeTemplate(name string) (fs.FS, error) {
builtin, err := template.Builtin()
if err != nil {
return nil, err
}
// If this is a built-in template, the return value will be non-nil.
var templateFS fs.FS
for _, entry := range builtin {
if entry.Name == name {
templateFS = entry.FS
break
}
}
return templateFS, nil
}
func isRepoUrl(url string) bool {
result := false
for _, prefix := range gitUrlPrefixes {
@ -198,9 +217,20 @@ See https://docs.databricks.com/en/dev-tools/bundles/templates.html for more inf
if templateDir != "" {
return errors.New("--template-dir can only be used with a Git repository URL")
}
templateFS, err := getFsForNativeTemplate(templatePath)
if err != nil {
return err
}
// If this is not a built-in template, then it must be a local file system path.
if templateFS == nil {
templateFS = os.DirFS(templatePath)
}
// skip downloading the repo because input arg is not a URL. We assume
// it's a path on the local file system in that case
return template.Materialize(ctx, configFile, templatePath, outputDir)
return template.Materialize(ctx, configFile, templateFS, outputDir)
}
// Create a temporary directory with the name of the repository. The '*'
@ -224,7 +254,8 @@ See https://docs.databricks.com/en/dev-tools/bundles/templates.html for more inf
// Clean up downloaded repository once the template is materialized.
defer os.RemoveAll(repoDir)
return template.Materialize(ctx, configFile, filepath.Join(repoDir, templateDir), outputDir)
templateFS := os.DirFS(filepath.Join(repoDir, templateDir))
return template.Materialize(ctx, configFile, templateFS, outputDir)
}
return cmd
}

View File

@ -1,6 +1,7 @@
package bundle
import (
"fmt"
"os"
"path"
"path/filepath"
@ -8,13 +9,18 @@ import (
"testing"
"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/bundletest"
"github.com/databricks/cli/bundle/config"
"github.com/databricks/cli/bundle/config/resources"
"github.com/databricks/cli/bundle/libraries"
"github.com/databricks/cli/internal"
"github.com/databricks/cli/internal/acc"
"github.com/databricks/cli/libs/diag"
"github.com/databricks/cli/libs/dyn"
"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/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -225,3 +231,112 @@ func TestAccUploadArtifactFileToCorrectRemotePathForVolumes(t *testing.T) {
b.Config.Resources.Jobs["test"].JobSettings.Tasks[0].Libraries[0].Whl,
)
}
func TestAccUploadArtifactFileToInvalidVolume(t *testing.T) {
ctx, wt := acc.UcWorkspaceTest(t)
w := wt.W
schemaName := internal.RandomName("schema-")
_, err := w.Schemas.Create(ctx, catalog.CreateSchema{
CatalogName: "main",
Comment: "test schema",
Name: schemaName,
})
require.NoError(t, err)
t.Cleanup(func() {
err = w.Schemas.DeleteByFullName(ctx, "main."+schemaName)
require.NoError(t, err)
})
t.Run("volume not in DAB", func(t *testing.T) {
volumePath := fmt.Sprintf("/Volumes/main/%s/doesnotexist", schemaName)
dir := t.TempDir()
b := &bundle.Bundle{
BundleRootPath: dir,
SyncRootPath: dir,
Config: config.Root{
Bundle: config.Bundle{
Target: "whatever",
},
Workspace: config.Workspace{
ArtifactPath: volumePath,
},
Resources: config.Resources{
Volumes: map[string]*resources.Volume{
"foo": {
CreateVolumeRequestContent: &catalog.CreateVolumeRequestContent{
CatalogName: "main",
Name: "my_volume",
VolumeType: "MANAGED",
SchemaName: schemaName,
},
},
},
},
},
}
diags := bundle.Apply(ctx, b, libraries.Upload())
assert.ErrorContains(t, diags.Error(), fmt.Sprintf("failed to fetch metadata for the UC volume %s that is configured in the artifact_path:", volumePath))
})
t.Run("volume in DAB config", func(t *testing.T) {
volumePath := fmt.Sprintf("/Volumes/main/%s/my_volume", schemaName)
dir := t.TempDir()
b := &bundle.Bundle{
BundleRootPath: dir,
SyncRootPath: dir,
Config: config.Root{
Bundle: config.Bundle{
Target: "whatever",
},
Workspace: config.Workspace{
ArtifactPath: volumePath,
},
Resources: config.Resources{
Volumes: map[string]*resources.Volume{
"foo": {
CreateVolumeRequestContent: &catalog.CreateVolumeRequestContent{
CatalogName: "main",
Name: "my_volume",
VolumeType: "MANAGED",
SchemaName: schemaName,
},
},
},
},
},
}
// set location of volume definition in config.
bundletest.SetLocation(b, "resources.volumes.foo", []dyn.Location{{
File: filepath.Join(dir, "databricks.yml"),
Line: 1,
Column: 2,
}})
diags := bundle.Apply(ctx, b, libraries.Upload())
assert.Contains(t, diags, diag.Diagnostic{
Severity: diag.Error,
Summary: fmt.Sprintf("failed to fetch metadata for the UC volume %s that is configured in the artifact_path: Not Found", volumePath),
})
assert.Contains(t, diags, diag.Diagnostic{
Severity: diag.Warning,
Summary: "You might be using a UC volume in your artifact_path that is managed by this bundle but which has not been deployed yet. Please deploy the UC volume in a separate bundle deploy before using it in the artifact_path.",
Locations: []dyn.Location{
{
File: filepath.Join(dir, "databricks.yml"),
Line: 1,
Column: 2,
},
},
Paths: []dyn.Path{
dyn.MustPathFromString("resources.volumes.foo"),
},
})
})
}

View File

@ -0,0 +1,8 @@
{
"properties": {
"unique_id": {
"type": "string",
"description": "Unique ID for the schema names"
}
}
}

View File

@ -0,0 +1,31 @@
bundle:
name: test-uc-volumes-{{.unique_id}}
variables:
schema_name:
default: ${resources.schemas.schema1.name}
resources:
schemas:
schema1:
name: schema1-{{.unique_id}}
catalog_name: main
comment: This schema was created from DABs
schema2:
name: schema2-{{.unique_id}}
catalog_name: main
comment: This schema was created from DABs
volumes:
foo:
catalog_name: main
name: my_volume
schema_name: ${var.schema_name}
volume_type: MANAGED
comment: This volume was created from DABs.
grants:
- principal: account users
privileges:
- WRITE_VOLUME

View File

@ -0,0 +1,2 @@
-- Databricks notebook source
select 1

View File

@ -243,3 +243,73 @@ func TestAccDeployBasicBundleLogs(t *testing.T) {
}, "\n"), stderr)
assert.Equal(t, "", stdout)
}
func TestAccDeployUcVolume(t *testing.T) {
ctx, wt := acc.UcWorkspaceTest(t)
w := wt.W
uniqueId := uuid.New().String()
bundleRoot, err := initTestTemplate(t, ctx, "volume", map[string]any{
"unique_id": uniqueId,
})
require.NoError(t, err)
err = deployBundle(t, ctx, bundleRoot)
require.NoError(t, err)
t.Cleanup(func() {
destroyBundle(t, ctx, bundleRoot)
})
// Assert the volume is created successfully
catalogName := "main"
schemaName := "schema1-" + uniqueId
volumeName := "my_volume"
fullName := fmt.Sprintf("%s.%s.%s", catalogName, schemaName, volumeName)
volume, err := w.Volumes.ReadByName(ctx, fullName)
require.NoError(t, err)
require.Equal(t, volume.Name, volumeName)
require.Equal(t, catalogName, volume.CatalogName)
require.Equal(t, schemaName, volume.SchemaName)
// Assert that the grants were successfully applied.
grants, err := w.Grants.GetBySecurableTypeAndFullName(ctx, catalog.SecurableTypeVolume, fullName)
require.NoError(t, err)
assert.Len(t, grants.PrivilegeAssignments, 1)
assert.Equal(t, "account users", grants.PrivilegeAssignments[0].Principal)
assert.Equal(t, []catalog.Privilege{catalog.PrivilegeWriteVolume}, grants.PrivilegeAssignments[0].Privileges)
// Recreation of the volume without --auto-approve should fail since prompting is not possible
t.Setenv("TERM", "dumb")
t.Setenv("BUNDLE_ROOT", bundleRoot)
stdout, stderr, err := internal.NewCobraTestRunnerWithContext(t, ctx, "bundle", "deploy", "--var=schema_name=${resources.schemas.schema2.name}").Run()
assert.Error(t, err)
assert.Contains(t, stderr.String(), `This action will result in the deletion or recreation of the following Volumes.
For managed volumes, the files stored in the volume are also deleted from your
cloud tenant within 30 days. For external volumes, the metadata about the volume
is removed from the catalog, but the underlying files are not deleted:
recreate volume foo`)
assert.Contains(t, stdout.String(), "the deployment requires destructive actions, but current console does not support prompting. Please specify --auto-approve if you would like to skip prompts and proceed")
// Successfully recreate the volume with --auto-approve
t.Setenv("TERM", "dumb")
t.Setenv("BUNDLE_ROOT", bundleRoot)
_, _, err = internal.NewCobraTestRunnerWithContext(t, ctx, "bundle", "deploy", "--var=schema_name=${resources.schemas.schema2.name}", "--auto-approve").Run()
assert.NoError(t, err)
// Assert the volume is updated successfully
schemaName = "schema2-" + uniqueId
fullName = fmt.Sprintf("%s.%s.%s", catalogName, schemaName, volumeName)
volume, err = w.Volumes.ReadByName(ctx, fullName)
require.NoError(t, err)
require.Equal(t, volume.Name, volumeName)
require.Equal(t, catalogName, volume.CatalogName)
require.Equal(t, schemaName, volume.SchemaName)
// assert that the grants were applied / retained on recreate.
grants, err = w.Grants.GetBySecurableTypeAndFullName(ctx, catalog.SecurableTypeVolume, fullName)
require.NoError(t, err)
assert.Len(t, grants.PrivilegeAssignments, 1)
assert.Equal(t, "account users", grants.PrivilegeAssignments[0].Principal)
assert.Equal(t, []catalog.Privilege{catalog.PrivilegeWriteVolume}, grants.PrivilegeAssignments[0].Privileges)
}

View File

@ -42,7 +42,7 @@ func initTestTemplateWithBundleRoot(t *testing.T, ctx context.Context, templateN
cmd := cmdio.NewIO(flags.OutputJSON, strings.NewReader(""), os.Stdout, os.Stderr, "", "bundles")
ctx = cmdio.InContext(ctx, cmd)
err = template.Materialize(ctx, configFilePath, templateRoot, bundleRoot)
err = template.Materialize(ctx, configFilePath, os.DirFS(templateRoot), bundleRoot)
return bundleRoot, err
}

View File

@ -71,3 +71,23 @@ func (v ref) references() []string {
func IsPureVariableReference(s string) bool {
return len(s) > 0 && re.FindString(s) == s
}
// If s is a pure variable reference, this function returns the corresponding
// dyn.Path. Otherwise, it returns false.
func PureReferenceToPath(s string) (dyn.Path, bool) {
ref, ok := newRef(dyn.V(s))
if !ok {
return nil, false
}
if !ref.isPure() {
return nil, false
}
p, err := dyn.NewPathFromString(ref.references()[0])
if err != nil {
return nil, false
}
return p, true
}

View File

@ -51,3 +51,34 @@ func TestIsPureVariableReference(t *testing.T) {
assert.False(t, IsPureVariableReference("prefix ${foo.bar}"))
assert.True(t, IsPureVariableReference("${foo.bar}"))
}
func TestPureReferenceToPath(t *testing.T) {
for _, tc := range []struct {
in string
out string
ok bool
}{
{"${foo.bar}", "foo.bar", true},
{"${foo.bar.baz}", "foo.bar.baz", true},
{"${foo.bar.baz[0]}", "foo.bar.baz[0]", true},
{"${foo.bar.baz[0][1]}", "foo.bar.baz[0][1]", true},
{"${foo.bar.baz[0][1].qux}", "foo.bar.baz[0][1].qux", true},
{"${foo.one}${foo.two}", "", false},
{"prefix ${foo.bar}", "", false},
{"${foo.bar} suffix", "", false},
{"${foo.bar", "", false},
{"foo.bar}", "", false},
{"foo.bar", "", false},
{"{foo.bar}", "", false},
{"", "", false},
} {
path, ok := PureReferenceToPath(tc.in)
if tc.ok {
assert.True(t, ok)
assert.Equal(t, dyn.MustPathFromString(tc.out), path)
} else {
assert.False(t, ok)
}
}
}

View File

@ -7,13 +7,24 @@ import (
"io/fs"
)
// WriteMode captures intent when writing a file.
//
// The first 9 bits are reserved for the [fs.FileMode] permission bits.
// These are used only by the local filer implementation and have
// no effect for the other implementations.
type WriteMode int
// writeModePerm is a mask to extract permission bits from a WriteMode.
const writeModePerm = WriteMode(fs.ModePerm)
const (
OverwriteIfExists WriteMode = 1 << iota
// Note: these constants are defined as powers of 2 to support combining them using a bit-wise OR.
// They starts from the 10th bit (permission mask + 1) to avoid conflicts with the permission bits.
OverwriteIfExists WriteMode = (writeModePerm + 1) << iota
CreateParentDirectories
)
// DeleteMode captures intent when deleting a file.
type DeleteMode int
const (

12
libs/filer/filer_test.go Normal file
View File

@ -0,0 +1,12 @@
package filer
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestWriteMode(t *testing.T) {
assert.Equal(t, 512, int(OverwriteIfExists))
assert.Equal(t, 1024, int(CreateParentDirectories))
}

View File

@ -28,6 +28,15 @@ func (w *LocalClient) Write(ctx context.Context, name string, reader io.Reader,
return err
}
// Retrieve permission mask from the [WriteMode], if present.
perm := fs.FileMode(0644)
for _, m := range mode {
bits := m & writeModePerm
if bits != 0 {
perm = fs.FileMode(bits)
}
}
flags := os.O_WRONLY | os.O_CREATE
if slices.Contains(mode, OverwriteIfExists) {
flags |= os.O_TRUNC
@ -35,7 +44,7 @@ func (w *LocalClient) Write(ctx context.Context, name string, reader io.Reader,
flags |= os.O_EXCL
}
f, err := os.OpenFile(absPath, flags, 0644)
f, err := os.OpenFile(absPath, flags, perm)
if errors.Is(err, fs.ErrNotExist) && slices.Contains(mode, CreateParentDirectories) {
// Create parent directories if they don't exist.
err = os.MkdirAll(filepath.Dir(absPath), 0755)
@ -43,7 +52,7 @@ func (w *LocalClient) Write(ctx context.Context, name string, reader io.Reader,
return err
}
// Try again.
f, err = os.OpenFile(absPath, flags, 0644)
f, err = os.OpenFile(absPath, flags, perm)
}
if err != nil {

View File

@ -114,7 +114,7 @@ type apiClient interface {
// NOTE: This API is available for files under /Repos if a workspace has files-in-repos enabled.
// It can access any workspace path if files-in-workspace is enabled.
type workspaceFilesClient struct {
type WorkspaceFilesClient struct {
workspaceClient *databricks.WorkspaceClient
apiClient apiClient
@ -128,7 +128,7 @@ func NewWorkspaceFilesClient(w *databricks.WorkspaceClient, root string) (Filer,
return nil, err
}
return &workspaceFilesClient{
return &WorkspaceFilesClient{
workspaceClient: w,
apiClient: apiClient,
@ -136,7 +136,7 @@ func NewWorkspaceFilesClient(w *databricks.WorkspaceClient, root string) (Filer,
}, nil
}
func (w *workspaceFilesClient) Write(ctx context.Context, name string, reader io.Reader, mode ...WriteMode) error {
func (w *WorkspaceFilesClient) Write(ctx context.Context, name string, reader io.Reader, mode ...WriteMode) error {
absPath, err := w.root.Join(name)
if err != nil {
return err
@ -214,7 +214,7 @@ func (w *workspaceFilesClient) Write(ctx context.Context, name string, reader io
return err
}
func (w *workspaceFilesClient) Read(ctx context.Context, name string) (io.ReadCloser, error) {
func (w *WorkspaceFilesClient) Read(ctx context.Context, name string) (io.ReadCloser, error) {
absPath, err := w.root.Join(name)
if err != nil {
return nil, err
@ -238,7 +238,7 @@ func (w *workspaceFilesClient) Read(ctx context.Context, name string) (io.ReadCl
return w.workspaceClient.Workspace.Download(ctx, absPath)
}
func (w *workspaceFilesClient) Delete(ctx context.Context, name string, mode ...DeleteMode) error {
func (w *WorkspaceFilesClient) Delete(ctx context.Context, name string, mode ...DeleteMode) error {
absPath, err := w.root.Join(name)
if err != nil {
return err
@ -282,7 +282,7 @@ func (w *workspaceFilesClient) Delete(ctx context.Context, name string, mode ...
return err
}
func (w *workspaceFilesClient) ReadDir(ctx context.Context, name string) ([]fs.DirEntry, error) {
func (w *WorkspaceFilesClient) ReadDir(ctx context.Context, name string) ([]fs.DirEntry, error) {
absPath, err := w.root.Join(name)
if err != nil {
return nil, err
@ -315,7 +315,7 @@ func (w *workspaceFilesClient) ReadDir(ctx context.Context, name string) ([]fs.D
return wsfsDirEntriesFromObjectInfos(objects), nil
}
func (w *workspaceFilesClient) Mkdir(ctx context.Context, name string) error {
func (w *WorkspaceFilesClient) Mkdir(ctx context.Context, name string) error {
dirPath, err := w.root.Join(name)
if err != nil {
return err
@ -325,7 +325,7 @@ func (w *workspaceFilesClient) Mkdir(ctx context.Context, name string) error {
})
}
func (w *workspaceFilesClient) Stat(ctx context.Context, name string) (fs.FileInfo, error) {
func (w *WorkspaceFilesClient) Stat(ctx context.Context, name string) (fs.FileInfo, error) {
absPath, err := w.root.Join(name)
if err != nil {
return nil, err

View File

@ -174,7 +174,7 @@ func TestFilerWorkspaceFilesExtensionsErrorsOnDupName(t *testing.T) {
"return_export_info": "true",
}, mock.AnythingOfType("*filer.wsfsFileInfo"), []func(*http.Request) error(nil)).Return(nil, statNotebook)
workspaceFilesClient := workspaceFilesClient{
workspaceFilesClient := WorkspaceFilesClient{
workspaceClient: mockedWorkspaceClient.WorkspaceClient,
apiClient: &mockedApiClient,
root: NewWorkspaceRootPath("/dir"),

View File

@ -3,7 +3,9 @@ package jsonschema
import (
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"slices"
@ -255,7 +257,12 @@ func (schema *Schema) validate() error {
}
func Load(path string) (*Schema, error) {
b, err := os.ReadFile(path)
dir, file := filepath.Split(path)
return LoadFS(os.DirFS(dir), file)
}
func LoadFS(fsys fs.FS, path string) (*Schema, error) {
b, err := fs.ReadFile(fsys, path)
if err != nil {
return nil, err
}

View File

@ -1,6 +1,7 @@
package jsonschema
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
@ -305,3 +306,9 @@ func TestValidateSchemaSkippedPropertiesHaveDefaults(t *testing.T) {
err = s.validate()
assert.NoError(t, err)
}
func TestSchema_LoadFS(t *testing.T) {
fsys := os.DirFS("./testdata/schema-load-int")
_, err := LoadFS(fsys, "schema-valid.json")
assert.NoError(t, err)
}

47
libs/template/builtin.go Normal file
View File

@ -0,0 +1,47 @@
package template
import (
"embed"
"io/fs"
)
//go:embed all:templates
var builtinTemplates embed.FS
// BuiltinTemplate represents a template that is built into the CLI.
type BuiltinTemplate struct {
Name string
FS fs.FS
}
// Builtin returns the list of all built-in templates.
func Builtin() ([]BuiltinTemplate, error) {
templates, err := fs.Sub(builtinTemplates, "templates")
if err != nil {
return nil, err
}
entries, err := fs.ReadDir(templates, ".")
if err != nil {
return nil, err
}
var out []BuiltinTemplate
for _, entry := range entries {
if !entry.IsDir() {
continue
}
templateFS, err := fs.Sub(templates, entry.Name())
if err != nil {
return nil, err
}
out = append(out, BuiltinTemplate{
Name: entry.Name(),
FS: templateFS,
})
}
return out, nil
}

View File

@ -0,0 +1,28 @@
package template
import (
"io/fs"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuiltin(t *testing.T) {
out, err := Builtin()
require.NoError(t, err)
assert.Len(t, out, 3)
// Confirm names.
assert.Equal(t, "dbt-sql", out[0].Name)
assert.Equal(t, "default-python", out[1].Name)
assert.Equal(t, "default-sql", out[2].Name)
// Confirm that the filesystems work.
_, err = fs.Stat(out[0].FS, `template/{{.project_name}}/dbt_project.yml.tmpl`)
assert.NoError(t, err)
_, err = fs.Stat(out[1].FS, `template/{{.project_name}}/tests/main_test.py.tmpl`)
assert.NoError(t, err)
_, err = fs.Stat(out[2].FS, `template/{{.project_name}}/src/orders_daily.sql.tmpl`)
assert.NoError(t, err)
}

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"io/fs"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/jsonschema"
@ -28,9 +29,8 @@ type config struct {
schema *jsonschema.Schema
}
func newConfig(ctx context.Context, schemaPath string) (*config, error) {
// Read config schema
schema, err := jsonschema.Load(schemaPath)
func newConfig(ctx context.Context, templateFS fs.FS, schemaPath string) (*config, error) {
schema, err := jsonschema.LoadFS(templateFS, schemaPath)
if err != nil {
return nil, err
}

View File

@ -3,6 +3,8 @@ package template
import (
"context"
"fmt"
"os"
"path"
"path/filepath"
"testing"
"text/template"
@ -16,7 +18,7 @@ func TestTemplateConfigAssignValuesFromFile(t *testing.T) {
testDir := "./testdata/config-assign-from-file"
ctx := context.Background()
c, err := newConfig(ctx, filepath.Join(testDir, "schema.json"))
c, err := newConfig(ctx, os.DirFS(testDir), "schema.json")
require.NoError(t, err)
err = c.assignValuesFromFile(filepath.Join(testDir, "config.json"))
@ -32,7 +34,7 @@ func TestTemplateConfigAssignValuesFromFileDoesNotOverwriteExistingConfigs(t *te
testDir := "./testdata/config-assign-from-file"
ctx := context.Background()
c, err := newConfig(ctx, filepath.Join(testDir, "schema.json"))
c, err := newConfig(ctx, os.DirFS(testDir), "schema.json")
require.NoError(t, err)
c.values = map[string]any{
@ -52,7 +54,7 @@ func TestTemplateConfigAssignValuesFromFileForInvalidIntegerValue(t *testing.T)
testDir := "./testdata/config-assign-from-file-invalid-int"
ctx := context.Background()
c, err := newConfig(ctx, filepath.Join(testDir, "schema.json"))
c, err := newConfig(ctx, os.DirFS(testDir), "schema.json")
require.NoError(t, err)
err = c.assignValuesFromFile(filepath.Join(testDir, "config.json"))
@ -63,7 +65,7 @@ func TestTemplateConfigAssignValuesFromFileFiltersPropertiesNotInTheSchema(t *te
testDir := "./testdata/config-assign-from-file-unknown-property"
ctx := context.Background()
c, err := newConfig(ctx, filepath.Join(testDir, "schema.json"))
c, err := newConfig(ctx, os.DirFS(testDir), "schema.json")
require.NoError(t, err)
err = c.assignValuesFromFile(filepath.Join(testDir, "config.json"))
@ -78,10 +80,10 @@ func TestTemplateConfigAssignValuesFromDefaultValues(t *testing.T) {
testDir := "./testdata/config-assign-from-default-value"
ctx := context.Background()
c, err := newConfig(ctx, filepath.Join(testDir, "schema.json"))
c, err := newConfig(ctx, os.DirFS(testDir), "schema.json")
require.NoError(t, err)
r, err := newRenderer(ctx, nil, nil, "./testdata/empty/template", "./testdata/empty/library", t.TempDir())
r, err := newRenderer(ctx, nil, nil, os.DirFS("."), "./testdata/empty/template", "./testdata/empty/library")
require.NoError(t, err)
err = c.assignDefaultValues(r)
@ -97,10 +99,10 @@ func TestTemplateConfigAssignValuesFromTemplatedDefaultValues(t *testing.T) {
testDir := "./testdata/config-assign-from-templated-default-value"
ctx := context.Background()
c, err := newConfig(ctx, filepath.Join(testDir, "schema.json"))
c, err := newConfig(ctx, os.DirFS(testDir), "schema.json")
require.NoError(t, err)
r, err := newRenderer(ctx, nil, nil, filepath.Join(testDir, "template/template"), filepath.Join(testDir, "template/library"), t.TempDir())
r, err := newRenderer(ctx, nil, nil, os.DirFS("."), path.Join(testDir, "template/template"), path.Join(testDir, "template/library"))
require.NoError(t, err)
// Note: only the string value is templated.
@ -116,7 +118,7 @@ func TestTemplateConfigAssignValuesFromTemplatedDefaultValues(t *testing.T) {
func TestTemplateConfigValidateValuesDefined(t *testing.T) {
ctx := context.Background()
c, err := newConfig(ctx, "testdata/config-test-schema/test-schema.json")
c, err := newConfig(ctx, os.DirFS("testdata/config-test-schema"), "test-schema.json")
require.NoError(t, err)
c.values = map[string]any{
@ -131,7 +133,7 @@ func TestTemplateConfigValidateValuesDefined(t *testing.T) {
func TestTemplateConfigValidateTypeForValidConfig(t *testing.T) {
ctx := context.Background()
c, err := newConfig(ctx, "testdata/config-test-schema/test-schema.json")
c, err := newConfig(ctx, os.DirFS("testdata/config-test-schema"), "test-schema.json")
require.NoError(t, err)
c.values = map[string]any{
@ -147,7 +149,7 @@ func TestTemplateConfigValidateTypeForValidConfig(t *testing.T) {
func TestTemplateConfigValidateTypeForUnknownField(t *testing.T) {
ctx := context.Background()
c, err := newConfig(ctx, "testdata/config-test-schema/test-schema.json")
c, err := newConfig(ctx, os.DirFS("testdata/config-test-schema"), "test-schema.json")
require.NoError(t, err)
c.values = map[string]any{
@ -164,7 +166,7 @@ func TestTemplateConfigValidateTypeForUnknownField(t *testing.T) {
func TestTemplateConfigValidateTypeForInvalidType(t *testing.T) {
ctx := context.Background()
c, err := newConfig(ctx, "testdata/config-test-schema/test-schema.json")
c, err := newConfig(ctx, os.DirFS("testdata/config-test-schema"), "test-schema.json")
require.NoError(t, err)
c.values = map[string]any{
@ -271,7 +273,8 @@ func TestTemplateEnumValidation(t *testing.T) {
}
func TestTemplateSchemaErrorsWithEmptyDescription(t *testing.T) {
_, err := newConfig(context.Background(), "./testdata/config-test-schema/invalid-test-schema.json")
ctx := context.Background()
_, err := newConfig(ctx, os.DirFS("./testdata/config-test-schema"), "invalid-test-schema.json")
assert.EqualError(t, err, "template property property-without-description is missing a description")
}

View File

@ -1,11 +1,10 @@
package template
import (
"bytes"
"context"
"io"
"io/fs"
"os"
"path/filepath"
"slices"
"github.com/databricks/cli/libs/filer"
)
@ -13,89 +12,69 @@ import (
// Interface representing a file to be materialized from a template into a project
// instance
type file interface {
// Destination path for file. This is where the file will be created when
// PersistToDisk is called.
DstPath() *destinationPath
// Path of the file relative to the root of the instantiated template.
// This is where the file is written to when persisting the template to disk.
// Must be slash-separated.
RelPath() string
// Write file to disk at the destination path.
PersistToDisk() error
}
Write(ctx context.Context, out filer.Filer) error
type destinationPath struct {
// Root path for the project instance. This path uses the system's default
// file separator. For example /foo/bar on Unix and C:\foo\bar on windows
root string
// Unix like file path relative to the "root" of the instantiated project. Is used to
// evaluate whether the file should be skipped by comparing it to a list of
// skip glob patterns.
relPath string
}
// Absolute path of the file, in the os native format. For example /foo/bar on
// Unix and C:\foo\bar on windows
func (f *destinationPath) absPath() string {
return filepath.Join(f.root, filepath.FromSlash(f.relPath))
// contents returns the file contents as a byte slice.
// This is used for testing purposes.
contents() ([]byte, error)
}
type copyFile struct {
ctx context.Context
// Permissions bits for the destination file
perm fs.FileMode
dstPath *destinationPath
// Destination path for the file.
relPath string
// Filer rooted at template root. Used to read srcPath.
srcFiler filer.Filer
// [fs.FS] rooted at template root. Used to read srcPath.
srcFS fs.FS
// Relative path from template root for file to be copied.
srcPath string
}
func (f *copyFile) DstPath() *destinationPath {
return f.dstPath
func (f *copyFile) RelPath() string {
return f.relPath
}
func (f *copyFile) PersistToDisk() error {
path := f.DstPath().absPath()
err := os.MkdirAll(filepath.Dir(path), 0755)
func (f *copyFile) Write(ctx context.Context, out filer.Filer) error {
src, err := f.srcFS.Open(f.srcPath)
if err != nil {
return err
}
srcFile, err := f.srcFiler.Read(f.ctx, f.srcPath)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, f.perm)
if err != nil {
return err
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
return err
defer src.Close()
return out.Write(ctx, f.relPath, src, filer.CreateParentDirectories, filer.WriteMode(f.perm))
}
func (f *copyFile) contents() ([]byte, error) {
return fs.ReadFile(f.srcFS, f.srcPath)
}
type inMemoryFile struct {
dstPath *destinationPath
content []byte
// Permissions bits for the destination file
perm fs.FileMode
// Destination path for the file.
relPath string
// Contents of the file.
content []byte
}
func (f *inMemoryFile) DstPath() *destinationPath {
return f.dstPath
func (f *inMemoryFile) RelPath() string {
return f.relPath
}
func (f *inMemoryFile) PersistToDisk() error {
path := f.DstPath().absPath()
err := os.MkdirAll(filepath.Dir(path), 0755)
if err != nil {
return err
}
return os.WriteFile(path, f.content, f.perm)
func (f *inMemoryFile) Write(ctx context.Context, out filer.Filer) error {
return out.Write(ctx, f.relPath, bytes.NewReader(f.content), filer.CreateParentDirectories, filer.WriteMode(f.perm))
}
func (f *inMemoryFile) contents() ([]byte, error) {
return slices.Clone(f.content), nil
}

View File

@ -13,76 +13,51 @@ import (
"github.com/stretchr/testify/require"
)
func testInMemoryFile(t *testing.T, perm fs.FileMode) {
func testInMemoryFile(t *testing.T, ctx context.Context, perm fs.FileMode) {
tmpDir := t.TempDir()
f := &inMemoryFile{
dstPath: &destinationPath{
root: tmpDir,
relPath: "a/b/c",
},
perm: perm,
relPath: "a/b/c",
content: []byte("123"),
}
err := f.PersistToDisk()
out, err := filer.NewLocalClient(tmpDir)
require.NoError(t, err)
err = f.Write(ctx, out)
assert.NoError(t, err)
assertFileContent(t, filepath.Join(tmpDir, "a/b/c"), "123")
assertFilePermissions(t, filepath.Join(tmpDir, "a/b/c"), perm)
}
func testCopyFile(t *testing.T, perm fs.FileMode) {
func testCopyFile(t *testing.T, ctx context.Context, perm fs.FileMode) {
tmpDir := t.TempDir()
templateFiler, err := filer.NewLocalClient(tmpDir)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(tmpDir, "source"), []byte("qwerty"), perm)
err := os.WriteFile(filepath.Join(tmpDir, "source"), []byte("qwerty"), perm)
require.NoError(t, err)
f := &copyFile{
ctx: context.Background(),
dstPath: &destinationPath{
root: tmpDir,
relPath: "a/b/c",
},
perm: perm,
srcPath: "source",
srcFiler: templateFiler,
perm: perm,
relPath: "a/b/c",
srcFS: os.DirFS(tmpDir),
srcPath: "source",
}
err = f.PersistToDisk()
out, err := filer.NewLocalClient(tmpDir)
require.NoError(t, err)
err = f.Write(ctx, out)
assert.NoError(t, err)
assertFileContent(t, filepath.Join(tmpDir, "a/b/c"), "qwerty")
assertFilePermissions(t, filepath.Join(tmpDir, "a/b/c"), perm)
}
func TestTemplateFileDestinationPath(t *testing.T) {
if runtime.GOOS == "windows" {
t.SkipNow()
}
f := &destinationPath{
root: `a/b/c`,
relPath: "d/e",
}
assert.Equal(t, `a/b/c/d/e`, f.absPath())
}
func TestTemplateFileDestinationPathForWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.SkipNow()
}
f := &destinationPath{
root: `c:\a\b\c`,
relPath: "d/e",
}
assert.Equal(t, `c:\a\b\c\d\e`, f.absPath())
}
func TestTemplateInMemoryFilePersistToDisk(t *testing.T) {
if runtime.GOOS == "windows" {
t.SkipNow()
}
testInMemoryFile(t, 0755)
ctx := context.Background()
testInMemoryFile(t, ctx, 0755)
}
func TestTemplateInMemoryFilePersistToDiskForWindows(t *testing.T) {
@ -91,14 +66,16 @@ func TestTemplateInMemoryFilePersistToDiskForWindows(t *testing.T) {
}
// we have separate tests for windows because of differences in valid
// fs.FileMode values we can use for different operating systems.
testInMemoryFile(t, 0666)
ctx := context.Background()
testInMemoryFile(t, ctx, 0666)
}
func TestTemplateCopyFilePersistToDisk(t *testing.T) {
if runtime.GOOS == "windows" {
t.SkipNow()
}
testCopyFile(t, 0644)
ctx := context.Background()
testCopyFile(t, ctx, 0644)
}
func TestTemplateCopyFilePersistToDiskForWindows(t *testing.T) {
@ -107,5 +84,6 @@ func TestTemplateCopyFilePersistToDiskForWindows(t *testing.T) {
}
// we have separate tests for windows because of differences in valid
// fs.FileMode values we can use for different operating systems.
testCopyFile(t, 0666)
ctx := context.Background()
testCopyFile(t, ctx, 0666)
}

View File

@ -18,11 +18,10 @@ import (
func TestTemplatePrintStringWithoutProcessing(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
ctx = root.SetWorkspaceClient(ctx, nil)
helpers := loadHelpers(ctx)
r, err := newRenderer(ctx, nil, helpers, "./testdata/print-without-processing/template", "./testdata/print-without-processing/library", tmpDir)
r, err := newRenderer(ctx, nil, helpers, os.DirFS("."), "./testdata/print-without-processing/template", "./testdata/print-without-processing/library")
require.NoError(t, err)
err = r.walk()
@ -35,11 +34,10 @@ func TestTemplatePrintStringWithoutProcessing(t *testing.T) {
func TestTemplateRegexpCompileFunction(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
ctx = root.SetWorkspaceClient(ctx, nil)
helpers := loadHelpers(ctx)
r, err := newRenderer(ctx, nil, helpers, "./testdata/regexp-compile/template", "./testdata/regexp-compile/library", tmpDir)
r, err := newRenderer(ctx, nil, helpers, os.DirFS("."), "./testdata/regexp-compile/template", "./testdata/regexp-compile/library")
require.NoError(t, err)
err = r.walk()
@ -53,11 +51,10 @@ func TestTemplateRegexpCompileFunction(t *testing.T) {
func TestTemplateRandIntFunction(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
ctx = root.SetWorkspaceClient(ctx, nil)
helpers := loadHelpers(ctx)
r, err := newRenderer(ctx, nil, helpers, "./testdata/random-int/template", "./testdata/random-int/library", tmpDir)
r, err := newRenderer(ctx, nil, helpers, os.DirFS("."), "./testdata/random-int/template", "./testdata/random-int/library")
require.NoError(t, err)
err = r.walk()
@ -71,11 +68,10 @@ func TestTemplateRandIntFunction(t *testing.T) {
func TestTemplateUuidFunction(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
ctx = root.SetWorkspaceClient(ctx, nil)
helpers := loadHelpers(ctx)
r, err := newRenderer(ctx, nil, helpers, "./testdata/uuid/template", "./testdata/uuid/library", tmpDir)
r, err := newRenderer(ctx, nil, helpers, os.DirFS("."), "./testdata/uuid/template", "./testdata/uuid/library")
require.NoError(t, err)
err = r.walk()
@ -88,11 +84,10 @@ func TestTemplateUuidFunction(t *testing.T) {
func TestTemplateUrlFunction(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
ctx = root.SetWorkspaceClient(ctx, nil)
helpers := loadHelpers(ctx)
r, err := newRenderer(ctx, nil, helpers, "./testdata/urlparse-function/template", "./testdata/urlparse-function/library", tmpDir)
r, err := newRenderer(ctx, nil, helpers, os.DirFS("."), "./testdata/urlparse-function/template", "./testdata/urlparse-function/library")
require.NoError(t, err)
@ -105,11 +100,10 @@ func TestTemplateUrlFunction(t *testing.T) {
func TestTemplateMapPairFunction(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
ctx = root.SetWorkspaceClient(ctx, nil)
helpers := loadHelpers(ctx)
r, err := newRenderer(ctx, nil, helpers, "./testdata/map-pair/template", "./testdata/map-pair/library", tmpDir)
r, err := newRenderer(ctx, nil, helpers, os.DirFS("."), "./testdata/map-pair/template", "./testdata/map-pair/library")
require.NoError(t, err)
@ -122,7 +116,6 @@ func TestTemplateMapPairFunction(t *testing.T) {
func TestWorkspaceHost(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
w := &databricks.WorkspaceClient{
Config: &workspaceConfig.Config{
@ -132,7 +125,7 @@ func TestWorkspaceHost(t *testing.T) {
ctx = root.SetWorkspaceClient(ctx, w)
helpers := loadHelpers(ctx)
r, err := newRenderer(ctx, nil, helpers, "./testdata/workspace-host/template", "./testdata/map-pair/library", tmpDir)
r, err := newRenderer(ctx, nil, helpers, os.DirFS("."), "./testdata/workspace-host/template", "./testdata/map-pair/library")
require.NoError(t, err)
@ -149,7 +142,6 @@ func TestWorkspaceHostNotConfigured(t *testing.T) {
ctx := context.Background()
cmd := cmdio.NewIO(flags.OutputJSON, strings.NewReader(""), os.Stdout, os.Stderr, "", "template")
ctx = cmdio.InContext(ctx, cmd)
tmpDir := t.TempDir()
w := &databricks.WorkspaceClient{
Config: &workspaceConfig.Config{},
@ -157,7 +149,7 @@ func TestWorkspaceHostNotConfigured(t *testing.T) {
ctx = root.SetWorkspaceClient(ctx, w)
helpers := loadHelpers(ctx)
r, err := newRenderer(ctx, nil, helpers, "./testdata/workspace-host/template", "./testdata/map-pair/library", tmpDir)
r, err := newRenderer(ctx, nil, helpers, os.DirFS("."), "./testdata/workspace-host/template", "./testdata/map-pair/library")
assert.NoError(t, err)

View File

@ -2,54 +2,32 @@ package template
import (
"context"
"embed"
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/filer"
)
const libraryDirName = "library"
const templateDirName = "template"
const schemaFileName = "databricks_template_schema.json"
//go:embed all:templates
var builtinTemplates embed.FS
// This function materializes the input templates as a project, using user defined
// configurations.
// Parameters:
//
// ctx: context containing a cmdio object. This is used to prompt the user
// configFilePath: file path containing user defined config values
// templateRoot: root of the template definition
// templateFS: root of the template definition
// outputDir: root of directory where to initialize the template
func Materialize(ctx context.Context, configFilePath, templateRoot, outputDir string) error {
// Use a temporary directory in case any builtin templates like default-python are used
tempDir, err := os.MkdirTemp("", "templates")
defer os.RemoveAll(tempDir)
if err != nil {
return err
}
templateRoot, err = prepareBuiltinTemplates(templateRoot, tempDir)
if err != nil {
return err
func Materialize(ctx context.Context, configFilePath string, templateFS fs.FS, outputDir string) error {
if _, err := fs.Stat(templateFS, schemaFileName); errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("not a bundle template: expected to find a template schema file at %s", schemaFileName)
}
templatePath := filepath.Join(templateRoot, templateDirName)
libraryPath := filepath.Join(templateRoot, libraryDirName)
schemaPath := filepath.Join(templateRoot, schemaFileName)
helpers := loadHelpers(ctx)
if _, err := os.Stat(schemaPath); errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("not a bundle template: expected to find a template schema file at %s", schemaPath)
}
config, err := newConfig(ctx, schemaPath)
config, err := newConfig(ctx, templateFS, schemaFileName)
if err != nil {
return err
}
@ -62,7 +40,8 @@ func Materialize(ctx context.Context, configFilePath, templateRoot, outputDir st
}
}
r, err := newRenderer(ctx, config.values, helpers, templatePath, libraryPath, outputDir)
helpers := loadHelpers(ctx)
r, err := newRenderer(ctx, config.values, helpers, templateFS, templateDirName, libraryDirName)
if err != nil {
return err
}
@ -94,7 +73,12 @@ func Materialize(ctx context.Context, configFilePath, templateRoot, outputDir st
return err
}
err = r.persistToDisk()
out, err := filer.NewLocalClient(outputDir)
if err != nil {
return err
}
err = r.persistToDisk(ctx, out)
if err != nil {
return err
}
@ -111,44 +95,3 @@ func Materialize(ctx context.Context, configFilePath, templateRoot, outputDir st
}
return nil
}
// If the given templateRoot matches
func prepareBuiltinTemplates(templateRoot string, tempDir string) (string, error) {
// Check that `templateRoot` is a clean basename, i.e. `some_path` and not `./some_path` or "."
// Return early if that's not the case.
if templateRoot == "." || path.Base(templateRoot) != templateRoot {
return templateRoot, nil
}
_, err := fs.Stat(builtinTemplates, path.Join("templates", templateRoot))
if err != nil {
// The given path doesn't appear to be using out built-in templates
return templateRoot, nil
}
// We have a built-in template with the same name as templateRoot!
// Now we need to make a fully copy of the builtin templates to a real file system
// since template.Parse() doesn't support embed.FS.
err = fs.WalkDir(builtinTemplates, "templates", func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
targetPath := filepath.Join(tempDir, path)
if entry.IsDir() {
return os.Mkdir(targetPath, 0755)
} else {
content, err := fs.ReadFile(builtinTemplates, path)
if err != nil {
return err
}
return os.WriteFile(targetPath, content, 0644)
}
})
if err != nil {
return "", err
}
return filepath.Join(tempDir, "templates", templateRoot), nil
}

View File

@ -3,7 +3,7 @@ package template
import (
"context"
"fmt"
"path/filepath"
"os"
"testing"
"github.com/databricks/cli/cmd/root"
@ -19,6 +19,6 @@ func TestMaterializeForNonTemplateDirectory(t *testing.T) {
ctx := root.SetWorkspaceClient(context.Background(), w)
// Try to materialize a non-template directory.
err = Materialize(ctx, "", tmpDir, "")
assert.EqualError(t, err, fmt.Sprintf("not a bundle template: expected to find a template schema file at %s", filepath.Join(tmpDir, schemaFileName)))
err = Materialize(ctx, "", os.DirFS(tmpDir), "")
assert.EqualError(t, err, fmt.Sprintf("not a bundle template: expected to find a template schema file at %s", schemaFileName))
}

View File

@ -6,9 +6,7 @@ import (
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"regexp"
"slices"
"sort"
@ -52,32 +50,38 @@ type renderer struct {
// do not match any glob patterns from this list
skipPatterns []string
// Filer rooted at template root. The file tree from this root is walked to
// generate the project
templateFiler filer.Filer
// Root directory for the project instantiated from the template
instanceRoot string
// [fs.FS] that holds the template's file tree.
srcFS fs.FS
}
func newRenderer(ctx context.Context, config map[string]any, helpers template.FuncMap, templateRoot, libraryRoot, instanceRoot string) (*renderer, error) {
func newRenderer(
ctx context.Context,
config map[string]any,
helpers template.FuncMap,
templateFS fs.FS,
templateDir string,
libraryDir string,
) (*renderer, error) {
// Initialize new template, with helper functions loaded
tmpl := template.New("").Funcs(helpers)
// Load user defined associated templates from the library root
libraryGlob := filepath.Join(libraryRoot, "*")
matches, err := filepath.Glob(libraryGlob)
// Find user-defined templates in the library directory
matches, err := fs.Glob(templateFS, path.Join(libraryDir, "*"))
if err != nil {
return nil, err
}
// Parse user-defined templates.
// Note: we do not call [ParseFS] with the glob directly because
// it returns an error if no files match the pattern.
if len(matches) != 0 {
tmpl, err = tmpl.ParseFiles(matches...)
tmpl, err = tmpl.ParseFS(templateFS, matches...)
if err != nil {
return nil, err
}
}
templateFiler, err := filer.NewLocalClient(templateRoot)
srcFS, err := fs.Sub(templateFS, path.Clean(templateDir))
if err != nil {
return nil, err
}
@ -85,13 +89,12 @@ func newRenderer(ctx context.Context, config map[string]any, helpers template.Fu
ctx = log.NewContext(ctx, log.GetLogger(ctx).With("action", "initialize-template"))
return &renderer{
ctx: ctx,
config: config,
baseTemplate: tmpl,
files: make([]file, 0),
skipPatterns: make([]string, 0),
templateFiler: templateFiler,
instanceRoot: instanceRoot,
ctx: ctx,
config: config,
baseTemplate: tmpl,
files: make([]file, 0),
skipPatterns: make([]string, 0),
srcFS: srcFS,
}, nil
}
@ -141,7 +144,7 @@ func (r *renderer) executeTemplate(templateDefinition string) (string, error) {
func (r *renderer) computeFile(relPathTemplate string) (file, error) {
// read file permissions
info, err := r.templateFiler.Stat(r.ctx, relPathTemplate)
info, err := fs.Stat(r.srcFS, relPathTemplate)
if err != nil {
return nil, err
}
@ -157,14 +160,10 @@ func (r *renderer) computeFile(relPathTemplate string) (file, error) {
// over as is, without treating it as a template
if !strings.HasSuffix(relPathTemplate, templateExtension) {
return &copyFile{
dstPath: &destinationPath{
root: r.instanceRoot,
relPath: relPath,
},
perm: perm,
ctx: r.ctx,
srcPath: relPathTemplate,
srcFiler: r.templateFiler,
perm: perm,
relPath: relPath,
srcFS: r.srcFS,
srcPath: relPathTemplate,
}, nil
} else {
// Trim the .tmpl suffix from file name, if specified in the template
@ -173,7 +172,7 @@ func (r *renderer) computeFile(relPathTemplate string) (file, error) {
}
// read template file's content
templateReader, err := r.templateFiler.Read(r.ctx, relPathTemplate)
templateReader, err := r.srcFS.Open(relPathTemplate)
if err != nil {
return nil, err
}
@ -194,11 +193,8 @@ func (r *renderer) computeFile(relPathTemplate string) (file, error) {
}
return &inMemoryFile{
dstPath: &destinationPath{
root: r.instanceRoot,
relPath: relPath,
},
perm: perm,
relPath: relPath,
content: []byte(content),
}, nil
}
@ -263,7 +259,7 @@ func (r *renderer) walk() error {
//
// 2. For directories: They are appended to a slice, which acts as a queue
// allowing BFS traversal of the template file tree
entries, err := r.templateFiler.ReadDir(r.ctx, currentDirectory)
entries, err := fs.ReadDir(r.srcFS, currentDirectory)
if err != nil {
return err
}
@ -283,7 +279,7 @@ func (r *renderer) walk() error {
if err != nil {
return err
}
logger.Infof(r.ctx, "added file to list of possible project files: %s", f.DstPath().relPath)
logger.Infof(r.ctx, "added file to list of possible project files: %s", f.RelPath())
r.files = append(r.files, f)
}
@ -291,17 +287,17 @@ func (r *renderer) walk() error {
return nil
}
func (r *renderer) persistToDisk() error {
func (r *renderer) persistToDisk(ctx context.Context, out filer.Filer) error {
// Accumulate files which we will persist, skipping files whose path matches
// any of the skip patterns
filesToPersist := make([]file, 0)
for _, file := range r.files {
match, err := isSkipped(file.DstPath().relPath, r.skipPatterns)
match, err := isSkipped(file.RelPath(), r.skipPatterns)
if err != nil {
return err
}
if match {
log.Infof(r.ctx, "skipping file: %s", file.DstPath())
log.Infof(r.ctx, "skipping file: %s", file.RelPath())
continue
}
filesToPersist = append(filesToPersist, file)
@ -309,8 +305,8 @@ func (r *renderer) persistToDisk() error {
// Assert no conflicting files exist
for _, file := range filesToPersist {
path := file.DstPath().absPath()
_, err := os.Stat(path)
path := file.RelPath()
_, err := out.Stat(ctx, path)
if err == nil {
return fmt.Errorf("failed to initialize template, one or more files already exist: %s", path)
}
@ -321,7 +317,7 @@ func (r *renderer) persistToDisk() error {
// Persist files to disk
for _, file := range filesToPersist {
err := file.PersistToDisk()
err := file.Write(ctx, out)
if err != nil {
return err
}

View File

@ -3,9 +3,9 @@ package template
import (
"context"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"runtime"
"strings"
@ -18,6 +18,7 @@ import (
"github.com/databricks/cli/cmd/root"
"github.com/databricks/cli/internal/testutil"
"github.com/databricks/cli/libs/diag"
"github.com/databricks/cli/libs/filer"
"github.com/databricks/cli/libs/tags"
"github.com/databricks/databricks-sdk-go"
workspaceConfig "github.com/databricks/databricks-sdk-go/config"
@ -41,9 +42,8 @@ func assertFilePermissions(t *testing.T, path string, perm fs.FileMode) {
func assertBuiltinTemplateValid(t *testing.T, template string, settings map[string]any, target string, isServicePrincipal bool, build bool, tempDir string) {
ctx := context.Background()
templatePath, err := prepareBuiltinTemplates(template, tempDir)
templateFS, err := fs.Sub(builtinTemplates, path.Join("templates", template))
require.NoError(t, err)
libraryPath := filepath.Join(templatePath, "library")
w := &databricks.WorkspaceClient{
Config: &workspaceConfig.Config{Host: "https://myhost.com"},
@ -58,16 +58,18 @@ func assertBuiltinTemplateValid(t *testing.T, template string, settings map[stri
ctx = root.SetWorkspaceClient(ctx, w)
helpers := loadHelpers(ctx)
renderer, err := newRenderer(ctx, settings, helpers, templatePath, libraryPath, tempDir)
renderer, err := newRenderer(ctx, settings, helpers, templateFS, templateDirName, libraryDirName)
require.NoError(t, err)
// Evaluate template
err = renderer.walk()
require.NoError(t, err)
err = renderer.persistToDisk()
out, err := filer.NewLocalClient(tempDir)
require.NoError(t, err)
err = renderer.persistToDisk(ctx, out)
require.NoError(t, err)
b, err := bundle.Load(ctx, filepath.Join(tempDir, "template", "my_project"))
b, err := bundle.Load(ctx, filepath.Join(tempDir, "my_project"))
require.NoError(t, err)
diags := bundle.Apply(ctx, b, phases.LoadNamedTarget(target))
require.NoError(t, diags.Error())
@ -96,18 +98,6 @@ func assertBuiltinTemplateValid(t *testing.T, template string, settings map[stri
}
}
func TestPrepareBuiltInTemplatesWithRelativePaths(t *testing.T) {
// CWD should not be resolved as a built in template
dir, err := prepareBuiltinTemplates(".", t.TempDir())
assert.NoError(t, err)
assert.Equal(t, ".", dir)
// relative path should not be resolved as a built in template
dir, err = prepareBuiltinTemplates("./default-python", t.TempDir())
assert.NoError(t, err)
assert.Equal(t, "./default-python", dir)
}
func TestBuiltinPythonTemplateValid(t *testing.T) {
// Test option combinations
options := []string{"yes", "no"}
@ -194,13 +184,14 @@ func TestRendererWithAssociatedTemplateInLibrary(t *testing.T) {
ctx := context.Background()
ctx = root.SetWorkspaceClient(ctx, nil)
helpers := loadHelpers(ctx)
r, err := newRenderer(ctx, nil, helpers, "./testdata/email/template", "./testdata/email/library", tmpDir)
r, err := newRenderer(ctx, nil, helpers, os.DirFS("."), "./testdata/email/template", "./testdata/email/library")
require.NoError(t, err)
err = r.walk()
require.NoError(t, err)
err = r.persistToDisk()
out, err := filer.NewLocalClient(tmpDir)
require.NoError(t, err)
err = r.persistToDisk(ctx, out)
require.NoError(t, err)
b, err := os.ReadFile(filepath.Join(tmpDir, "my_email"))
@ -325,45 +316,34 @@ func TestRendererPersistToDisk(t *testing.T) {
r := &renderer{
ctx: ctx,
instanceRoot: tmpDir,
skipPatterns: []string{"a/b/c", "mn*"},
files: []file{
&inMemoryFile{
dstPath: &destinationPath{
root: tmpDir,
relPath: "a/b/c",
},
perm: 0444,
relPath: "a/b/c",
content: nil,
},
&inMemoryFile{
dstPath: &destinationPath{
root: tmpDir,
relPath: "mno",
},
perm: 0444,
relPath: "mno",
content: nil,
},
&inMemoryFile{
dstPath: &destinationPath{
root: tmpDir,
relPath: "a/b/d",
},
perm: 0444,
relPath: "a/b/d",
content: []byte("123"),
},
&inMemoryFile{
dstPath: &destinationPath{
root: tmpDir,
relPath: "mmnn",
},
perm: 0444,
relPath: "mmnn",
content: []byte("456"),
},
},
}
err := r.persistToDisk()
out, err := filer.NewLocalClient(tmpDir)
require.NoError(t, err)
err = r.persistToDisk(ctx, out)
require.NoError(t, err)
assert.NoFileExists(t, filepath.Join(tmpDir, "a", "b", "c"))
@ -378,10 +358,9 @@ func TestRendererPersistToDisk(t *testing.T) {
func TestRendererWalk(t *testing.T) {
ctx := context.Background()
ctx = root.SetWorkspaceClient(ctx, nil)
tmpDir := t.TempDir()
helpers := loadHelpers(ctx)
r, err := newRenderer(ctx, nil, helpers, "./testdata/walk/template", "./testdata/walk/library", tmpDir)
r, err := newRenderer(ctx, nil, helpers, os.DirFS("."), "./testdata/walk/template", "./testdata/walk/library")
require.NoError(t, err)
err = r.walk()
@ -389,21 +368,12 @@ func TestRendererWalk(t *testing.T) {
getContent := func(r *renderer, path string) string {
for _, f := range r.files {
if f.DstPath().relPath != path {
if f.RelPath() != path {
continue
}
switch v := f.(type) {
case *inMemoryFile:
return strings.Trim(string(v.content), "\r\n")
case *copyFile:
r, err := r.templateFiler.Read(context.Background(), v.srcPath)
require.NoError(t, err)
b, err := io.ReadAll(r)
require.NoError(t, err)
return strings.Trim(string(b), "\r\n")
default:
require.FailNow(t, "execution should not reach here")
}
b, err := f.contents()
require.NoError(t, err)
return strings.Trim(string(b), "\r\n")
}
require.FailNow(t, "file is absent: "+path)
return ""
@ -419,10 +389,9 @@ func TestRendererWalk(t *testing.T) {
func TestRendererFailFunction(t *testing.T) {
ctx := context.Background()
ctx = root.SetWorkspaceClient(ctx, nil)
tmpDir := t.TempDir()
helpers := loadHelpers(ctx)
r, err := newRenderer(ctx, nil, helpers, "./testdata/fail/template", "./testdata/fail/library", tmpDir)
r, err := newRenderer(ctx, nil, helpers, os.DirFS("."), "./testdata/fail/template", "./testdata/fail/library")
require.NoError(t, err)
err = r.walk()
@ -432,10 +401,9 @@ func TestRendererFailFunction(t *testing.T) {
func TestRendererSkipsDirsEagerly(t *testing.T) {
ctx := context.Background()
ctx = root.SetWorkspaceClient(ctx, nil)
tmpDir := t.TempDir()
helpers := loadHelpers(ctx)
r, err := newRenderer(ctx, nil, helpers, "./testdata/skip-dir-eagerly/template", "./testdata/skip-dir-eagerly/library", tmpDir)
r, err := newRenderer(ctx, nil, helpers, os.DirFS("."), "./testdata/skip-dir-eagerly/template", "./testdata/skip-dir-eagerly/library")
require.NoError(t, err)
err = r.walk()
@ -452,7 +420,7 @@ func TestRendererSkipAllFilesInCurrentDirectory(t *testing.T) {
tmpDir := t.TempDir()
helpers := loadHelpers(ctx)
r, err := newRenderer(ctx, nil, helpers, "./testdata/skip-all-files-in-cwd/template", "./testdata/skip-all-files-in-cwd/library", tmpDir)
r, err := newRenderer(ctx, nil, helpers, os.DirFS("."), "./testdata/skip-all-files-in-cwd/template", "./testdata/skip-all-files-in-cwd/library")
require.NoError(t, err)
err = r.walk()
@ -460,7 +428,9 @@ func TestRendererSkipAllFilesInCurrentDirectory(t *testing.T) {
// All 3 files are executed and have in memory representations
require.Len(t, r.files, 3)
err = r.persistToDisk()
out, err := filer.NewLocalClient(tmpDir)
require.NoError(t, err)
err = r.persistToDisk(ctx, out)
require.NoError(t, err)
entries, err := os.ReadDir(tmpDir)
@ -472,10 +442,9 @@ func TestRendererSkipAllFilesInCurrentDirectory(t *testing.T) {
func TestRendererSkipPatternsAreRelativeToFileDirectory(t *testing.T) {
ctx := context.Background()
ctx = root.SetWorkspaceClient(ctx, nil)
tmpDir := t.TempDir()
helpers := loadHelpers(ctx)
r, err := newRenderer(ctx, nil, helpers, "./testdata/skip-is-relative/template", "./testdata/skip-is-relative/library", tmpDir)
r, err := newRenderer(ctx, nil, helpers, os.DirFS("."), "./testdata/skip-is-relative/template", "./testdata/skip-is-relative/library")
require.NoError(t, err)
err = r.walk()
@ -493,7 +462,7 @@ func TestRendererSkip(t *testing.T) {
tmpDir := t.TempDir()
helpers := loadHelpers(ctx)
r, err := newRenderer(ctx, nil, helpers, "./testdata/skip/template", "./testdata/skip/library", tmpDir)
r, err := newRenderer(ctx, nil, helpers, os.DirFS("."), "./testdata/skip/template", "./testdata/skip/library")
require.NoError(t, err)
err = r.walk()
@ -502,7 +471,9 @@ func TestRendererSkip(t *testing.T) {
// This is because "dir2/*" matches the files in dir2, but not dir2 itself
assert.Len(t, r.files, 6)
err = r.persistToDisk()
out, err := filer.NewLocalClient(tmpDir)
require.NoError(t, err)
err = r.persistToDisk(ctx, out)
require.NoError(t, err)
assert.FileExists(t, filepath.Join(tmpDir, "file1"))
@ -520,12 +491,11 @@ func TestRendererReadsPermissionsBits(t *testing.T) {
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
t.SkipNow()
}
tmpDir := t.TempDir()
ctx := context.Background()
ctx = root.SetWorkspaceClient(ctx, nil)
helpers := loadHelpers(ctx)
r, err := newRenderer(ctx, nil, helpers, "./testdata/executable-bit-read/template", "./testdata/executable-bit-read/library", tmpDir)
r, err := newRenderer(ctx, nil, helpers, os.DirFS("."), "./testdata/executable-bit-read/template", "./testdata/executable-bit-read/library")
require.NoError(t, err)
err = r.walk()
@ -533,7 +503,7 @@ func TestRendererReadsPermissionsBits(t *testing.T) {
getPermissions := func(r *renderer, path string) fs.FileMode {
for _, f := range r.files {
if f.DstPath().relPath != path {
if f.RelPath() != path {
continue
}
switch v := f.(type) {
@ -556,6 +526,7 @@ func TestRendererReadsPermissionsBits(t *testing.T) {
func TestRendererErrorOnConflictingFile(t *testing.T) {
tmpDir := t.TempDir()
ctx := context.Background()
f, err := os.Create(filepath.Join(tmpDir, "a"))
require.NoError(t, err)
@ -566,17 +537,16 @@ func TestRendererErrorOnConflictingFile(t *testing.T) {
skipPatterns: []string{},
files: []file{
&inMemoryFile{
dstPath: &destinationPath{
root: tmpDir,
relPath: "a",
},
perm: 0444,
relPath: "a",
content: []byte("123"),
},
},
}
err = r.persistToDisk()
assert.EqualError(t, err, fmt.Sprintf("failed to initialize template, one or more files already exist: %s", filepath.Join(tmpDir, "a")))
out, err := filer.NewLocalClient(tmpDir)
require.NoError(t, err)
err = r.persistToDisk(ctx, out)
assert.EqualError(t, err, fmt.Sprintf("failed to initialize template, one or more files already exist: %s", "a"))
}
func TestRendererNoErrorOnConflictingFileIfSkipped(t *testing.T) {
@ -593,16 +563,15 @@ func TestRendererNoErrorOnConflictingFileIfSkipped(t *testing.T) {
skipPatterns: []string{"a"},
files: []file{
&inMemoryFile{
dstPath: &destinationPath{
root: tmpDir,
relPath: "a",
},
perm: 0444,
relPath: "a",
content: []byte("123"),
},
},
}
err = r.persistToDisk()
out, err := filer.NewLocalClient(tmpDir)
require.NoError(t, err)
err = r.persistToDisk(ctx, out)
// No error is returned even though a conflicting file exists. This is because
// the generated file is being skipped
assert.NoError(t, err)
@ -612,10 +581,9 @@ func TestRendererNoErrorOnConflictingFileIfSkipped(t *testing.T) {
func TestRendererNonTemplatesAreCreatedAsCopyFiles(t *testing.T) {
ctx := context.Background()
ctx = root.SetWorkspaceClient(ctx, nil)
tmpDir := t.TempDir()
helpers := loadHelpers(ctx)
r, err := newRenderer(ctx, nil, helpers, "./testdata/copy-file-walk/template", "./testdata/copy-file-walk/library", tmpDir)
r, err := newRenderer(ctx, nil, helpers, os.DirFS("."), "./testdata/copy-file-walk/template", "./testdata/copy-file-walk/library")
require.NoError(t, err)
err = r.walk()
@ -623,7 +591,7 @@ func TestRendererNonTemplatesAreCreatedAsCopyFiles(t *testing.T) {
assert.Len(t, r.files, 1)
assert.Equal(t, r.files[0].(*copyFile).srcPath, "not-a-template")
assert.Equal(t, r.files[0].DstPath().absPath(), filepath.Join(tmpDir, "not-a-template"))
assert.Equal(t, r.files[0].RelPath(), "not-a-template")
}
func TestRendererFileTreeRendering(t *testing.T) {
@ -635,7 +603,7 @@ func TestRendererFileTreeRendering(t *testing.T) {
r, err := newRenderer(ctx, map[string]any{
"dir_name": "my_directory",
"file_name": "my_file",
}, helpers, "./testdata/file-tree-rendering/template", "./testdata/file-tree-rendering/library", tmpDir)
}, helpers, os.DirFS("."), "./testdata/file-tree-rendering/template", "./testdata/file-tree-rendering/library")
require.NoError(t, err)
err = r.walk()
@ -643,9 +611,11 @@ func TestRendererFileTreeRendering(t *testing.T) {
// Assert in memory representation is created.
assert.Len(t, r.files, 1)
assert.Equal(t, r.files[0].DstPath().absPath(), filepath.Join(tmpDir, "my_directory", "my_file"))
assert.Equal(t, r.files[0].RelPath(), "my_directory/my_file")
err = r.persistToDisk()
out, err := filer.NewLocalClient(tmpDir)
require.NoError(t, err)
err = r.persistToDisk(ctx, out)
require.NoError(t, err)
// Assert files and directories are correctly materialized.
@ -667,8 +637,7 @@ func TestRendererSubTemplateInPath(t *testing.T) {
// https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file.
testutil.Touch(t, filepath.Join(templateDir, "template/{{template `dir_name`}}/{{template `file_name`}}"))
tmpDir := t.TempDir()
r, err := newRenderer(ctx, nil, nil, filepath.Join(templateDir, "template"), filepath.Join(templateDir, "library"), tmpDir)
r, err := newRenderer(ctx, nil, nil, os.DirFS(templateDir), "template", "library")
require.NoError(t, err)
err = r.walk()
@ -676,7 +645,6 @@ func TestRendererSubTemplateInPath(t *testing.T) {
if assert.Len(t, r.files, 2) {
f := r.files[1]
assert.Equal(t, filepath.Join(tmpDir, "my_directory", "my_file"), f.DstPath().absPath())
assert.Equal(t, "my_directory/my_file", f.DstPath().relPath)
assert.Equal(t, "my_directory/my_file", f.RelPath())
}
}