Ctrl K
ComunidadGuías…

cli/templatepush.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package cli

import (
	"bufio"
	"bytes"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"slices"
	"strconv"
	"strings"
	"time"

	"github.com/briandowns/spinner"
	"github.com/gohugoio/hugo/parser/pageparser"
	"github.com/google/uuid"
	"github.com/mitchellh/mapstructure"
	"golang.org/x/xerrors"

	"github.com/coder/coder/v2/cli/cliui"
	"github.com/coder/coder/v2/cli/cliutil"
	"github.com/coder/coder/v2/codersdk"
	"github.com/coder/coder/v2/provisionersdk"
	"github.com/coder/pretty"
	"github.com/coder/serpent"
)

func (r *RootCmd) templatePush() *serpent.Command {
	var (
		versionName          string
		provisioner          string
		workdir              string
		variablesFile        string
		commandLineVariables []string
		alwaysPrompt         bool
		provisionerTags      []string
		uploadFlags          templateUploadFlags
		activate             bool
		displayName          string
		icon                 string
		orgContext           = NewOrganizationContext()
	)
	cmd := &serpent.Command{
		Use:   "push [template]",
		Short: "Create or update a template from the current directory or as specified by flag",
		Middleware: serpent.Chain(
			serpent.RequireRangeArgs(0, 1),
		),
		Handler: func(inv *serpent.Invocation) error {
			client, err := r.InitClient(inv)
			if err != nil {
				return err
			}
			uploadFlags.setWorkdir(workdir)

			organization, err := orgContext.Selected(inv, client)
			if err != nil {
				return err
			}

			name, err := uploadFlags.templateName(inv)
			if err != nil {
				return err
			}

			err = codersdk.NameValid(name)
			if err != nil {
				return xerrors.Errorf("template name %q is invalid: %w", name, err)
			}

			if versionName != "" {
				err = codersdk.TemplateVersionNameValid(versionName)
				if err != nil {
					return xerrors.Errorf("template version name %q is invalid: %w", versionName, err)
				}
			}

			var createTemplate bool
			template, err := client.TemplateByName(inv.Context(), organization.ID, name)
			if err != nil {
				var apiError *codersdk.Error
				if errors.As(err, &apiError) && apiError.StatusCode() != http.StatusNotFound {
					return err
				}
				// Template doesn't exist, create it.
				createTemplate = true
			}

			// Attempt to read template metadata from the README.md front matter.
			var tmplMeta templateFrontMatter
			var description string
			if !uploadFlags.stdin(inv) {
				tmplMeta, err = parseREADMEFrontMatter(inv, uploadFlags.directory)
				if err != nil {
					return err
				}

				if displayName == "" && tmplMeta.DisplayName != "" {
					displayName = tmplMeta.DisplayName
				}
				if icon == "" && tmplMeta.Icon != "" {
					icon = tmplMeta.Icon
				}
				if tmplMeta.Description != "" {
					description = tmplMeta.Description
				}
			}

			var tags map[string]string
			// Passing --provisioner-tag="-" allows the user to clear all provisioner tags.
			if len(provisionerTags) == 1 && strings.TrimSpace(provisionerTags[0]) == "-" {
				cliui.Warn(inv.Stderr, "Not reusing provisioner tags from the previous template version.")
				tags = map[string]string{}
			} else {
				tags, err = ParseProvisionerTags(provisionerTags)
				if err != nil {
					return err
				}

				// If user hasn't provided new provisioner tags, inherit ones from the active template version.
				if len(tags) == 0 && template.ActiveVersionID != uuid.Nil {
					templateVersion, err := client.TemplateVersion(inv.Context(), template.ActiveVersionID)
					if err != nil {
						return err
					}
					tags = templateVersion.Job.Tags
					cliui.Info(inv.Stderr, "Re-using provisioner tags from the active template version.")
					cliui.Info(inv.Stderr, "Tip: You can override these tags by passing "+cliui.Code(`--provisioner-tag="key=value"`)+".")
					cliui.Info(inv.Stderr, "     You can also clear all provisioner tags by passing "+cliui.Code(`--provisioner-tag="-"`)+".")
				}
			}

			{ // For clarity, display provisioner tags to the user.
				var tmp []string
				for k, v := range tags {
					if k == provisionersdk.TagScope || k == provisionersdk.TagOwner {
						continue
					}
					tmp = append(tmp, fmt.Sprintf("%s=%q", k, v))
				}
				slices.Sort(tmp)
				tagStr := strings.Join(tmp, " ")
				if len(tmp) == 0 {
					tagStr = "<none>"
				}
				cliui.Info(inv.Stderr, "Provisioner tags: "+cliui.Code(tagStr))
			}

			err = uploadFlags.checkForLockfile(inv)
			if err != nil {
				return xerrors.Errorf("check for lockfile: %w", err)
			}

			message := uploadFlags.templateMessage(inv)

			var varsFiles []string
			if !uploadFlags.stdin(inv) {
				varsFiles, err = codersdk.DiscoverVarsFiles(uploadFlags.directory)
				if err != nil {
					return err
				}

				if len(varsFiles) > 0 {
					_, _ = fmt.Fprintln(inv.Stdout, "Auto-discovered Terraform tfvars files. Make sure to review and clean up any unused files.")
				}
			}

			resp, err := uploadFlags.upload(inv, client)
			if err != nil {
				return err
			}

			userVariableValues, err := codersdk.ParseUserVariableValues(
				varsFiles,
				variablesFile,
				commandLineVariables)
			if err != nil {
				return err
			}

			args := createValidTemplateVersionArgs{
				Message:            message,
				Client:             client,
				Organization:       organization,
				Provisioner:        codersdk.ProvisionerType(provisioner),
				FileID:             resp.ID,
				ProvisionerTags:    tags,
				UserVariableValues: userVariableValues,
			}

			// This ensures the version name is set in the request arguments regardless of whether you're creating a new template or updating an existing one.
			args.Name = versionName
			if !createTemplate {
				args.Template = &template
				args.ReuseParameters = !alwaysPrompt
			}

			job, err := createValidTemplateVersion(inv, args)
			if err != nil {
				return err
			}

			if job.Job.Status != codersdk.ProvisionerJobSucceeded {
				return xerrors.Errorf("job failed: %s", job.Job.Status)
			}

			switch {
			// Create the template now that an initial template version exists
			case createTemplate:
				_, err := client.CreateTemplate(inv.Context(), organization.ID, codersdk.CreateTemplateRequest{
					Name:        name,
					VersionID:   job.ID,
					DisplayName: displayName,
					Description: description,
					Icon:        icon,
				})
				if err != nil {
					return err
				}

				_, _ = fmt.Fprintln(
					inv.Stdout, "\n"+cliui.Wrap(
						"The "+cliui.Keyword(name)+" template has been created at "+cliui.Timestamp(time.Now())+"! "+
							"Developers can provision a workspace with this template using:")+"\n")

			// Set the new template version as the active version for an existing template
			case activate:
				meta := codersdk.UpdateTemplateMeta{}
				shouldUpdateMeta := false

				if displayName != "" {
					meta.DisplayName = &displayName
					shouldUpdateMeta = true
					cliui.Info(inv.Stderr, "updating the display name from README.md front matter")
				}
				if icon != "" {
					meta.Icon = &icon
					shouldUpdateMeta = true
					cliui.Info(inv.Stderr, "updating the icon from README.md front matter")
				}
				if description != "" {
					meta.Description = &description
					shouldUpdateMeta = true
					cliui.Info(inv.Stderr, "updating the description from README.md front matter")
				}

				if shouldUpdateMeta {
					_, err := client.UpdateTemplateMeta(inv.Context(), template.ID, meta)
					if err != nil {
						return xerrors.Errorf("update template metadata from README.md front matter: %w", err)
					}
				}

				err := client.UpdateActiveTemplateVersion(inv.Context(), template.ID, codersdk.UpdateActiveTemplateVersion{
					ID: job.ID,
				})
				if err != nil {
					return err
				}
			}

			_, _ = fmt.Fprintf(inv.Stdout, "Updated version at %s!\n", pretty.Sprint(cliui.DefaultStyles.DateTimeStamp, time.Now().Format(time.Stamp)))
			return nil
		},
	}

	cmd.Options = serpent.OptionSet{
		{
			Flag:        "test.provisioner",
			Description: "Customize the provisioner backend.",
			Default:     "terraform",
			Value:       serpent.StringOf(&provisioner),
			// This is for testing!
			Hidden: true,
		},
		{
			Flag:        "test.workdir",
			Description: "Customize the working directory.",
			Default:     "",
			Value:       serpent.StringOf(&workdir),
			// This is for testing!
			Hidden: true,
		},
		{
			Flag:        "variables-file",
			Description: "Specify a file path with values for Terraform-managed variables.",
			Value:       serpent.StringOf(&variablesFile),
		},
		{
			Flag:        "variable",
			Description: "Specify a set of values for Terraform-managed variables.",
			Value:       serpent.StringArrayOf(&commandLineVariables),
		},
		{
			Flag:        "var",
			Description: "Alias of --variable.",
			Value:       serpent.StringArrayOf(&commandLineVariables),
		},
		{
			Flag:        "provisioner-tag",
			Description: "Specify a set of tags to target provisioner daemons. If you do not specify any tags, the tags from the active template version will be reused, if available. To remove existing tags, use --provisioner-tag=\"-\".",
			Value:       serpent.StringArrayOf(&provisionerTags),
		},
		{
			Flag:        "name",
			Description: "Specify a name for the new template version. It will be automatically generated if not provided.",
			Value:       serpent.StringOf(&versionName),
		},
		{
			Flag:        "always-prompt",
			Description: "Always prompt all parameters. Does not pull parameter values from active template version.",
			Value:       serpent.BoolOf(&alwaysPrompt),
		},
		{
			Flag:        "activate",
			Description: "Whether the new template will be marked active.",
			Default:     "true",
			Value:       serpent.BoolOf(&activate),
		},
		{
			Flag:        "display-name",
			Description: "Specify a display name for the template. Takes precedence over display_name in README.md front matter, if present.",
			Value:       serpent.StringOf(&displayName),
		},
		{
			Flag:        "icon",
			Description: "Specify an icon path or external URL for the template. Takes precedence over icon in README.md front matter, if present.",
			Value:       serpent.StringOf(&icon),
		},
		cliui.SkipPromptOption(),
	}
	cmd.Options = append(cmd.Options, uploadFlags.options()...)
	orgContext.AttachOptions(cmd)
	return cmd
}

type templateUploadFlags struct {
	directory      string
	ignoreLockfile bool
	message        string
}

func (pf *templateUploadFlags) options() []serpent.Option {
	return []serpent.Option{{
		Flag:          "directory",
		FlagShorthand: "d",
		Description:   "Specify the directory to create from, use '-' to read tar from stdin.",
		Default:       ".",
		Value:         serpent.StringOf(&pf.directory),
	}, {
		Flag:        "ignore-lockfile",
		Description: "Ignore warnings about not having a .terraform.lock.hcl file present in the template.",
		Default:     "false",
		Value:       serpent.BoolOf(&pf.ignoreLockfile),
	}, {
		Flag:          "message",
		FlagShorthand: "m",
		Description:   "Specify a message describing the changes in this version of the template. Messages longer than 72 characters will be displayed as truncated.",
		Value:         serpent.StringOf(&pf.message),
	}}
}

func (pf *templateUploadFlags) setWorkdir(wd string) {
	if wd == "" {
		return
	}
	if pf.directory == "" || pf.directory == "." {
		pf.directory = wd
	} else if !filepath.IsAbs(pf.directory) {
		pf.directory = filepath.Join(wd, pf.directory)
	}
}

func (pf *templateUploadFlags) stdin(inv *serpent.Invocation) (out bool) {
	defer func() {
		if out {
			inv.Logger.Info(inv.Context(), "uploading tar read from stdin")
		}
	}()
	// We read a tar from stdin if the directory is "-" or if we're not in a
	// TTY and the directory flag is unset.
	return pf.directory == "-" || (!isTTYIn(inv) && !inv.ParsedFlags().Lookup("directory").Changed)
}

func (pf *templateUploadFlags) upload(inv *serpent.Invocation, client *codersdk.Client) (*codersdk.UploadResponse, error) {
	var content io.Reader
	if pf.stdin(inv) {
		content = inv.Stdin
	} else {
		prettyDir := prettyDirectoryPath(pf.directory)
		_, err := cliui.Prompt(inv, cliui.PromptOptions{
			Text:      fmt.Sprintf("Upload %q?", prettyDir),
			IsConfirm: true,
			Default:   cliui.ConfirmYes,
		})
		if err != nil {
			return nil, err
		}

		pipeReader, pipeWriter := io.Pipe()
		go func() {
			err := provisionersdk.Tar(pipeWriter, inv.Logger, pf.directory, provisionersdk.TemplateArchiveLimit)
			_ = pipeWriter.CloseWithError(err)
		}()
		defer pipeReader.Close()
		content = pipeReader
	}

	spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond)
	spin.Writer = inv.Stdout
	spin.Suffix = pretty.Sprint(cliui.DefaultStyles.Keyword, " Uploading directory...")
	spin.Start()
	defer spin.Stop()

	resp, err := client.Upload(inv.Context(), codersdk.ContentTypeTar, bufio.NewReader(content))
	if err != nil {
		return nil, xerrors.Errorf("upload: %w", err)
	}
	return &resp, nil
}

func (pf *templateUploadFlags) checkForLockfile(inv *serpent.Invocation) error {
	if pf.stdin(inv) || pf.ignoreLockfile {
		// Just assume there's a lockfile if reading from stdin.
		return nil
	}

	hasLockfile, err := provisionersdk.DirHasLockfile(pf.directory)
	if err != nil {
		return xerrors.Errorf("dir has lockfile: %w", err)
	}

	if !hasLockfile {
		cliui.Warn(inv.Stdout, "No .terraform.lock.hcl file found",
			"Coder generates one during import so workspace builds can reuse cached providers, but provider versions may change between pushes.",
			"To pin them, create one by running "+pretty.Sprint(cliui.DefaultStyles.Code, "terraform init")+" in your template directory.",
		)
	}
	return nil
}

func (pf *templateUploadFlags) templateMessage(inv *serpent.Invocation) string {
	title := strings.SplitN(pf.message, "\n", 2)[0]
	if len(title) > 72 {
		cliui.Warn(inv.Stdout, "Template message is longer than 72 characters, it will be displayed as truncated.")
	}
	if title != pf.message {
		cliui.Warn(inv.Stdout, "Template message contains newlines, only the first line will be displayed.")
	}
	if pf.message != "" {
		return pf.message
	}
	return "Uploaded from the CLI"
}

func (pf *templateUploadFlags) templateName(inv *serpent.Invocation) (string, error) {
	args := inv.Args
	if pf.stdin(inv) {
		// Can't infer name from directory if none provided.
		if len(args) == 0 {
			return "", xerrors.New("template name argument must be provided")
		}
		return args[0], nil
	}

	if len(args) > 0 {
		return args[0], nil
	}
	// Have to take absPath to resolve "." and "..".
	absPath, err := filepath.Abs(pf.directory)
	if err != nil {
		return "", err
	}
	// If no name is provided, use the directory name.
	return filepath.Base(absPath), nil
}

type createValidTemplateVersionArgs struct {
	Name         string
	Message      string
	Client       *codersdk.Client
	Organization codersdk.Organization
	Provisioner  codersdk.ProvisionerType
	FileID       uuid.UUID

	// Template is only required if updating a template's active version.
	Template *codersdk.Template
	// ReuseParameters will attempt to reuse params from the Template field
	// before prompting the user. Set to false to always prompt for param
	// values.
	ReuseParameters    bool
	ProvisionerTags    map[string]string
	UserVariableValues []codersdk.VariableValue
}

func createValidTemplateVersion(inv *serpent.Invocation, args createValidTemplateVersionArgs) (*codersdk.TemplateVersion, error) {
	client := args.Client

	req := codersdk.CreateTemplateVersionRequest{
		Name:               args.Name,
		Message:            args.Message,
		StorageMethod:      codersdk.ProvisionerStorageMethodFile,
		FileID:             args.FileID,
		Provisioner:        args.Provisioner,
		ProvisionerTags:    args.ProvisionerTags,
		UserVariableValues: args.UserVariableValues,
	}
	if args.Template != nil {
		req.TemplateID = args.Template.ID
	}
	version, err := client.CreateTemplateVersion(inv.Context(), args.Organization.ID, req)
	if err != nil {
		return nil, err
	}
	cliutil.WarnMatchedProvisioners(inv.Stderr, version.MatchedProvisioners, version.Job)
	err = cliui.ProvisionerJob(inv.Context(), inv.Stdout, cliui.ProvisionerJobOptions{
		Fetch: func() (codersdk.ProvisionerJob, error) {
			version, err := client.TemplateVersion(inv.Context(), version.ID)
			return version.Job, err
		},
		Cancel: func() error {
			return client.CancelTemplateVersion(inv.Context(), version.ID)
		},
		Logs: func() (<-chan codersdk.ProvisionerJobLog, io.Closer, error) {
			return client.TemplateVersionLogsAfter(inv.Context(), version.ID, 0)
		},
	})
	if err != nil {
		var jobErr *cliui.ProvisionerJobError
		if errors.As(err, &jobErr) {
			if codersdk.JobIsMissingRequiredTemplateVariableErrorCode(jobErr.Code) {
				return handleMissingTemplateVariables(inv, args, version.ID)
			}
			if !codersdk.JobIsMissingParameterErrorCode(jobErr.Code) {
				return nil, err
			}
		}
		return nil, err
	}
	version, err = client.TemplateVersion(inv.Context(), version.ID)
	if err != nil {
		return nil, err
	}

	if version.Job.Status != codersdk.ProvisionerJobSucceeded {
		return nil, xerrors.New(version.Job.Error)
	}

	resources, err := client.TemplateVersionResources(inv.Context(), version.ID)
	if err != nil {
		return nil, err
	}

	// Only display the resources on the start transition, to avoid listing them more than once.
	var startResources []codersdk.WorkspaceResource
	for _, r := range resources {
		if r.Transition == codersdk.WorkspaceTransitionStart {
			startResources = append(startResources, r)
		}
	}
	err = cliui.WorkspaceResources(inv.Stdout, startResources, cliui.WorkspaceResourcesOptions{
		HideAgentState: true,
		HideAccess:     true,
		Title:          "Template Preview",
	})
	if err != nil {
		return nil, xerrors.Errorf("preview template resources: %w", err)
	}

	return &version, nil
}

func ParseProvisionerTags(rawTags []string) (map[string]string, error) {
	tags := map[string]string{}
	for _, rawTag := range rawTags {
		parts := strings.SplitN(rawTag, "=", 2)
		if len(parts) < 2 {
			return nil, xerrors.Errorf("invalid tag format for %q. must be key=value", rawTag)
		}
		tags[parts[0]] = parts[1]
	}
	return tags, nil
}

// prettyDirectoryPath returns a prettified path when inside the users
// home directory. Falls back to dir if the users home directory cannot
// discerned. This function calls filepath.Clean on the result.
func prettyDirectoryPath(dir string) string {
	dir = filepath.Clean(dir)
	homeDir, err := os.UserHomeDir()
	if err != nil {
		return dir
	}
	prettyDir := dir
	if strings.HasPrefix(prettyDir, homeDir) {
		prettyDir = strings.TrimPrefix(prettyDir, homeDir)
		prettyDir = "~" + prettyDir
	}
	return prettyDir
}

func handleMissingTemplateVariables(inv *serpent.Invocation, args createValidTemplateVersionArgs, failedVersionID uuid.UUID) (*codersdk.TemplateVersion, error) {
	client := args.Client

	templateVariables, err := client.TemplateVersionVariables(inv.Context(), failedVersionID)
	if err != nil {
		return nil, xerrors.Errorf("fetch template variables: %w", err)
	}

	existingValues := make(map[string]string)
	for _, v := range args.UserVariableValues {
		existingValues[v.Name] = v.Value
	}

	var missingVariables []codersdk.TemplateVersionVariable
	for _, variable := range templateVariables {
		if !variable.Required {
			continue
		}

		if existingValue, exists := existingValues[variable.Name]; exists && existingValue != "" {
			continue
		}

		// Only prompt for variables that don't have a default value or have a redacted default
		// Sensitive variables have a default value of "*redacted*"
		// See: https://github.com/coder/coder/blob/a78790c632974e04babfef6de0e2ddf044787a7a/coderd/provisionerdserver/provisionerdserver.go#L3206
		if variable.DefaultValue == "" || (variable.Sensitive && variable.DefaultValue == "*redacted*") {
			missingVariables = append(missingVariables, variable)
		}
	}

	if len(missingVariables) == 0 {
		return nil, xerrors.New("no missing required variables found")
	}

	_, _ = fmt.Fprintf(inv.Stderr, "Found %d missing required variables:\n", len(missingVariables))
	for _, v := range missingVariables {
		_, _ = fmt.Fprintf(inv.Stderr, "  - %s (%s): %s\n", v.Name, v.Type, v.Description)
	}

	_, _ = fmt.Fprintln(inv.Stderr, "\nThe template requires values for the following variables:")

	var promptedValues []codersdk.VariableValue
	for _, variable := range missingVariables {
		value, err := promptForTemplateVariable(inv, variable)
		if err != nil {
			return nil, xerrors.Errorf("prompt for variable %q: %w", variable.Name, err)
		}
		promptedValues = append(promptedValues, codersdk.VariableValue{
			Name:  variable.Name,
			Value: value,
		})
	}

	combinedValues := codersdk.CombineVariableValues(args.UserVariableValues, promptedValues)

	_, _ = fmt.Fprintln(inv.Stderr, "\nRetrying template build with provided variables...")

	retryArgs := args
	retryArgs.UserVariableValues = combinedValues

	return createValidTemplateVersion(inv, retryArgs)
}

func promptForTemplateVariable(inv *serpent.Invocation, variable codersdk.TemplateVersionVariable) (string, error) {
	displayVariableInfo(inv, variable)

	switch variable.Type {
	case "bool":
		return promptForBoolVariable(inv, variable)
	case "number":
		return promptForNumberVariable(inv, variable)
	default:
		return promptForStringVariable(inv, variable)
	}
}

func displayVariableInfo(inv *serpent.Invocation, variable codersdk.TemplateVersionVariable) {
	_, _ = fmt.Fprintf(inv.Stderr, "var.%s", cliui.Bold(variable.Name))
	if variable.Required {
		_, _ = fmt.Fprint(inv.Stderr, pretty.Sprint(cliui.DefaultStyles.Error, " (required)"))
	}
	if variable.Sensitive {
		_, _ = fmt.Fprint(inv.Stderr, pretty.Sprint(cliui.DefaultStyles.Warn, ", sensitive"))
	}
	_, _ = fmt.Fprintln(inv.Stderr, "")

	if variable.Description != "" {
		_, _ = fmt.Fprintf(inv.Stderr, "  Description: %s\n", variable.Description)
	}
	_, _ = fmt.Fprintf(inv.Stderr, "  Type: %s\n", variable.Type)
	_, _ = fmt.Fprintf(inv.Stderr, "  Current value: %s\n", pretty.Sprint(cliui.DefaultStyles.Placeholder, "<empty>"))
}

func promptForBoolVariable(inv *serpent.Invocation, variable codersdk.TemplateVersionVariable) (string, error) {
	defaultValue := variable.DefaultValue
	if defaultValue == "" {
		defaultValue = "false"
	}

	return cliui.Select(inv, cliui.SelectOptions{
		Options: []string{"true", "false"},
		Default: defaultValue,
		Message: "Select value:",
	})
}

func promptForNumberVariable(inv *serpent.Invocation, variable codersdk.TemplateVersionVariable) (string, error) {
	prompt := "Enter value:"
	if !variable.Required && variable.DefaultValue != "" {
		prompt = fmt.Sprintf("Enter value (default: %q):", variable.DefaultValue)
	}

	return cliui.Prompt(inv, cliui.PromptOptions{
		Text:     prompt,
		Default:  variable.DefaultValue,
		Validate: createVariableValidator(variable),
	})
}

func promptForStringVariable(inv *serpent.Invocation, variable codersdk.TemplateVersionVariable) (string, error) {
	prompt := "Enter value:"
	if !variable.Sensitive {
		if !variable.Required && variable.DefaultValue != "" {
			prompt = fmt.Sprintf("Enter value (default: %q):", variable.DefaultValue)
		}
	}

	return cliui.Prompt(inv, cliui.PromptOptions{
		Text:     prompt,
		Default:  variable.DefaultValue,
		Secret:   variable.Sensitive,
		Validate: createVariableValidator(variable),
	})
}

func createVariableValidator(variable codersdk.TemplateVersionVariable) func(string) error {
	return func(s string) error {
		if variable.Required && s == "" && variable.DefaultValue == "" {
			return xerrors.New("value is required")
		}
		if variable.Type == "number" && s != "" {
			if _, err := strconv.ParseFloat(s, 64); err != nil {
				return xerrors.Errorf("must be a valid number, got: %q", s)
			}
		}
		return nil
	}
}

type templateFrontMatter struct {
	DisplayName string `mapstructure:"display_name"`
	Description string `mapstructure:"description"`
	Icon        string `mapstructure:"icon"`
}

func parseREADMEFrontMatter(inv *serpent.Invocation, dir string) (templateFrontMatter, error) {
	var tmplMeta templateFrontMatter

	content, err := os.ReadFile(filepath.Join(dir, "README.md"))
	if err != nil {
		if !errors.Is(err, os.ErrNotExist) {
			cliui.Warn(inv.Stderr, "Failed to read README.md: "+err.Error())
		}
		return tmplMeta, nil
	}

	parsed, err := pageparser.ParseFrontMatterAndContent(bytes.NewReader(content))
	if err != nil {
		cliui.Warn(inv.Stderr, "Ignoring README.md front matter: "+err.Error())
		return tmplMeta, nil
	}

	// Use mapstructure to decode the map directly into the struct
	if err := mapstructure.Decode(parsed.FrontMatter, &tmplMeta); err != nil {
		cliui.Warn(inv.Stderr, "Failed to decode README.md front matter: "+err.Error())
		return tmplMeta, nil
	}

	return tmplMeta, nil
}