pkg/prompts/prompts.go (250 lines of code) (raw):
package prompts
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"unicode"
"github.com/manifoldco/promptui"
log "github.com/sirupsen/logrus"
"k8s.io/apimachinery/pkg/util/validation"
"github.com/Azure/draft/pkg/config"
)
const defaultAppName = "my-app"
// Function to get current directory name
var getCurrentDirNameFunc = getCurrentDirName
func RunPromptsFromConfig(draftConfig *config.DraftConfig) error {
return RunPromptsFromConfigWithSkips(draftConfig)
}
func RunPromptsFromConfigWithSkips(draftConfig *config.DraftConfig) error {
return RunPromptsFromConfigWithSkipsIO(draftConfig, nil, nil)
}
// RunPromptsFromConfigWithSkipsIO runs the prompts for the given draftConfig
// skipping any variables in varsToSkip or where the BuilderVar.IsPromptDisabled is true.
// If Stdin or Stdout are nil, the default values will be used.
func RunPromptsFromConfigWithSkipsIO(draftConfig *config.DraftConfig, Stdin io.ReadCloser, Stdout io.WriteCloser) error {
if draftConfig == nil {
return errors.New("draftConfig is nil")
}
for _, variable := range draftConfig.Variables {
if variable.Value != "" {
log.Debugf("Skipping prompt for %s", variable.Name)
continue
}
if variable.Default.IsPromptDisabled {
log.Debugf("Skipping prompt for %s as it has IsPromptDisabled=true", variable.Name)
noPromptDefaultValue := GetVariableDefaultValue(draftConfig, variable)
if noPromptDefaultValue == "" {
return fmt.Errorf("IsPromptDisabled is true for %s but no default value was found", variable.Name)
}
log.Debugf("Using default value %s for %s", noPromptDefaultValue, variable.Name)
variable.Value = noPromptDefaultValue
continue
}
isVarActive, err := draftConfig.CheckActiveWhenConstraint(variable)
if err != nil {
return fmt.Errorf("unable to check ActiveWhen constraint: %w", err)
}
if !isVarActive {
continue
}
log.Debugf("constructing prompt for: %s", variable.Name)
if variable.Type == "bool" {
input, err := RunBoolPrompt(variable, Stdin, Stdout)
if err != nil {
return err
}
variable.Value = input
} else {
defaultValue := GetVariableDefaultValue(draftConfig, variable)
stringInput, err := RunDefaultableStringPrompt(defaultValue, variable, nil, Stdin, Stdout)
if err != nil {
return err
}
variable.Value = stringInput
}
}
return nil
}
// GetVariableDefaultValue returns the default value for a variable, if one is set in variableDefaults from a ReferenceVar or literal Variable.DefaultValue in that order.
func GetVariableDefaultValue(draftConfig *config.DraftConfig, variable *config.BuilderVar) string {
defaultValue := ""
if variable.Name == "APPNAME" {
dirName, err := getCurrentDirNameFunc()
if err != nil {
log.Errorf("Error retrieving current directory name: %s", err)
return defaultAppName
}
defaultValue = sanitizeAppName(dirName)
return defaultValue
}
defaultValue = variable.Default.Value
log.Debugf("setting default value for %s to %s from variable default rule", variable.Name, defaultValue)
if variable.Default.ReferenceVar != "" {
if referenceVar, err := draftConfig.GetVariable(variable.Default.ReferenceVar); err != nil {
log.Errorf("Error getting reference variable %s: %s", variable.Default.ReferenceVar, err)
} else if referenceVar.Value != "" {
defaultValue = referenceVar.Value
log.Debugf("setting default value for %s to %s from referenceVar %s", variable.Name, defaultValue, variable.Default.ReferenceVar)
}
}
return defaultValue
}
func RunBoolPrompt(customPrompt *config.BuilderVar, Stdin io.ReadCloser, Stdout io.WriteCloser) (string, error) {
newSelect := &promptui.Select{
Label: "Please select " + customPrompt.Description,
Items: []bool{true, false},
Stdin: Stdin,
Stdout: Stdout,
}
_, input, err := newSelect.Run()
if err != nil {
return "", err
}
return input, nil
}
// AllowAllStringValidator is a string validator that allows any string
func AllowAllStringValidator(_ string) error {
return nil
}
// NoBlankStringValidator is a string validator that does not allow blank strings
func NoBlankStringValidator(s string) error {
if len(s) <= 0 {
return fmt.Errorf("input must be greater than 0")
}
return nil
}
// Validator for App name
func appNameValidator(name string) error {
errors := validation.IsDNS1123Label(name)
if errors != nil {
return fmt.Errorf("invalid app name: %s", strings.Join(errors, ", "))
}
return nil
}
// RunDefaultableStringPrompt runs a prompt for a string variable, returning the user string input for the prompt
func RunDefaultableStringPrompt(defaultValue string, customPrompt *config.BuilderVar, validate func(string) error, Stdin io.ReadCloser, Stdout io.WriteCloser) (string, error) {
if validate == nil {
validate = NoBlankStringValidator
}
validatorFunc := func(input string) error {
// Allow blank inputs because defaults are set later
if input == "" {
return nil
}
if customPrompt.Name == "APPNAME" {
if err := appNameValidator(input); err != nil {
return err
}
} else {
if err := validate(input); err != nil {
return err
}
}
return nil
}
prompt := &promptui.Prompt{
Label: "Please enter " + customPrompt.Description + " (default: " + defaultValue + ")",
Validate: validatorFunc,
Stdin: Stdin,
Stdout: Stdout,
}
input, err := prompt.Run()
if err != nil {
return "", err
}
if input == "" && defaultValue != "" {
input = defaultValue
}
return input, nil
}
func GetInputFromPrompt(desiredInput string) string {
prompt := &promptui.Prompt{
Label: "Please enter " + desiredInput,
Validate: func(s string) error {
if len(s) <= 0 {
return fmt.Errorf("input must be greater than 0")
}
return nil
},
}
input, err := prompt.Run()
if err != nil {
log.Fatal(err)
}
return input
}
type SelectOpt[T any] struct {
// Field returns the name to use for each select item.
Field func(t T) string
// Default is the default selection. If Field is used this should be the result of calling Field on the default.
Default *T
}
func Select[T any](label string, items []T, opt *SelectOpt[T]) (T, error) {
selections := make([]interface{}, len(items))
for i, item := range items {
selections[i] = item
}
if opt != nil && opt.Field != nil {
for i, item := range items {
selections[i] = opt.Field(item)
}
}
if len(selections) == 0 {
return *new(T), errors.New("no selection options")
}
if _, ok := selections[0].(string); !ok {
return *new(T), errors.New("selections must be of type string or use opt.Field")
}
searcher := func(search string, i int) bool {
str, _ := selections[i].(string) // no need to check if okay, we guard earlier
selection := strings.ToLower(str)
search = strings.ToLower(search)
searchWords := strings.Split(search, " ")
for _, word := range searchWords {
if !strings.Contains(selection, word) {
return false
}
}
return true
}
// sort the default selection to top if exists
if opt != nil && opt.Default != nil {
defaultStr := opt.Field(*opt.Default)
for i, selection := range selections {
if defaultStr == selection {
selections[0], selections[i] = selections[i], selections[0]
items[0], items[i] = items[i], items[0]
break
}
}
}
p := promptui.Select{
Label: label,
Items: selections,
Searcher: searcher,
}
i, _, err := p.Run()
if err != nil {
return *new(T), fmt.Errorf("running select: %w", err)
}
if i >= len(items) {
return *new(T), errors.New("items index out of range")
}
return items[i], nil
}
func getCurrentDirName() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("getting current directory: %v", err)
}
dirName := filepath.Base(dir)
return sanitizeAppName(dirName), nil
}
// Sanitize the directory name to comply with k8s label rules
func sanitizeAppName(name string) string {
var builder strings.Builder
// Remove all characters except alphanumeric, '-', '.'
for _, r := range name {
if unicode.IsLetter(r) || unicode.IsDigit(r) || strings.ContainsRune("-.", r) {
builder.WriteRune(r)
}
}
sanitized := builder.String()
if sanitized == "" {
sanitized = defaultAppName
} else {
// Ensure the length does not exceed 63 characters
if len(sanitized) > 63 {
sanitized = sanitized[:63]
}
// Trim leading and trailing '-', '_', '.'
sanitized = strings.Trim(sanitized, "-._")
}
return sanitized
}