package cmd import ( "errors" "fmt" "os" "strings" "text/tabwriter" "text/template" "github.com/AlecAivazis/survey/v2" "github.com/ory/viper" "github.com/spf13/cobra" fn "knative.dev/kn-plugin-func" "knative.dev/kn-plugin-func/utils" ) func init() { // Add to the root a new "Create" command which obtains an appropriate // instance of fn.Client from the given client creator function. root.AddCommand(NewCreateCmd(newCreateClient)) } // createClientFn is a factory function which returns a Client suitable for // use with the Create command. type createClientFn func(createConfig) *fn.Client // newCreateClient returns an instance of fn.Client for the "Create" command. // The createClientFn is a client factory which creates a new Client for use by // the create command during normal execution (see tests for alternative client // factories which return clients with various mocks). func newCreateClient(cfg createConfig) *fn.Client { return fn.New( fn.WithRepositories(cfg.Repositories), // path to repositories in disk fn.WithRepository(cfg.Repository), // URI of repository override fn.WithVerbose(cfg.Verbose)) // verbose logging } // NewCreateCmd creates a create command using the given client creator. func NewCreateCmd(clientFn createClientFn) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Create a Function Project", Long: ` NAME {{.Prefix}}func create - Create a Function project. SYNOPSIS func create [-l|--language] [-t|--template] [-r|--repository] [-c|--confirm] [-v|--verbose] [path] DESCRIPTION Creates a new Function project. $ {{.Prefix}}func create -l node -t http Creates a Function in the current directory '.' which is written in the language/runtime 'node' and handles HTTP events. If [path] is provided, the Function is initialized at that path, creating the path if necessary. To complete this command interactivly, use --confirm (-c): $ {{.Prefix}}func create -c Available Language Runtimes and Templates: {{ .Options | indent 2 " " | indent 1 "\t" }} To install more language runtimes and their templates see '{{.Prefix}}func repository'. EXAMPLES o Create a Node.js Function (the default language runtime) in the current directory (the default path) which handles http events (the default template). $ {{.Prefix}}func create o Create a Node.js Function in the directory 'myfunc'. $ {{.Prefix}}func create myfunc o Create a Go Function which handles CloudEvents in ./myfunc. $ {{.Prefix}}func create -l go -t cloudevents myfunc `, SuggestFor: []string{"vreate", "creaet", "craete", "new"}, PreRunE: bindEnv("language", "template", "repository", "confirm"), } // Flags cmd.Flags().StringP("language", "l", fn.DefaultRuntime, "Language Runtime (see help text for list) (Env: $FUNC_LANGUAGE)") cmd.Flags().StringP("template", "t", fn.DefaultTemplate, "Function template. (see help text for list) (Env: $FUNC_TEMPLATE)") cmd.Flags().StringP("repository", "r", "", "URI to a Git repository containing the specified template (Env: $FUNC_REPOSITORY)") cmd.Flags().BoolP("confirm", "c", false, "Prompt to confirm all options interactively (Env: $FUNC_CONFIRM)") // Help Action cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { runCreateHelp(cmd, args, clientFn) }) // Run Action cmd.RunE = func(cmd *cobra.Command, args []string) error { return runCreate(cmd, args, clientFn) } // Tab Completion if err := cmd.RegisterFlagCompletionFunc("language", newRuntimeCompletionFunc(clientFn)); err != nil { fmt.Fprintf(os.Stderr, "unable to provide language runtime suggestions: %v", err) } if err := cmd.RegisterFlagCompletionFunc("template", newTemplateCompletionFunc(clientFn)); err != nil { fmt.Fprintf(os.Stderr, "unable to provide template suggestions: %v", err) } return cmd } // Run Create func runCreate(cmd *cobra.Command, args []string, clientFn createClientFn) (err error) { // Config // Create a config based on args. Also uses the clientFn to create a // temporary client for completing options such as available runtimes. cfg, err := newCreateConfig(args, clientFn) if err != nil { return } // Client // From environment variables, flags, arguments, and user prompts if --confirm // (in increasing levels of precidence) client := clientFn(cfg) // Validate - a deeper validation than that which is performed when // instantiating the client with the raw config above. if err = cfg.Validate(client); err != nil { return } // Create err = client.Create(fn.Function{ Name: cfg.Name, Root: cfg.Path, Runtime: cfg.Runtime, Template: cfg.Template, }) if err != nil { return err } // Confirm fmt.Fprintf(cmd.OutOrStderr(), "Created %v Function in %v\n", cfg.Runtime, cfg.Path) return nil } // Run Help func runCreateHelp(cmd *cobra.Command, args []string, clientFn createClientFn) { // Error-tolerant implementataion: // Help can not fail when creating the client config (such as on invalid // flag values) because help text is needed in that situation. Therefore // this implementation must be resilient to cfg zero value. failSoft := func(err error) { if err != nil { fmt.Fprintf(cmd.OutOrStderr(), "error: help text may be partial: %v", err) } } tpl := createHelpTemplate(cmd) cfg, err := newCreateConfig(args, clientFn) failSoft(err) client := clientFn(cfg) options, err := runtimeTemplateOptions(client) // human-friendly failSoft(err) var data = struct { Options string Prefix string }{ Options: options, Prefix: pluginPrefix(), } if err := tpl.Execute(cmd.OutOrStdout(), data); err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "unable to display help text: %v", err) } } type createConfig struct { Path string // Absolute path to Function sourcsource Runtime string // Language Runtime Repository string // Repository URI (overrides builtin and installed) Verbose bool // Verbose output Confirm bool // Confirm values via an interactive prompt // Repositories is an optional path that, if it exists, will be used as a source // for additional template repositories not included in the binary. provided via // env (FUNC_REPOSITORIES), the default location is $XDG_CONFIG_HOME/repositories // ($HOME/.config/func/repositories) Repositories string // Template is the code written into the new Function project, including // an implementation adhering to one of the supported function signatures. // May also include additional configuration settings or examples. // For example, embedded are 'http' for a Function whose function signature // is invoked via straight HTTP requests, or 'events' for a Function which // will be invoked with CloudEvents. These embedded templates contain a // minimum implementation of the signature itself and example tests. Template string // Name of the Function Name string } // newCreateConfig returns a config populated from the current execution context // (args, flags and environment variables) // The client constructor function is used to create a transient client for // accessing things like the current valid templates list, and uses the // current value of the config at time of prompting. func newCreateConfig(args []string, clientFn createClientFn) (cfg createConfig, err error) { var ( path string dirName string absolutePath string repositories string ) if len(args) >= 1 { path = args[0] } // Convert the path to an absolute path, and extract the ending directory name // as the function name. TODO: refactor to be git-like with no name up-front // and set instead as a named one-to-many deploy target. dirName, absolutePath = deriveNameAndAbsolutePathFromPath(path) // Repositories Path // Not exposed as a flag due to potential confusion with the more likely // "repository override" flag, and due to its unlikliness of being needed, but // it is still available as an environment variable. repositories = os.Getenv("FUNC_REPOSITORIES") if repositories == "" { // if no env var provided repositories = repositoriesPath() // use ~/.config/func/repositories } // Config is the final default values based off the execution context. // When prompting, these become the defaults presented. cfg = createConfig{ Name: dirName, // TODO: refactor to be git-like Path: absolutePath, Repositories: repositories, Repository: viper.GetString("repository"), Runtime: viper.GetString("language"), // users refer to it is language Template: viper.GetString("template"), Confirm: viper.GetBool("confirm"), Verbose: viper.GetBool("verbose"), } // If not in confirm/prompting mode, this cfg structure is complete. if !cfg.Confirm { return } // Create a tempoarary client for use by the following prompts to complete // runtime/template suggestions etc client := clientFn(cfg) // IN confirm mode. If also in an interactive terminal, run prompts. if interactiveTerminal() { return cfg.prompt(client) } // Confirming, but noninteractive // Print out the final values as a confirmation. Only show Repository or // Repositories, not both (repository takes precidence) in order to avoid // likely confusion if both are displayed and one is empty. // be removed and both displayed. fmt.Printf("Path: %v\n", cfg.Path) fmt.Printf("Language: %v\n", cfg.Runtime) // users refer to it as language if cfg.Repository != "" { // if an override was provided fmt.Printf("Repository: %v\n", cfg.Repository) // show only the override } else { fmt.Printf("Repositories: %v\n", cfg.Repositories) // or path to installed } fmt.Printf("Template: %v\n", cfg.Template) return } // isValidRuntime determines if the given language runtime is a valid choice. func isValidRuntime(client *fn.Client, runtime string) bool { runtimes, err := client.Runtimes() if err != nil { fmt.Fprintf(os.Stderr, "error checking runtimes: %v\n", err) return false } for _, v := range runtimes { if v == runtime { return true } } return false } // isValidTemplate determines if the given template is valid for the given // runtime. func isValidTemplate(client *fn.Client, runtime, template string) bool { if !isValidRuntime(client, runtime) { return false } templates, err := client.Templates().List(runtime) if err != nil { fmt.Fprintf(os.Stderr, "error checking templates: %v", err) return false } for _, v := range templates { if v == template { return true } } return false } // newInvalidRuntimeError creates an error stating that the given language // is not valid, and a verbose list of valid options. func newInvalidRuntimeError(client *fn.Client, runtime string) error { b := strings.Builder{} fmt.Fprintf(&b, "The language runtime '%v' is not recognized.\n", runtime) fmt.Fprintln(&b, "Available language runtimes are:") runtimes, err := client.Runtimes() if err != nil { return err } for _, v := range runtimes { fmt.Fprintf(&b, " %v\n", v) } return errors.New(b.String()) } // newInvalidTemplateError creates an error stating that the given template // is not available for the given runtime, and a verbose list of valid options. // The runtime is expected to already have been validated. func newInvalidTemplateError(client *fn.Client, runtime, template string) error { b := strings.Builder{} fmt.Fprintf(&b, "The template '%v' was not found for language runtime '%v'.\n", template, runtime) fmt.Fprintln(&b, "Available templates for this language runtime are:") templates, err := client.Templates().List(runtime) if err != nil { return err } for _, v := range templates { fmt.Fprintf(&b, " %v\n", v) } return errors.New(b.String()) } // Validate the current state of the config, returning any errors. // Note this is a deeper validation using a client already configured with a // preliminary config object from flags/config, such that the client instance // can be used to determine possible values for runtime, templates, etc. a // pre-client validation should not be required, as the Client does its own // validation. func (c createConfig) Validate(client *fn.Client) (err error) { // Confirm Name is valid // Note that this is highly constricted, as it must currently adhere to the // naming of a Knative Service, which itself is constrained to a Kubernetes // Service, which itself is constrained to a DNS label (a subdomain). // TODO: refactor to be git-like with no name at time of creation, but rather // with named deployment targets in a one-to-many configuration. dirName, _ := deriveNameAndAbsolutePathFromPath(c.Path) if err = utils.ValidateFunctionName(dirName); err != nil { return } // Validate Runtime and Template Name // // Perhaps additional validation would be of use here in the CLI, but // the client libray itself is ultimately responsible for validating all input // prior to exeuting any requests. // Client validates both language runtime and template exist, defaulting // them if not (to 'node' and 'http', respectively). However, if either of // them are invalid, or the chosen combination does not exist, the error // message is a rather terse one-liner. This is suitable for libraries, but // for a CLI it behooves us to be more verbose, including valid options for // each. So here, we check that the values entered (if any) are both valid // and valid together. if c.Runtime != "" && c.Repository == "" && !isValidRuntime(client, c.Runtime) { return newInvalidRuntimeError(client, c.Runtime) } if c.Template != "" && c.Repository == "" && !isValidTemplate(client, c.Runtime, c.Template) { return newInvalidTemplateError(client, c.Runtime, c.Template) } return } // prompt the user with value of config members, allowing for interactively // mutating the values. The provided clientFn is used to construct a transient // client for use during prompt autocompletion/suggestions (such as suggesting // valid templates) func (c createConfig) prompt(client *fn.Client) (createConfig, error) { var qs []*survey.Question runtimes, err := client.Runtimes() if err != nil { return createConfig{}, err } // First ask for path... qs = []*survey.Question{ { Name: "Path", Prompt: &survey.Input{ Message: "Function Path:", Default: c.Path, }, Validate: func(val interface{}) error { derivedName, _ := deriveNameAndAbsolutePathFromPath(val.(string)) return utils.ValidateFunctionName(derivedName) }, Transform: func(ans interface{}) interface{} { _, absolutePath := deriveNameAndAbsolutePathFromPath(ans.(string)) return absolutePath }, }, { Name: "Runtime", Prompt: &survey.Select{ Message: "Language Runtime:", Options: runtimes, Default: c.Runtime, }, }} if err := survey.Ask(qs, &c); err != nil { return c, err } // Second loop: choose template with autocompletion filtered by chosen runtime qs = []*survey.Question{ { Name: "Template", Prompt: &survey.Input{ Message: "Template:", Default: c.Template, Suggest: func(prefix string) []string { suggestions, err := templatesWithPrefix(prefix, c.Runtime, client) if err != nil { fmt.Fprintf(os.Stderr, "unable to suggest: %v", err) } return suggestions }, }, }, } if err := survey.Ask(qs, &c); err != nil { return c, err } return c, nil } // Tab Completion and Prompt Suggestions Helpers // --------------------------------------------- type flagCompletionFunc func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) func newRuntimeCompletionFunc(clientFn createClientFn) flagCompletionFunc { return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { cfg, err := newCreateConfig(args, clientFn) if err != nil { fmt.Fprintf(os.Stderr, "error creating client config for flag completion: %v", err) } client := clientFn(cfg) return CompleteRuntimeList(cmd, args, toComplete, client) } } func newTemplateCompletionFunc(clientFn createClientFn) flagCompletionFunc { return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { cfg, err := newCreateConfig(args, clientFn) if err != nil { fmt.Fprintf(os.Stderr, "error creating client config for flag completion: %v", err) } client := clientFn(cfg) return CompleteTemplateList(cmd, args, toComplete, client) } } // return templates for language runtime whose full name (including repository) // have the given prefix. func templatesWithPrefix(prefix, runtime string, client *fn.Client) ([]string, error) { var ( suggestions = []string{} templates, err = client.Templates().List(runtime) ) if err != nil { return suggestions, err } for _, template := range templates { if strings.HasPrefix(template, prefix) { suggestions = append(suggestions, template) } } return suggestions, nil } // Template Helpers // --------------- // createHelpTemplate is the template for the create command help func createHelpTemplate(cmd *cobra.Command) *template.Template { body := cmd.Long + "\n\n" + cmd.UsageString() t := template.New("help") fm := template.FuncMap{ "indent": func(i int, c string, v string) string { indentation := strings.Repeat(c, i) return indentation + strings.Replace(v, "\n", "\n"+indentation, -1) }, } t.Funcs(fm) return template.Must(t.Parse(body)) } // runtimeTemplateOptions is a human-friendly table of valid Language Runtime // to Template combinations. func runtimeTemplateOptions(client *fn.Client) (string, error) { runtimes, err := client.Runtimes() if err != nil { return "", err } builder := strings.Builder{} writer := tabwriter.NewWriter(&builder, 0, 0, 3, ' ', 0) fmt.Fprint(writer, "Language\tTemplate\n") fmt.Fprint(writer, "--------\t--------\n") for _, r := range runtimes { templates, err := client.Templates().List(r) if err != nil { return "", err } for _, t := range templates { fmt.Fprintf(writer, "%v\t%v\n", r, t) // write tabbed } } writer.Flush() return builder.String(), nil }