databricks-cli/libs/template/config.go

267 lines
7.0 KiB
Go

package template
import (
"context"
"errors"
"fmt"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/jsonschema"
"github.com/databricks/cli/libs/log"
"golang.org/x/exp/maps"
)
// The latest template schema version supported by the CLI
const latestSchemaVersion = 1
type retriableError struct {
err error
}
func (e retriableError) Error() string {
return e.err.Error()
}
type config struct {
ctx context.Context
values map[string]any
schema *jsonschema.Schema
}
func newConfig(ctx context.Context, schemaPath string) (*config, error) {
// Read config schema
schema, err := jsonschema.Load(schemaPath)
if err != nil {
return nil, err
}
if err := validateSchema(schema); err != nil {
return nil, err
}
// Validate that all properties have a description
for name, p := range schema.Properties {
if p.Description == "" {
return nil, fmt.Errorf("template property %s is missing a description", name)
}
}
// Do not allow template input variables that are not defined in the schema.
schema.AdditionalProperties = false
// Return config
return &config{
ctx: ctx,
schema: schema,
values: make(map[string]any, 0),
}, nil
}
func validateSchema(schema *jsonschema.Schema) error {
for _, v := range schema.Properties {
if v.Type == jsonschema.ArrayType || v.Type == jsonschema.ObjectType {
return fmt.Errorf("property type %s is not supported by bundle templates", v.Type)
}
}
if schema.Version != nil && *schema.Version > latestSchemaVersion {
return fmt.Errorf("template schema version %d is not supported by this version of the CLI. Please upgrade your CLI to the latest version", *schema.Version)
}
return nil
}
// Reads json file at path and assigns values from the file
func (c *config) assignValuesFromFile(path string) error {
// Load the config file.
configFromFile, err := c.schema.LoadInstance(path)
if err != nil {
return fmt.Errorf("failed to load config from file %s: %w", path, err)
}
// Write configs from the file to the input map, not overwriting any existing
// configurations.
for name, val := range configFromFile {
if _, ok := c.values[name]; ok {
continue
}
c.values[name] = val
}
return nil
}
// Assigns default values from schema to input config map
func (c *config) assignDefaultValues(r *renderer) error {
for name, property := range c.schema.Properties {
// Config already has a value assigned
if _, ok := c.values[name]; ok {
continue
}
// No default value defined for the property
if property.Default == nil {
continue
}
defaultVal, err := property.DefaultString()
if err != nil {
return err
}
defaultVal, err = r.executeTemplate(defaultVal)
if err != nil {
return err
}
defaultValTyped, err := property.ParseString(defaultVal)
if err != nil {
return err
}
c.values[name] = defaultValTyped
}
return nil
}
func (c *config) skipPrompt(p jsonschema.Property, r *renderer) (bool, error) {
// Config already has a value assigned. We don't have to prompt for a user input.
if _, ok := c.values[p.Name]; ok {
return true, nil
}
if p.Schema.SkipPromptIf == nil {
return false, nil
}
var keys []string
for k := range p.Schema.SkipPromptIf.Properties {
keys = append(keys, k)
}
p.Schema.SkipPromptIf.Required = append(keys, p.Schema.SkipPromptIf.Required...)
validationErr := p.Schema.SkipPromptIf.ValidateInstance(c.values)
if validationErr != nil {
return false, nil
}
if p.Schema.Default == nil {
return false, fmt.Errorf("property %s has skip_prompt_if set but no default value", p.Name)
}
// Assign default value to property if we are skipping it.
if p.Schema.Type != jsonschema.StringType {
c.values[p.Name] = p.Schema.Default
return true, nil
}
// Execute the default value as a template and assign it to the property.
var err error
c.values[p.Name], err = r.executeTemplate(p.Schema.Default.(string))
if err != nil {
return false, err
}
return true, nil
}
func (c *config) promptOnce(property *jsonschema.Schema, name, defaultVal, description string) error {
var userInput string
if property.Enum != nil {
// List options for the user to select from
options, err := property.EnumStringSlice()
if err != nil {
return err
}
userInput, err = cmdio.AskSelect(c.ctx, description, options)
if err != nil {
return err
}
} else {
var err error
userInput, err = cmdio.Ask(c.ctx, description, defaultVal)
if err != nil {
return err
}
}
// Convert user input string back to a Go value
var err error
c.values[name], err = property.ParseString(userInput)
if err != nil {
// Show error and retry if validation fails
cmdio.LogString(c.ctx, fmt.Sprintf("Validation failed: %s", err.Error()))
return retriableError{err: err}
}
// Validate the partial config which includes the new value
err = c.schema.ValidateInstance(c.values)
if err != nil {
// Show error and retry if validation fails
cmdio.LogString(c.ctx, fmt.Sprintf("Validation failed: %s", err.Error()))
return retriableError{err: err}
}
return nil
}
// Prompts user for values for properties that do not have a value set yet
func (c *config) promptForValues(r *renderer) error {
for _, p := range c.schema.OrderedProperties() {
name := p.Name
property := p.Schema
// Skip prompting if we can.
skip, err := c.skipPrompt(p, r)
if err != nil {
return err
}
if skip {
continue
}
// Compute default value to display by converting it to a string
var defaultVal string
if property.Default != nil {
defaultValRaw, err := property.DefaultString()
if err != nil {
return err
}
defaultVal, err = r.executeTemplate(defaultValRaw)
if err != nil {
return err
}
}
// Compute description for the prompt
description, err := r.executeTemplate(property.Description)
if err != nil {
return err
}
// We wrap this function in a retry loop to allow retries when the user
// entered value is invalid.
for {
err = c.promptOnce(property, name, defaultVal, description)
if err == nil {
break
}
if !errors.As(err, &retriableError{}) {
return err
}
}
}
return nil
}
// Prompt user for any missing config values. Assign default values if
// terminal is not TTY
func (c *config) promptOrAssignDefaultValues(r *renderer) error {
// TODO: replace with IsPromptSupported call (requires fixing TestAccBundleInitErrorOnUnknownFields test)
if cmdio.IsOutTTY(c.ctx) && cmdio.IsInTTY(c.ctx) && !cmdio.IsGitBash(c.ctx) {
return c.promptForValues(r)
}
log.Debugf(c.ctx, "Terminal is not TTY. Assigning default values to template input parameters")
return c.assignDefaultValues(r)
}
// Validates the configuration. If passes, the configuration is ready to be used
// to initialize the template.
func (c *config) validate() error {
// For final validation, all properties in the JSON schema should have a value defined.
c.schema.Required = maps.Keys(c.schema.Properties)
if err := c.schema.ValidateInstance(c.values); err != nil {
return fmt.Errorf("validation for template input parameters failed. %w", err)
}
return nil
}