From 6d842fdbce100836f6972cfb570913bf91c90cc3 Mon Sep 17 00:00:00 2001 From: Anders Rex Date: Fri, 23 Jan 2026 16:17:00 +0200 Subject: [PATCH 1/2] Add render-template-schema command --- cmd/bundle/debug.go | 1 + cmd/bundle/debug/render_template_schema.go | 71 ++++++++ libs/template/reader.go | 51 ++++++ libs/template/resolver.go | 32 ++-- libs/template/resolver_test.go | 31 +++- libs/template/schema_renderer.go | 182 +++++++++++++++++++ libs/template/schema_renderer_test.go | 193 +++++++++++++++++++++ 7 files changed, 541 insertions(+), 20 deletions(-) create mode 100644 cmd/bundle/debug/render_template_schema.go create mode 100644 libs/template/schema_renderer.go create mode 100644 libs/template/schema_renderer_test.go diff --git a/cmd/bundle/debug.go b/cmd/bundle/debug.go index b912e14fe2..2af948ecac 100644 --- a/cmd/bundle/debug.go +++ b/cmd/bundle/debug.go @@ -16,5 +16,6 @@ func newDebugCommand() *cobra.Command { cmd.AddCommand(debug.NewTerraformCommand()) cmd.AddCommand(debug.NewRefSchemaCommand()) cmd.AddCommand(debug.NewStatesCommand()) + cmd.AddCommand(debug.NewRenderTemplateSchemaCommand()) return cmd } diff --git a/cmd/bundle/debug/render_template_schema.go b/cmd/bundle/debug/render_template_schema.go new file mode 100644 index 0000000000..475f73054a --- /dev/null +++ b/cmd/bundle/debug/render_template_schema.go @@ -0,0 +1,71 @@ +package debug + +import ( + "errors" + "fmt" + + "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/template" + "github.com/spf13/cobra" +) + +func NewRenderTemplateSchemaCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "render-template-schema [TEMPLATE_PATH]", + Short: "Render a template schema with provided input values", + Args: root.MaximumNArgs(1), + Hidden: true, + } + + var inputFile string + var templateDir string + var tag string + var branch string + + cmd.Flags().StringVar(&inputFile, "input-file", "", "JSON file containing key value pairs of input parameters required for template schema rendering.") + cmd.Flags().StringVar(&templateDir, "template-dir", "", "Directory path within a Git repository containing the template.") + cmd.Flags().StringVar(&tag, "tag", "", "Git tag to use for template initialization") + cmd.Flags().StringVar(&branch, "branch", "", "Git branch to use for template initialization") + + cmd.PreRunE = root.MustWorkspaceClient + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + if len(args) == 0 { + return errors.New("template path is required") + } + templatePathOrUrl := args[0] + + // Resolve git ref from tag/branch flags + ref := branch + if tag != "" { + ref = tag + } + + // Resolve the template reader + reader, isGitReader := template.ResolveReader(templatePathOrUrl, templateDir, ref) + defer reader.Cleanup(ctx) + + // For git reader, load schema first to initialize the temp directory + if isGitReader { + _, _, err := reader.LoadSchemaAndTemplateFS(ctx) + if err != nil { + return err + } + } + + // Render the schema + result, err := template.RenderSchema(ctx, reader, template.RenderSchemaInput{ + InputFile: inputFile, + }) + if err != nil { + return err + } + + _, err = fmt.Fprintln(cmd.OutOrStdout(), result.Content) + return err + } + + return cmd +} diff --git a/libs/template/reader.go b/libs/template/reader.go index 0289701396..04f90f2ef5 100644 --- a/libs/template/reader.go +++ b/libs/template/reader.go @@ -18,6 +18,10 @@ type Reader interface { // LoadSchemaAndTemplateFS loads and returns the schema and template filesystem. LoadSchemaAndTemplateFS(ctx context.Context) (*jsonschema.Schema, fs.FS, error) + // This may be different from the template FS returned by LoadSchemaAndTemplateFS + // when template_dir is set in the schema. + SchemaFS(ctx context.Context) (fs.FS, error) + // Cleanup releases any resources associated with the reader // like cleaning up temporary directories. Cleanup(ctx context.Context) @@ -70,6 +74,21 @@ func (r *builtinReader) LoadSchemaAndTemplateFS(ctx context.Context) (*jsonschem return nil, nil, fmt.Errorf("template directory %s (referenced by %s) not found", templateDirName, r.name) } +func (r *builtinReader) SchemaFS(ctx context.Context) (fs.FS, error) { + builtin, err := builtin() + if err != nil { + return nil, err + } + + for _, entry := range builtin { + if entry.Name == r.name { + return entry.FS, nil + } + } + + return nil, fmt.Errorf("builtin template %s not found", r.name) +} + func (r *builtinReader) Cleanup(ctx context.Context) {} // gitReader reads a template from a git repository. @@ -87,6 +106,21 @@ type gitReader struct { cloneFunc func(ctx context.Context, url, reference, targetPath string) error } +// NewGitReader creates a new reader for a git repository template. +func NewGitReader(gitUrl, ref, templateDir string, cloneFunc func(ctx context.Context, url, reference, targetPath string) error) Reader { + return &gitReader{ + gitUrl: gitUrl, + ref: ref, + templateDir: templateDir, + cloneFunc: cloneFunc, + } +} + +// NewBuiltinReader creates a new reader for a built-in template. +func NewBuiltinReader(name string) Reader { + return &builtinReader{name: name} +} + // Computes the repo name from the repo URL. Treats the last non empty word // when splitting at '/' as the repo name. For example: for url git@github.com:databricks/cli.git // the name would be "cli.git" @@ -125,6 +159,14 @@ func (r *gitReader) LoadSchemaAndTemplateFS(ctx context.Context) (*jsonschema.Sc return loadSchemaAndResolveTemplateDir(templateDir) } +func (r *gitReader) SchemaFS(ctx context.Context) (fs.FS, error) { + if r.tmpRepoDir == "" { + return nil, errors.New("must call LoadSchemaAndTemplateFS before SchemaFS") + } + templateDir := filepath.Join(r.tmpRepoDir, r.templateDir) + return os.DirFS(templateDir), nil +} + func (r *gitReader) Cleanup(ctx context.Context) { if r.tmpRepoDir == "" { return @@ -143,10 +185,19 @@ type localReader struct { path string } +// NewLocalReader creates a new reader for a local template directory. +func NewLocalReader(path string) Reader { + return &localReader{path: path} +} + func (r *localReader) LoadSchemaAndTemplateFS(ctx context.Context) (*jsonschema.Schema, fs.FS, error) { return loadSchemaAndResolveTemplateDir(r.path) } +func (r *localReader) SchemaFS(ctx context.Context) (fs.FS, error) { + return os.DirFS(r.path), nil +} + func (r *localReader) Cleanup(ctx context.Context) {} // loadSchemaAndResolveTemplateDir loads a schema from a local directory path diff --git a/libs/template/resolver.go b/libs/template/resolver.go index 97cfe4ff6c..260511c2f7 100644 --- a/libs/template/resolver.go +++ b/libs/template/resolver.go @@ -13,7 +13,7 @@ var gitUrlPrefixes = []string{ "git@", } -func isRepoUrl(url string) bool { +func IsRepoUrl(url string) bool { result := false for _, prefix := range gitUrlPrefixes { if strings.HasPrefix(url, prefix) { @@ -24,6 +24,19 @@ func isRepoUrl(url string) bool { return result } +// ResolveReader resolves a template path/URL to a Reader (built-in, git or local) +func ResolveReader(templatePathOrUrl, templateDir, ref string) (Reader, bool) { + if tmpl := GetDatabricksTemplate(TemplateName(templatePathOrUrl)); tmpl != nil { + return tmpl.Reader, false + } + + if IsRepoUrl(templatePathOrUrl) { + return NewGitReader(templatePathOrUrl, ref, templateDir, git.Clone), true + } + + return NewLocalReader(templatePathOrUrl), false +} + type Resolver struct { // One of the following three: // 1. Path to a local template directory. @@ -89,29 +102,16 @@ func (r Resolver) Resolve(ctx context.Context) (*Template, error) { // This reference could be one of: // 1. Path to a local template directory. // 2. URL to a Git repository containing a template. - // - // We resolve the appropriate reader according to the reference provided by the user. if tmpl == nil { + reader, _ := ResolveReader(r.TemplatePathOrUrl, r.TemplateDir, ref) tmpl = &Template{ name: Custom, + Reader: reader, // We use a writer that does not log verbose telemetry for custom templates. // This is important because template definitions can contain PII that we // do not want to centralize. Writer: &defaultWriter{name: Custom}, } - - if isRepoUrl(r.TemplatePathOrUrl) { - tmpl.Reader = &gitReader{ - gitUrl: r.TemplatePathOrUrl, - ref: ref, - templateDir: r.TemplateDir, - cloneFunc: git.Clone, - } - } else { - tmpl.Reader = &localReader{ - path: r.TemplatePathOrUrl, - } - } } err = tmpl.Writer.Configure(ctx, r.ConfigFile, r.OutputDir) if err != nil { diff --git a/libs/template/resolver_test.go b/libs/template/resolver_test.go index 1dee1c45fe..6fbe08c863 100644 --- a/libs/template/resolver_test.go +++ b/libs/template/resolver_test.go @@ -102,9 +102,32 @@ func TestTemplateResolverForCustomPath(t *testing.T) { } func TestBundleInitIsRepoUrl(t *testing.T) { - assert.True(t, isRepoUrl("git@github.com:databricks/cli.git")) - assert.True(t, isRepoUrl("https://github.com/databricks/cli.git")) + assert.True(t, IsRepoUrl("git@github.com:databricks/cli.git")) + assert.True(t, IsRepoUrl("https://github.com/databricks/cli.git")) - assert.False(t, isRepoUrl("./local")) - assert.False(t, isRepoUrl("foo")) + assert.False(t, IsRepoUrl("./local")) + assert.False(t, IsRepoUrl("foo")) +} + +func TestResolveReader(t *testing.T) { + t.Run("builtin template", func(t *testing.T) { + reader, isGit := ResolveReader("default-python", "", "") + assert.False(t, isGit) + assert.Equal(t, &builtinReader{name: "default-python"}, reader) + }) + + t.Run("git URL", func(t *testing.T) { + reader, isGit := ResolveReader("https://github.com/example/repo", "/template", "v1.0") + assert.True(t, isGit) + gitReader := reader.(*gitReader) + assert.Equal(t, "https://github.com/example/repo", gitReader.gitUrl) + assert.Equal(t, "/template", gitReader.templateDir) + assert.Equal(t, "v1.0", gitReader.ref) + }) + + t.Run("local path", func(t *testing.T) { + reader, isGit := ResolveReader("/local/path", "", "") + assert.False(t, isGit) + assert.Equal(t, "/local/path", reader.(*localReader).path) + }) } diff --git a/libs/template/schema_renderer.go b/libs/template/schema_renderer.go new file mode 100644 index 0000000000..eb30888aa1 --- /dev/null +++ b/libs/template/schema_renderer.go @@ -0,0 +1,182 @@ +package template + +import ( + "context" + "encoding/json" + "fmt" + "io/fs" + "os" + "strings" + "text/template" + + "github.com/databricks/cli/libs/jsonschema" +) + +type RenderSchemaInput struct { + // Path to the JSON file containing input values for rendering. + InputFile string +} + +type RenderSchemaResult struct { + // The rendered schema content. + Content string +} + +// RenderSchema renders the databricks_template_schema.json file from the template +// using the provided input values. Only fields that can contain template syntax +// are rendered (welcome_message, success_message, property descriptions, and defaults). +// +// Rendering is done in two passes: +// 1. First pass: Render all default values (in order) and add them to the values map +// 2. Second pass: Render descriptions and other fields using the populated values +func RenderSchema(ctx context.Context, reader Reader, input RenderSchemaInput) (*RenderSchemaResult, error) { + schemaFS, err := reader.SchemaFS(ctx) + if err != nil { + return nil, err + } + + // Load typed schema for ordering + typedSchema, err := jsonschema.LoadFS(schemaFS, schemaFileName) + if err != nil { + return nil, fmt.Errorf("failed to load schema: %w", err) + } + + // Read the raw schema file content (to preserve all fields in output) + schemaContent, err := fs.ReadFile(schemaFS, schemaFileName) + if err != nil { + return nil, fmt.Errorf("failed to read schema file: %w", err) + } + + // Parse the schema as JSON + var schema map[string]any + if err := json.Unmarshal(schemaContent, &schema); err != nil { + return nil, fmt.Errorf("failed to parse schema JSON: %w", err) + } + + // Load input values from file + values := make(map[string]any) + if input.InputFile != "" { + b, err := os.ReadFile(input.InputFile) + if err != nil { + return nil, fmt.Errorf("failed to read input file %s: %w", input.InputFile, err) + } + if err := json.Unmarshal(b, &values); err != nil { + return nil, fmt.Errorf("failed to parse input file %s: %w", input.InputFile, err) + } + } + + // missingkey=zero to allow missing variables + helpers := loadHelpers(ctx) + baseTmpl := template.New("base").Funcs(helpers).Option("missingkey=zero") + + // renderString renders a template string with the current values. + renderString := func(s string) (string, error) { + if s == "" || !strings.Contains(s, "{{") { + return s, nil + } + tmpl, err := baseTmpl.Clone() + if err != nil { + return "", err + } + tmpl, err = tmpl.Parse(s) + if err != nil { + return "", fmt.Errorf("failed to parse template %q: %w", truncate(s, 50), err) + } + var buf strings.Builder + if err := tmpl.Execute(&buf, values); err != nil { + return "", fmt.Errorf("failed to render template %q: %w", truncate(s, 50), err) + } + // Replace with empty string for missing variables + result := strings.ReplaceAll(buf.String(), "", "") + return result, nil + } + + // renderField renders a string field in a map if it exists. + renderField := func(m map[string]any, key, errContext string) error { + val, ok := m[key].(string) + if !ok { + return nil + } + rendered, err := renderString(val) + if err != nil { + if errContext != "" { + return fmt.Errorf("%s: %w", errContext, err) + } + return err + } + m[key] = rendered + return nil + } + + // Get properties from raw JSON and use typed schema for ordering + props, hasProps := schema["properties"].(map[string]any) + if hasProps { + orderedProps := typedSchema.OrderedProperties() + + // First pass: Render default values in order and add to values map + for _, p := range orderedProps { + propName := p.Name + prop, ok := props[propName].(map[string]any) + if !ok { + continue + } + + // Skip if value already provided in input + if _, exists := values[propName]; exists { + continue + } + + // Render default value (only if it's a string) + if def, ok := prop["default"].(string); ok { + rendered, err := renderString(def) + if err != nil { + return nil, fmt.Errorf("property %s default: %w", propName, err) + } + prop["default"] = rendered + // Add to values map for use in subsequent renders + values[propName] = rendered + } else if def, ok := prop["default"]; ok { + // Non-string default (bool, int, etc.) - skip render + values[propName] = def + } + } + + // Second pass: Render descriptions and other fields + for propName, propVal := range props { + prop, ok := propVal.(map[string]any) + if !ok { + continue + } + if err := renderField(prop, "description", "property "+propName); err != nil { + return nil, err + } + if err := renderField(prop, "pattern_match_failure_message", "property "+propName+" pattern_match_failure_message"); err != nil { + return nil, err + } + } + } + + // Render welcome_message and success_message (after properties so we have all values) + if err := renderField(schema, "welcome_message", ""); err != nil { + return nil, err + } + if err := renderField(schema, "success_message", ""); err != nil { + return nil, err + } + + result, err := json.MarshalIndent(schema, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to serialize rendered schema: %w", err) + } + + return &RenderSchemaResult{ + Content: string(result), + }, nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/libs/template/schema_renderer_test.go b/libs/template/schema_renderer_test.go new file mode 100644 index 0000000000..d82f0054b6 --- /dev/null +++ b/libs/template/schema_renderer_test.go @@ -0,0 +1,193 @@ +package template + +import ( + "context" + "path/filepath" + "testing" + + "github.com/databricks/cli/internal/testutil" + "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/databricks-sdk-go" + workspaceConfig "github.com/databricks/databricks-sdk-go/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderSchemaWithLocalTemplate(t *testing.T) { + tmpDir := t.TempDir() + schemaContent := `{ + "welcome_message": "Hello {{.name}}!", + "properties": { + "project_name": { + "type": "string", + "description": "Project name for {{.name}}" + } + } +}` + testutil.WriteFile(t, filepath.Join(tmpDir, "databricks_template_schema.json"), schemaContent) + + inputFile := filepath.Join(tmpDir, "input.json") + testutil.WriteFile(t, inputFile, `{"name": "TestUser"}`) + + ctx := context.Background() + ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{}) + + reader := NewLocalReader(tmpDir) + result, err := RenderSchema(ctx, reader, RenderSchemaInput{ + InputFile: inputFile, + }) + require.NoError(t, err) + + assert.Contains(t, result.Content, `"welcome_message": "Hello TestUser!"`) + assert.Contains(t, result.Content, `"description": "Project name for TestUser"`) +} + +func TestRenderSchemaWithoutInputFile(t *testing.T) { + tmpDir := t.TempDir() + schemaContent := `{ + "welcome_message": "Hello!", + "properties": { + "project_name": { + "type": "string", + "description": "Project name" + } + } +}` + testutil.WriteFile(t, filepath.Join(tmpDir, "databricks_template_schema.json"), schemaContent) + + ctx := context.Background() + ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{}) + + reader := NewLocalReader(tmpDir) + result, err := RenderSchema(ctx, reader, RenderSchemaInput{}) + require.NoError(t, err) + + assert.Contains(t, result.Content, `"welcome_message": "Hello!"`) +} + +func TestRenderSchemaWithMissingVariable(t *testing.T) { + tmpDir := t.TempDir() + + schemaContent := `{ + "welcome_message": "Hello {{.missing_var}}!" +}` + testutil.WriteFile(t, filepath.Join(tmpDir, "databricks_template_schema.json"), schemaContent) + + inputFile := filepath.Join(tmpDir, "input.json") + testutil.WriteFile(t, inputFile, `{}`) + + ctx := context.Background() + ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{}) + + reader := NewLocalReader(tmpDir) + // Missing variables are rendered as empty strings + result, err := RenderSchema(ctx, reader, RenderSchemaInput{ + InputFile: inputFile, + }) + require.NoError(t, err) + assert.Contains(t, result.Content, `"welcome_message": "Hello !"`) +} + +func TestRenderSchemaWithInvalidInputFile(t *testing.T) { + tmpDir := t.TempDir() + + schemaContent := `{"welcome_message": "Hello"}` + testutil.WriteFile(t, filepath.Join(tmpDir, "databricks_template_schema.json"), schemaContent) + + ctx := context.Background() + ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{}) + + reader := NewLocalReader(tmpDir) + _, err := RenderSchema(ctx, reader, RenderSchemaInput{ + InputFile: "/nonexistent/input.json", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read input file") +} + +func TestRenderSchemaWithMalformedInputJson(t *testing.T) { + tmpDir := t.TempDir() + + schemaContent := `{"welcome_message": "Hello"}` + testutil.WriteFile(t, filepath.Join(tmpDir, "databricks_template_schema.json"), schemaContent) + + inputFile := filepath.Join(tmpDir, "input.json") + testutil.WriteFile(t, inputFile, `{invalid json}`) + + ctx := context.Background() + ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{}) + + reader := NewLocalReader(tmpDir) + _, err := RenderSchema(ctx, reader, RenderSchemaInput{ + InputFile: inputFile, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse input file") +} + +func TestRenderSchemaWithBuiltinTemplateFS(t *testing.T) { + ctx := context.Background() + reader := NewBuiltinReader(string(DefaultPython)) + schemaFS, err := reader.SchemaFS(ctx) + require.NoError(t, err) + assert.NotNil(t, schemaFS) +} + +func TestRenderSchemaWithSimpleBuiltinTemplate(t *testing.T) { + tmpDir := t.TempDir() + + schemaContent := `{ + "welcome_message": "Welcome to {{workspace_host}}!", + "properties": { + "project_name": { + "type": "string", + "description": "Project name" + } + } +}` + testutil.WriteFile(t, filepath.Join(tmpDir, "databricks_template_schema.json"), schemaContent) + + ctx := context.Background() + ctx = cmdctx.SetWorkspaceClient(ctx, &databricks.WorkspaceClient{ + Config: &workspaceConfig.Config{ + Host: "https://test.databricks.com", + }, + }) + + reader := NewLocalReader(tmpDir) + result, err := RenderSchema(ctx, reader, RenderSchemaInput{}) + require.NoError(t, err) + + assert.Contains(t, result.Content, "https://test.databricks.com") + assert.Contains(t, result.Content, "welcome_message") +} + +func TestLocalReaderSchemaFS(t *testing.T) { + tmpDir := t.TempDir() + testutil.WriteFile(t, filepath.Join(tmpDir, "test.txt"), "content") + + reader := NewLocalReader(tmpDir) + ctx := context.Background() + + schemaFS, err := reader.SchemaFS(ctx) + require.NoError(t, err) + assert.NotNil(t, schemaFS) +} + +func TestBuiltinReaderSchemaFS(t *testing.T) { + reader := NewBuiltinReader(string(DefaultPython)) + ctx := context.Background() + + fs, err := reader.SchemaFS(ctx) + require.NoError(t, err) + assert.NotNil(t, fs) +} + +func TestBuiltinReaderSchemaFSNotFound(t *testing.T) { + reader := NewBuiltinReader("nonexistent-template") + ctx := context.Background() + + _, err := reader.SchemaFS(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} From 2ecc4a0960ae94d4498ce55c0879ed6c305e23a1 Mon Sep 17 00:00:00 2001 From: Anders Rex Date: Fri, 23 Jan 2026 16:53:35 +0200 Subject: [PATCH 2/2] Comment --- libs/template/resolver.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/template/resolver.go b/libs/template/resolver.go index 260511c2f7..b5a2fa8d88 100644 --- a/libs/template/resolver.go +++ b/libs/template/resolver.go @@ -102,10 +102,12 @@ func (r Resolver) Resolve(ctx context.Context) (*Template, error) { // This reference could be one of: // 1. Path to a local template directory. // 2. URL to a Git repository containing a template. + // + // We resolve the appropriate reader according to the reference provided by the user. if tmpl == nil { reader, _ := ResolveReader(r.TemplatePathOrUrl, r.TemplateDir, ref) tmpl = &Template{ - name: Custom, + name: Custom, Reader: reader, // We use a writer that does not log verbose telemetry for custom templates. // This is important because template definitions can contain PII that we