package cmd import ( "bufio" "errors" "fmt" "io" "os" "strings" "golang.org/x/term" "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/terminal" "github.com/ory/viper" "github.com/spf13/cobra" "knative.dev/client/pkg/util" fn "knative.dev/kn-plugin-func" "knative.dev/kn-plugin-func/builders" "knative.dev/kn-plugin-func/buildpacks" "knative.dev/kn-plugin-func/docker" "knative.dev/kn-plugin-func/docker/creds" "knative.dev/kn-plugin-func/k8s" "knative.dev/kn-plugin-func/s2i" ) func NewDeployCmd(newClient ClientFactory) *cobra.Command { cmd := &cobra.Command{ Use: "deploy", Short: "Deploy a function", Long: `Deploy a function Builds a container image for the function and deploys it to the connected Knative enabled cluster. The function is picked up from the project in the current directory or from the path provided with --path. If not already configured, either --registry or --image has to be provided and is then stored in the configuration file. If the function is already deployed, it is updated with a new container image that is pushed to an image registry, and finally the function's Knative service is updated. `, Example: ` # Build and deploy the function from the current directory's project. The image will be # pushed to "quay.io/myuser/" and deployed as Knative service with the # same name as the function to the currently connected cluster. {{.Name}} deploy --registry quay.io/myuser # Same as above but using a full image name, that will create a Knative service "myfunc" in # the namespace "myns" {{.Name}} deploy --image quay.io/myuser/myfunc -n myns `, SuggestFor: []string{"delpoy", "deplyo"}, PreRunE: bindEnv("image", "path", "registry", "confirm", "build", "push", "git-url", "git-branch", "git-dir", "builder", "builder-image", "platform"), } cmd.Flags().BoolP("confirm", "c", false, "Prompt to confirm all configuration options (Env: $FUNC_CONFIRM)") cmd.Flags().StringArrayP("env", "e", []string{}, "Environment variable to set in the form NAME=VALUE. "+ "You may provide this flag multiple times for setting multiple environment variables. "+ "To unset, specify the environment variable name followed by a \"-\" (e.g., NAME-).") cmd.Flags().StringP("git-url", "g", "", "Repo url to push the code to be built (Env: $FUNC_GIT_URL)") cmd.Flags().StringP("git-branch", "t", "", "Git branch to be used for remote builds (Env: $FUNC_GIT_BRANCH)") cmd.Flags().StringP("git-dir", "d", "", "Directory in the repo where the function is located (Env: $FUNC_GIT_DIR)") cmd.Flags().StringP("build", "b", fn.DefaultBuildType, fmt.Sprintf("Build specifies the way the function should be built. Supported types are %s (Env: $FUNC_BUILD)", fn.SupportedBuildTypes(true))) // Flags shared with Build specifically related to building: cmd.Flags().StringP("builder", "", builders.Default, fmt.Sprintf("build strategy to use when creating the underlying image. Currently supported build strategies are %s.", KnownBuilders())) cmd.Flags().StringP("builder-image", "", "", "builder image, either an as a an image name or a mapping name.\nSpecified value is stored in func.yaml (as 'builder' field) for subsequent builds. ($FUNC_BUILDER_IMAGE)") cmd.Flags().StringP("image", "i", "", "Full image name in the form [registry]/[namespace]/[name]:[tag]@[digest]. This option takes precedence over --registry. Specifying digest is optional, but if it is given, 'build' and 'push' phases are disabled. (Env: $FUNC_IMAGE)") cmd.Flags().StringP("registry", "r", GetDefaultRegistry(), "Registry + namespace part of the image to build, ex 'quay.io/myuser'. The full image name is automatically determined based on the local directory name. If not provided the registry will be taken from func.yaml (Env: $FUNC_REGISTRY)") cmd.Flags().BoolP("push", "u", true, "Attempt to push the function image to registry before deploying (Env: $FUNC_PUSH)") cmd.Flags().StringP("platform", "", "", "Target platform to build (e.g. linux/amd64).") setPathFlag(cmd) if err := cmd.RegisterFlagCompletionFunc("build", CompleteDeployBuildType); err != nil { fmt.Println("internal: error while calling RegisterFlagCompletionFunc: ", err) } if err := cmd.RegisterFlagCompletionFunc("builder", CompleteBuildersList); err != nil { fmt.Println("internal: error while calling RegisterFlagCompletionFunc: ", err) } if err := cmd.RegisterFlagCompletionFunc("builder-image", CompleteBuilderImageList); err != nil { fmt.Println("internal: error while calling RegisterFlagCompletionFunc: ", err) } cmd.SetHelpFunc(defaultTemplatedHelp) cmd.RunE = func(cmd *cobra.Command, args []string) error { return runDeploy(cmd, args, newClient) } return cmd } func runDeploy(cmd *cobra.Command, _ []string, newClient ClientFactory) (err error) { config, err := newDeployConfig(cmd) if err != nil { return } config, err = config.Prompt() if err != nil { if err == terminal.InterruptErr { return nil } return } //if --image contains '@', validate image digest and disable build and push if not set, otherwise return an error imageSplit := strings.Split(config.Image, "@") imageDigestProvided := false if len(imageSplit) == 2 { if config, err = parseImageDigest(imageSplit, config, cmd); err != nil { return } imageDigestProvided = true } function, err := functionWithOverrides(config.Path, functionOverrides{Namespace: config.Namespace, Image: config.Image}) if err != nil { return } // save image digest if provided in --image if imageDigestProvided { function.ImageDigest = imageSplit[1] } // add ns to func.yaml on first deploy and warn if current context differs from func.yaml function.Namespace, err = checkNamespaceDeploy(function.Namespace, config.Namespace) if err != nil { return } function.Envs, _, err = mergeEnvs(function.Envs, config.EnvToUpdate, config.EnvToRemove) if err != nil { return } currentBuildType := config.BuildType // if build type has been explicitly set as flag, validate it and override function config if config.BuildType != "" { err = validateBuildType(config.BuildType) if err != nil { return err } } else { currentBuildType = function.BuildType } // Check if the function has been initialized if !function.Initialized() { return fmt.Errorf("the given path '%v' does not contain an initialized function. Please create one at this path before deploying", config.Path) } // If the function does not yet have an image name and one was not provided on the command line if function.Image == "" && currentBuildType != "disabled" { // AND a --registry was not provided, then we need to // prompt for a registry from which we can derive an image name. if config.Registry == "" { fmt.Println("A registry for function images is required. For example, 'docker.io/tigerteam'.") err = survey.AskOne( &survey.Input{Message: "Registry for function images:"}, &config.Registry, survey.WithValidator(survey.Required)) if err != nil { if err == terminal.InterruptErr { return nil } return } } // We have the registry, so let's use it to derive the function image name config.Image = deriveImage(config.Image, config.Registry, config.Path) function.Image = config.Image } // Choose a builder based on the value of the --builder flag var builder fn.Builder if function.Builder == "" || cmd.Flags().Changed("builder") { function.Builder = config.Builder } else { config.Builder = function.Builder } if err = ValidateBuilder(config.Builder); err != nil { return err } if config.Builder == builders.Pack { if config.Platform != "" { err = fmt.Errorf("the --platform flag works only with s2i build") return } builder = buildpacks.NewBuilder(buildpacks.WithVerbose(config.Verbose)) } else if config.Builder == builders.S2I { builder = s2i.NewBuilder(s2i.WithVerbose(config.Verbose), s2i.WithPlatform(config.Platform)) } // All set, let's write changes in the config to the disk err = function.Write() if err != nil { return } // Default config namespace is the function's namespace if config.Namespace == "" { config.Namespace = function.Namespace } // if registry was not changed via command line flag meaning it's empty // keep the same registry by setting the config.registry to empty otherwise // trust viper to override the env variable with the given flag if both are specified if regFlag, _ := cmd.Flags().GetString("registry"); regFlag == "" { config.Registry = "" } // Use the user-provided builder image, if supplied if config.BuilderImage != "" { function.BuilderImages[config.Builder] = config.BuilderImage } client, done := newClient(ClientConfig{Namespace: config.Namespace, Verbose: config.Verbose}, fn.WithRegistry(config.Registry), fn.WithBuilder(builder)) defer done() switch currentBuildType { case fn.BuildTypeLocal, "": if config.GitURL != "" || config.GitDir != "" || config.GitBranch != "" { return fmt.Errorf("remote git arguments require the --build=git flag") } if err := client.Build(cmd.Context(), config.Path); err != nil { return err } case fn.BuildTypeGit: git := function.Git if config.GitURL != "" { git.URL = &config.GitURL if strings.Contains(config.GitURL, "#") { parts := strings.Split(config.GitURL, "#") git.URL = &parts[0] git.Revision = &parts[1] } } if config.GitBranch != "" { git.Revision = &config.GitBranch } if config.GitDir != "" { git.ContextDir = &config.GitDir } return client.RunPipeline(cmd.Context(), config.Path, git) case fn.BuildTypeDisabled: // nothing needed to be done for `build=disabled` default: return ErrInvalidBuildType(fmt.Errorf("unknown build type: %s", currentBuildType)) } if config.Push { if err := client.Push(cmd.Context(), config.Path); err != nil { return err } } return client.Deploy(cmd.Context(), config.Path) } func newPromptForCredentials(in io.Reader, out, errOut io.Writer) func(registry string) (docker.Credentials, error) { firstTime := true return func(registry string) (docker.Credentials, error) { var result docker.Credentials if firstTime { firstTime = false fmt.Fprintf(out, "Please provide credentials for image registry (%s).\n", registry) } else { fmt.Fprintln(out, "Incorrect credentials, please try again.") } var qs = []*survey.Question{ { Name: "username", Prompt: &survey.Input{ Message: "Username:", }, Validate: survey.Required, }, { Name: "password", Prompt: &survey.Password{ Message: "Password:", }, Validate: survey.Required, }, } var ( fr terminal.FileReader ok bool ) isTerm := false if fr, ok = in.(terminal.FileReader); ok { isTerm = term.IsTerminal(int(fr.Fd())) } if isTerm { err := survey.Ask(qs, &result, survey.WithStdio(fr, out.(terminal.FileWriter), errOut)) if err != nil { return docker.Credentials{}, err } } else { reader := bufio.NewReader(in) fmt.Fprintf(out, "Username: ") u, err := reader.ReadString('\n') if err != nil { return docker.Credentials{}, err } u = strings.Trim(u, "\r\n") fmt.Fprintf(out, "Password: ") p, err := reader.ReadString('\n') if err != nil { return docker.Credentials{}, err } p = strings.Trim(p, "\r\n") result = docker.Credentials{Username: u, Password: p} } return result, nil } } func newPromptForCredentialStore() creds.ChooseCredentialHelperCallback { return func(availableHelpers []string) (string, error) { if len(availableHelpers) < 1 { fmt.Fprintf(os.Stderr, `Credentials will not be saved. If you would like to save your credentials in the future, you can install docker credential helper https://github.com/docker/docker-credential-helpers. `) return "", nil } isTerm := term.IsTerminal(int(os.Stdin.Fd())) var resp string if isTerm { err := survey.AskOne(&survey.Select{ Message: "Choose credentials helper", Options: append(availableHelpers, "None"), }, &resp, survey.WithValidator(survey.Required)) if err != nil { return "", err } if resp == "None" { fmt.Fprintf(os.Stderr, "No helper selected. Credentials will not be saved.\n") return "", nil } } else { fmt.Fprintf(os.Stderr, "Available credential helpers:\n") for _, helper := range availableHelpers { fmt.Fprintf(os.Stderr, "%s\n", helper) } fmt.Fprintf(os.Stderr, "Choose credentials helper: ") reader := bufio.NewReader(os.Stdin) var err error resp, err = reader.ReadString('\n') if err != nil { return "", err } resp = strings.Trim(resp, "\r\n") if resp == "" { fmt.Fprintf(os.Stderr, "No helper selected. Credentials will not be saved.\n") } } return resp, nil } } type deployConfig struct { buildConfig // Namespace override for the deployed function. If provided, the // underlying platform will be instructed to deploy the function to the given // namespace (if such a setting is applicable; such as for Kubernetes // clusters). If not provided, the currently configured namespace will be // used. For instance, that which would be used by default by `kubectl` // (~/.kube/config) in the case of Kubernetes. Namespace string // Path of the function implementation on local disk. Defaults to current // working directory of the process. Path string // Verbose logging. Verbose bool // Confirm: confirm values arrived upon from environment plus flags plus defaults, // with interactive prompting (only applicable when attached to a TTY). Confirm bool // Build the associated function before deploying. BuildType string // Push function image to the registry before deploying. Push bool // Envs passed via cmd to be added/updated EnvToUpdate *util.OrderedMap // Envs passed via cmd to removed EnvToRemove []string // Git repo url for remote builds GitURL string // Git branch for remote builds GitBranch string // Directory in the git repo where the function is located GitDir string } // newDeployConfig creates a buildConfig populated from command flags and // environment variables; in that precedence. func newDeployConfig(cmd *cobra.Command) (deployConfig, error) { envToUpdate, envToRemove, err := envFromCmd(cmd) if err != nil { return deployConfig{}, err } // We need to know whether the `build`` flag had been explicitly set, // to distinguish between unset and default value. var buildType string if viper.IsSet("build") { buildType = viper.GetString("build") } return deployConfig{ buildConfig: newBuildConfig(), Namespace: viper.GetString("namespace"), Path: getPathFlag(), Verbose: viper.GetBool("verbose"), // defined on root Confirm: viper.GetBool("confirm"), BuildType: buildType, Push: viper.GetBool("push"), EnvToUpdate: envToUpdate, EnvToRemove: envToRemove, GitURL: viper.GetString("git-url"), GitBranch: viper.GetString("git-branch"), GitDir: viper.GetString("git-dir"), }, nil } // Prompt the user with value of config members, allowing for interaractive changes. // Skipped if not in an interactive terminal (non-TTY), or if --yes (agree to // all prompts) was explicitly set. func (c deployConfig) Prompt() (deployConfig, error) { if !interactiveTerminal() || !c.Confirm { return c, nil } var qs = []*survey.Question{ { Name: "registry", Prompt: &survey.Input{ Message: "Registry for function images:", Default: c.buildConfig.Registry, }, Validate: survey.Required, }, { Name: "namespace", Prompt: &survey.Input{ Message: "Namespace:", Default: c.Namespace, }, }, { Name: "path", Prompt: &survey.Input{ Message: "Project path:", Default: c.Path, }, Validate: survey.Required, }, } answers := struct { Registry string Namespace string Path string }{} err := survey.Ask(qs, &answers) if err != nil { return deployConfig{}, err } dc := deployConfig{ buildConfig: buildConfig{ Registry: answers.Registry, }, Namespace: answers.Namespace, Path: answers.Path, Verbose: c.Verbose, } dc.Image = deriveImage(dc.Image, dc.Registry, dc.Path) return dc, nil } // ErrInvalidBuildType indicates that the passed build type was invalid. type ErrInvalidBuildType error // ValidateBuildType validatest that the input Build type is valid for deploy command func validateBuildType(buildType string) error { if errs := fn.ValidateBuildType(buildType, false, true); len(errs) > 0 { return ErrInvalidBuildType(errors.New(strings.Join(errs, ""))) } return nil } func parseImageDigest(imageSplit []string, config deployConfig, cmd *cobra.Command) (deployConfig, error) { if !strings.HasPrefix(imageSplit[1], "sha256:") { return config, fmt.Errorf("value '%s' in --image has invalid prefix syntax for digest (should be 'sha256:')", config.Image) } if len(imageSplit[1][7:]) != 64 { return config, fmt.Errorf("sha256 hash in '%s' from --image has the wrong length (%d), should be 64", imageSplit[1], len(imageSplit[1][7:])) } // if --build was set but not as 'disabled', return an error if cmd.Flags().Changed("build") && config.BuildType != "disabled" { return config, fmt.Errorf("the --build flag '%s' is not valid when using --image with digest", config.BuildType) } // if the --push flag was set by a user to 'true', return an error if cmd.Flags().Changed("push") && config.Push { return config, fmt.Errorf("the --push flag '%v' is not valid when using --image with digest", config.Push) } fmt.Printf("Deploying existing image with digest %s. Build and push are disabled.\n", imageSplit[1]) config.BuildType = "disabled" config.Push = false config.Image = imageSplit[0] return config, nil } // checkNamespaceDeploy checks current namespace against func.yaml and warns if its different // or sets namespace to be written in func.yaml if its the first deployment func checkNamespaceDeploy(funcNamespace string, confNamespace string) (string, error) { currNamespace, err := k8s.GetNamespace("") if err != nil { return funcNamespace, err } // If ns exists in func.yaml & NOT given via CLI (--namespace flag) & current ns does NOT match func.yaml ns if funcNamespace != "" && confNamespace == "" && (currNamespace != funcNamespace) { fmt.Fprintf(os.Stderr, "Warning: Current namespace '%s' does not match namespace '%s' in func.yaml. Function is deployed at '%s' namespace\n", currNamespace, funcNamespace, funcNamespace) } // Add current namespace to func.yaml if it is NOT set yet & NOT given via --namespace. if funcNamespace == "" { funcNamespace = currNamespace } return funcNamespace, nil }