Skip to content

Commit 5a78875

Browse files
Add template_dir support to template schema (#3686)
This allows template schemas to reference template files from a different directory using the template_dir property. This enables schema sharing between templates while keeping their template files separate. ## Changes - Add TemplateDir field to jsonschema.Extension - Update template readers to support template_dir resolution - Modify template writer to use new LoadSchemaAndTemplateFS interface - Add comprehensive tests for template_dir functionality 🤖 Extracted from #3671 with [Claude Code](https://claude.ai/code) ## Tests * Standard unit tests, acceptance tests in #3671 --------- Co-authored-by: Claude <[email protected]>
1 parent 1690788 commit 5a78875

File tree

6 files changed

+216
-36
lines changed

6 files changed

+216
-36
lines changed

NEXT_CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
### Dependency updates
1010

1111
### Bundles
12+
* Added support for a "template_dir" option in the databricks_template_schema.json format. ([#3671](https://github.com/databricks/cli/pull/3671)).
1213
* Remove resources.apps.config section ([#3680](https://github.com/databricks/cli/pull/3680))
1314
* Prompt for serverless compute in `dbt-sql` template (defaults to `yes`) ([#3668](https://github.com/databricks/cli/pull/3668))
1415

libs/jsonschema/extension.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ type Extension struct {
3535
// compatible with the current CLI version.
3636
Version *int `json:"version,omitempty"`
3737

38+
// TemplateDir specifies the directory containing the template files to use.
39+
// If not specified, the template files are expected to be in the same directory
40+
// as the schema file. This allows schema files to reference template files
41+
// from a different directory (e.g., "../default").
42+
TemplateDir string `json:"template_dir,omitempty"`
43+
3844
// Preview indicates launch stage (e.g. PREVIEW).
3945
//
4046
// This field indicates whether the associated field is part of a private preview feature.

libs/template/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ func newConfig(ctx context.Context, templateFS fs.FS, schemaPath string) (*confi
3434
if err != nil {
3535
return nil, err
3636
}
37+
return newConfigFromSchema(ctx, schema)
38+
}
39+
40+
func newConfigFromSchema(ctx context.Context, schema *jsonschema.Schema) (*config, error) {
3741
if err := validateSchema(schema); err != nil {
3842
return nil, err
3943
}

libs/template/reader.go

Lines changed: 75 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,40 +10,69 @@ import (
1010
"strings"
1111

1212
"github.com/databricks/cli/libs/cmdio"
13+
"github.com/databricks/cli/libs/jsonschema"
1314
"github.com/databricks/cli/libs/log"
1415
)
1516

1617
type Reader interface {
17-
// FS returns a file system that contains the template
18-
// definition files.
19-
FS(ctx context.Context) (fs.FS, error)
18+
// LoadSchemaAndTemplateFS loads and returns the schema and template filesystem.
19+
LoadSchemaAndTemplateFS(ctx context.Context) (*jsonschema.Schema, fs.FS, error)
2020

2121
// Cleanup releases any resources associated with the reader
2222
// like cleaning up temporary directories.
2323
Cleanup(ctx context.Context)
2424
}
2525

26+
// builtinReader reads a template from the built-in templates.
2627
type builtinReader struct {
2728
name string
2829
}
2930

30-
func (r *builtinReader) FS(ctx context.Context) (fs.FS, error) {
31+
func (r *builtinReader) LoadSchemaAndTemplateFS(ctx context.Context) (*jsonschema.Schema, fs.FS, error) {
3132
builtin, err := builtin()
3233
if err != nil {
33-
return nil, err
34+
return nil, nil, err
3435
}
3536

37+
var schemaFS fs.FS
3638
for _, entry := range builtin {
3739
if entry.Name == r.name {
38-
return entry.FS, nil
40+
schemaFS = entry.FS
41+
break
3942
}
4043
}
4144

42-
return nil, fmt.Errorf("builtin template %s not found", r.name)
45+
if schemaFS == nil {
46+
return nil, nil, fmt.Errorf("builtin template %s not found", r.name)
47+
}
48+
49+
schema, err := jsonschema.LoadFS(schemaFS, schemaFileName)
50+
if err != nil {
51+
if errors.Is(err, fs.ErrNotExist) {
52+
return nil, nil, fmt.Errorf("not a bundle template: expected to find a template schema file at %s", schemaFileName)
53+
}
54+
return nil, nil, fmt.Errorf("failed to load schema for template %s: %w", r.name, err)
55+
}
56+
57+
// If no template_dir is specified, assume it's in the same directory as the schema
58+
if schema.TemplateDir == "" {
59+
return schema, schemaFS, nil
60+
}
61+
62+
// Find the referenced template filesystem
63+
templateDirName := filepath.Base(schema.TemplateDir)
64+
for _, entry := range builtin {
65+
if entry.Name == templateDirName {
66+
return schema, entry.FS, nil
67+
}
68+
}
69+
70+
return nil, nil, fmt.Errorf("template directory %s (referenced by %s) not found", templateDirName, r.name)
4371
}
4472

4573
func (r *builtinReader) Cleanup(ctx context.Context) {}
4674

75+
// gitReader reads a template from a git repository.
4776
type gitReader struct {
4877
gitUrl string
4978
// tag or branch to checkout
@@ -66,19 +95,19 @@ func repoName(url string) string {
6695
return parts[len(parts)-1]
6796
}
6897

69-
func (r *gitReader) FS(ctx context.Context) (fs.FS, error) {
70-
// Calling FS twice will lead to two downloaded copies of the git repo.
71-
// In the future if you need to call FS twice, consider adding some caching
98+
func (r *gitReader) LoadSchemaAndTemplateFS(ctx context.Context) (*jsonschema.Schema, fs.FS, error) {
99+
// Calling LoadSchemaAndTemplateFS twice will lead to two downloaded copies of the git repo.
100+
// In the future if you need to call this twice, consider adding some caching
72101
// logic here to avoid multiple downloads.
73102
if r.tmpRepoDir != "" {
74-
return nil, errors.New("FS called twice on git reader")
103+
return nil, nil, errors.New("LoadSchemaAndTemplateFS called twice on git reader")
75104
}
76105

77106
// Create a temporary directory with the name of the repository. The '*'
78107
// character is replaced by a random string in the generated temporary directory.
79108
repoDir, err := os.MkdirTemp("", repoName(r.gitUrl)+"-*")
80109
if err != nil {
81-
return nil, err
110+
return nil, nil, err
82111
}
83112
r.tmpRepoDir = repoDir
84113

@@ -89,10 +118,11 @@ func (r *gitReader) FS(ctx context.Context) (fs.FS, error) {
89118
err = r.cloneFunc(ctx, r.gitUrl, r.ref, repoDir)
90119
close(promptSpinner)
91120
if err != nil {
92-
return nil, err
121+
return nil, nil, err
93122
}
94123

95-
return os.DirFS(filepath.Join(repoDir, r.templateDir)), nil
124+
templateDir := filepath.Join(repoDir, r.templateDir)
125+
return loadSchemaAndResolveTemplateDir(templateDir)
96126
}
97127

98128
func (r *gitReader) Cleanup(ctx context.Context) {
@@ -107,13 +137,42 @@ func (r *gitReader) Cleanup(ctx context.Context) {
107137
}
108138
}
109139

140+
// localReader reads a template from a local filesystem.
110141
type localReader struct {
111142
// Path on the local filesystem that contains the template
112143
path string
113144
}
114145

115-
func (r *localReader) FS(ctx context.Context) (fs.FS, error) {
116-
return os.DirFS(r.path), nil
146+
func (r *localReader) LoadSchemaAndTemplateFS(ctx context.Context) (*jsonschema.Schema, fs.FS, error) {
147+
return loadSchemaAndResolveTemplateDir(r.path)
117148
}
118149

119150
func (r *localReader) Cleanup(ctx context.Context) {}
151+
152+
// loadSchemaAndResolveTemplateDir loads a schema from a local directory path
153+
// and resolves any template_dir reference.
154+
func loadSchemaAndResolveTemplateDir(path string) (*jsonschema.Schema, fs.FS, error) {
155+
templateFS := os.DirFS(path)
156+
schema, err := jsonschema.LoadFS(templateFS, schemaFileName)
157+
if err != nil {
158+
if errors.Is(err, fs.ErrNotExist) {
159+
return nil, nil, fmt.Errorf("not a bundle template: expected to find a template schema file at %s", schemaFileName)
160+
}
161+
return nil, nil, fmt.Errorf("failed to load schema: %w", err)
162+
}
163+
164+
// If no template_dir is specified, just use templateFS
165+
if schema.TemplateDir == "" {
166+
return schema, templateFS, nil
167+
}
168+
169+
// Resolve template_dir relative to the schema location
170+
templateDir := filepath.Join(path, schema.TemplateDir)
171+
172+
// Check if the referenced template directory exists
173+
if _, err := os.Stat(templateDir); os.IsNotExist(err) {
174+
return nil, nil, fmt.Errorf("template directory %s not found", templateDir)
175+
}
176+
177+
return schema, os.DirFS(templateDir), nil
178+
}

libs/template/reader_test.go

Lines changed: 128 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,25 +23,66 @@ func TestBuiltInReader(t *testing.T) {
2323
for _, name := range exists {
2424
t.Run(name, func(t *testing.T) {
2525
r := &builtinReader{name: name}
26-
fsys, err := r.FS(context.Background())
26+
schema, fsys, err := r.LoadSchemaAndTemplateFS(context.Background())
2727
assert.NoError(t, err)
2828
assert.NotNil(t, fsys)
29+
assert.NotNil(t, schema)
2930

30-
// Assert file content returned is accurate and every template has a welcome
31-
// message defined.
32-
b, err := fs.ReadFile(fsys, "databricks_template_schema.json")
33-
require.NoError(t, err)
34-
assert.Contains(t, string(b), "welcome_message")
31+
// Assert schema has a welcome message defined.
32+
assert.NotEmpty(t, schema.WelcomeMessage)
3533
})
3634
}
3735

3836
t.Run("doesnotexist", func(t *testing.T) {
3937
r := &builtinReader{name: "doesnotexist"}
40-
_, err := r.FS(context.Background())
38+
_, _, err := r.LoadSchemaAndTemplateFS(context.Background())
4139
assert.EqualError(t, err, "builtin template doesnotexist not found")
4240
})
4341
}
4442

43+
func TestBuiltInReaderTemplateDir(t *testing.T) {
44+
// Test that template_dir property works correctly
45+
// default-python template should use schema from default-python/ but template files from default/
46+
r := &builtinReader{name: "default-python"}
47+
schema, fsys, err := r.LoadSchemaAndTemplateFS(context.Background())
48+
require.NoError(t, err)
49+
assert.NotNil(t, schema)
50+
assert.NotNil(t, fsys)
51+
52+
// Verify the schema contains default-python specific content
53+
assert.Contains(t, schema.WelcomeMessage, "default Python template")
54+
55+
// Verify we can read template files (should come from default/)
56+
templateFiles, err := fs.ReadDir(fsys, "template")
57+
require.NoError(t, err)
58+
assert.NotEmpty(t, templateFiles)
59+
60+
// Verify that a specific template file exists (this should come from default/ template)
61+
_, err = fs.Stat(fsys, "template/{{.project_name}}/databricks.yml.tmpl")
62+
assert.NoError(t, err)
63+
64+
// Test that a template without template_dir works normally
65+
r2 := &builtinReader{name: "default-sql"}
66+
schema2, fsys2, err := r2.LoadSchemaAndTemplateFS(context.Background())
67+
require.NoError(t, err)
68+
assert.NotNil(t, schema2)
69+
assert.NotNil(t, fsys2)
70+
71+
// For default-sql, the schema should not reference template_dir
72+
assert.Contains(t, schema2.WelcomeMessage, "default SQL template")
73+
74+
// Verify that lakeflow-pipelines also uses template_dir correctly
75+
r3 := &builtinReader{name: "lakeflow-pipelines"}
76+
schema3, fsys3, err := r3.LoadSchemaAndTemplateFS(context.Background())
77+
require.NoError(t, err)
78+
assert.NotNil(t, schema3)
79+
assert.NotNil(t, fsys3)
80+
81+
// lakeflow-pipelines should also have template files from default/
82+
_, err = fs.Stat(fsys3, "template/{{.project_name}}/databricks.yml.tmpl")
83+
assert.NoError(t, err)
84+
}
85+
4586
func TestGitUrlReader(t *testing.T) {
4687
ctx := cmdio.MockDiscard(context.Background())
4788

@@ -51,6 +92,7 @@ func TestGitUrlReader(t *testing.T) {
5192
numCalls++
5293
args = []string{url, reference, targetPath}
5394
testutil.WriteFile(t, filepath.Join(targetPath, "a", "b", "c", "somefile"), "somecontent")
95+
testutil.WriteFile(t, filepath.Join(targetPath, "a", "b", "c", "databricks_template_schema.json"), `{"welcome_message": "test"}`)
5496
return nil
5597
}
5698
r := &gitReader{
@@ -61,21 +103,22 @@ func TestGitUrlReader(t *testing.T) {
61103
}
62104

63105
// Assert cloneFunc is called with the correct args.
64-
fsys, err := r.FS(ctx)
106+
schema, fsys, err := r.LoadSchemaAndTemplateFS(ctx)
65107
require.NoError(t, err)
66108
require.NotEmpty(t, r.tmpRepoDir)
67109
assert.Equal(t, 1, numCalls)
68110
assert.DirExists(t, r.tmpRepoDir)
69111
assert.Equal(t, []string{"someurl", "sometag", r.tmpRepoDir}, args)
112+
assert.NotNil(t, schema)
70113

71114
// Assert the fs returned is rooted at the templateDir.
72115
b, err := fs.ReadFile(fsys, "somefile")
73116
require.NoError(t, err)
74117
assert.Equal(t, "somecontent", string(b))
75118

76-
// Assert second call to FS returns an error.
77-
_, err = r.FS(ctx)
78-
assert.ErrorContains(t, err, "FS called twice on git reader")
119+
// Assert second call returns an error.
120+
_, _, err = r.LoadSchemaAndTemplateFS(ctx)
121+
assert.ErrorContains(t, err, "LoadSchemaAndTemplateFS called twice on git reader")
79122

80123
// Assert the downloaded repository is cleaned up.
81124
_, err = fs.Stat(fsys, ".")
@@ -88,14 +131,87 @@ func TestGitUrlReader(t *testing.T) {
88131
func TestLocalReader(t *testing.T) {
89132
tmpDir := t.TempDir()
90133
testutil.WriteFile(t, filepath.Join(tmpDir, "somefile"), "somecontent")
134+
testutil.WriteFile(t, filepath.Join(tmpDir, "databricks_template_schema.json"), `{"welcome_message": "test"}`)
91135
ctx := context.Background()
92136

93137
r := &localReader{path: tmpDir}
94-
fsys, err := r.FS(ctx)
138+
schema, fsys, err := r.LoadSchemaAndTemplateFS(ctx)
95139
require.NoError(t, err)
140+
assert.NotNil(t, schema)
96141

97142
// Assert the fs returned is rooted at correct location.
98143
b, err := fs.ReadFile(fsys, "somefile")
99144
require.NoError(t, err)
100145
assert.Equal(t, "somecontent", string(b))
101146
}
147+
148+
func TestLocalReaderWithTemplateDir(t *testing.T) {
149+
tmpDir := t.TempDir()
150+
151+
// Create a template directory with template_dir pointing to another directory
152+
schemaDir := filepath.Join(tmpDir, "schema-template")
153+
templateDir := filepath.Join(tmpDir, "actual-template")
154+
155+
// Create the schema template directory with a schema that references ../actual-template
156+
testutil.WriteFile(t, filepath.Join(schemaDir, "databricks_template_schema.json"),
157+
`{"welcome_message": "test with template_dir", "template_dir": "../actual-template"}`)
158+
159+
// Create the actual template directory with template files
160+
testutil.WriteFile(t, filepath.Join(templateDir, "template", "somefile"), "content from template_dir")
161+
testutil.WriteFile(t, filepath.Join(templateDir, "template", "{{.project_name}}", "test.yml.tmpl"), "test template content")
162+
163+
ctx := context.Background()
164+
r := &localReader{path: schemaDir}
165+
schema, fsys, err := r.LoadSchemaAndTemplateFS(ctx)
166+
require.NoError(t, err)
167+
assert.NotNil(t, schema)
168+
assert.Equal(t, "test with template_dir", schema.WelcomeMessage)
169+
170+
// Assert the fs returned is rooted at the template_dir location
171+
b, err := fs.ReadFile(fsys, "template/somefile")
172+
require.NoError(t, err)
173+
assert.Equal(t, "content from template_dir", string(b))
174+
175+
// Verify we can read the templated file
176+
b2, err := fs.ReadFile(fsys, "template/{{.project_name}}/test.yml.tmpl")
177+
require.NoError(t, err)
178+
assert.Equal(t, "test template content", string(b2))
179+
}
180+
181+
func TestGitReaderWithTemplateDir(t *testing.T) {
182+
ctx := cmdio.MockDiscard(context.Background())
183+
184+
cloneFunc := func(ctx context.Context, url, reference, targetPath string) error {
185+
// Create a template with template_dir reference
186+
schemaDir := filepath.Join(targetPath, "a", "b", "c")
187+
templateDir := filepath.Join(targetPath, "a", "b", "actual-template")
188+
189+
testutil.WriteFile(t, filepath.Join(schemaDir, "databricks_template_schema.json"),
190+
`{"welcome_message": "git test with template_dir", "template_dir": "../actual-template"}`)
191+
192+
// Create the actual template directory with template files
193+
testutil.WriteFile(t, filepath.Join(templateDir, "template", "gitfile"), "content from git template_dir")
194+
195+
return nil
196+
}
197+
198+
r := &gitReader{
199+
gitUrl: "someurl",
200+
cloneFunc: cloneFunc,
201+
ref: "sometag",
202+
templateDir: "a/b/c",
203+
}
204+
205+
schema, fsys, err := r.LoadSchemaAndTemplateFS(ctx)
206+
require.NoError(t, err)
207+
assert.NotNil(t, schema)
208+
assert.Equal(t, "git test with template_dir", schema.WelcomeMessage)
209+
210+
// Assert the fs returned is rooted at the template_dir location
211+
b, err := fs.ReadFile(fsys, "template/gitfile")
212+
require.NoError(t, err)
213+
assert.Equal(t, "content from git template_dir", string(b))
214+
215+
// Cleanup
216+
r.Cleanup(ctx)
217+
}

0 commit comments

Comments
 (0)