cmd/create.go (457 lines of code) (raw):

package cmd import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "github.com/manifoldco/promptui" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "gopkg.in/yaml.v3" "github.com/Azure/draft/pkg/config" dryrunpkg "github.com/Azure/draft/pkg/dryrun" "github.com/Azure/draft/pkg/filematches" "github.com/Azure/draft/pkg/handlers" "github.com/Azure/draft/pkg/linguist" "github.com/Azure/draft/pkg/prompts" "github.com/Azure/draft/pkg/reporeader" "github.com/Azure/draft/pkg/reporeader/readers" "github.com/Azure/draft/pkg/templatewriter" "github.com/Azure/draft/pkg/templatewriter/writers" "github.com/Azure/draft/template" ) // ErrNoLanguageDetected is raised when `draft create` does not detect source // code for linguist to classify, or if there are no packs available for the detected languages. var ErrNoLanguageDetected = errors.New("no supported languages were detected") var flagVariablesMap = make(map[string]string) const LANGUAGE_VARIABLE = "LANGUAGE" const TWO_SPACES = " " // Flag defaults const emptyDefaultFlagValue = "" const currentDirDefaultFlagValue = "." const DOCKERFILES_DIR = "dockerfiles" func listSupportedLanguages() ([]string, error) { var supportedLanguages []string entries, err := template.Templates.ReadDir(DOCKERFILES_DIR) if err != nil { return supportedLanguages, fmt.Errorf("reading supported languages from embedded fs: %w", err) } for _, d := range entries { if d.IsDir() { supportedLanguages = append(supportedLanguages, d.Name()) } } return supportedLanguages, nil } type createCmd struct { lang string dest string deployType string dockerfileOnly bool deploymentOnly bool skipFileDetection bool flagVariables []string createConfigPath string createConfig *CreateConfig templateWriter templatewriter.TemplateWriter templateVariableRecorder config.TemplateVariableRecorder repoReader reporeader.RepoReader } func newCreateCmd() *cobra.Command { cc := &createCmd{} cmd := &cobra.Command{ Use: "create [flags]", Short: "Add minimum required files to the directory", Long: "This command will add the minimum required files to the local directory for your Kubernetes deployment.", RunE: func(cmd *cobra.Command, args []string) error { if err := cc.initConfig(); err != nil { return err } return cc.run() }, } f := cmd.Flags() f.StringVarP(&cc.createConfigPath, "create-config", "c", emptyDefaultFlagValue, "specify the path to the configuration file") f.StringVarP(&cc.lang, "language", "l", emptyDefaultFlagValue, "specify the language used to create the Kubernetes deployment") f.StringVarP(&cc.dest, "destination", "d", currentDirDefaultFlagValue, "specify the path to the project directory") f.StringVarP(&cc.deployType, "deploy-type", "", emptyDefaultFlagValue, "specify deployment type (eg. helm, kustomize, manifests)") f.BoolVar(&cc.dockerfileOnly, "dockerfile-only", false, "only create Dockerfile in the project directory") f.BoolVar(&cc.deploymentOnly, "deployment-only", false, "only create deployment files in the project directory") f.BoolVar(&cc.skipFileDetection, "skip-file-detection", false, "skip file detection step") f.StringArrayVarP(&cc.flagVariables, "variable", "", []string{}, "pass template variables (e.g. --variable PORT=8080 --variable APPNAME=test)") return cmd } func (cc *createCmd) initConfig() error { if cc.createConfigPath != "" { log.Debug("loading config") configBytes, err := os.ReadFile(cc.createConfigPath) if err != nil { return err } var cfg CreateConfig if err = yaml.Unmarshal(configBytes, &cfg); err != nil { return err } cc.createConfig = &cfg return nil } //TODO: create a config for the user and save it for subsequent uses cc.createConfig = &CreateConfig{} return nil } func (cc *createCmd) run() error { log.Debugf("config: %s", cc.createConfigPath) flagVariablesMap = flagVariablesToMap(cc.flagVariables) var dryRunRecorder *dryrunpkg.DryRunRecorder if dryRun { dryRunRecorder = dryrunpkg.NewDryRunRecorder() cc.templateVariableRecorder = dryRunRecorder cc.templateWriter = dryRunRecorder } else { cc.templateWriter = &writers.LocalFSWriter{} } cc.repoReader = &readers.LocalFSReader{} detectedLangDraftConfig, languageName, err := cc.detectLanguage() if err != nil { return err } err = cc.createFiles(detectedLangDraftConfig, languageName) if dryRun { cc.templateVariableRecorder.Record(LANGUAGE_VARIABLE, languageName) dryRunText, err := json.MarshalIndent(dryRunRecorder.DryRunInfo, "", TWO_SPACES) if err != nil { return err } fmt.Println(string(dryRunText)) if dryRunFile != "" { log.Printf("writing dry run info to file %s", dryRunFile) err = os.WriteFile(dryRunFile, dryRunText, 0644) if err != nil { return err } } } return err } // detectLanguage detects the language used in a project destination directory // It returns the DraftConfig for that language and the name of the language func (cc *createCmd) detectLanguage() (*handlers.Template, string, error) { hasGo := false hasGoMod := false var langs []*linguist.Language var err error supportedLanguages, err := listSupportedLanguages() if err != nil { log.Errorf("loading supported languages: %s", err.Error()) } log.Debugf("loaded supported languages: %v", supportedLanguages) if cc.createConfig.LanguageType == "" { if cc.lang != "" { cc.createConfig.LanguageType = cc.lang } else { log.Info("--- Detecting Language ---") langs, err = linguist.ProcessDir(cc.dest) log.Debugf("linguist.ProcessDir(%v) result:\n\nError: %v", cc.dest, err) if err != nil { return nil, "", fmt.Errorf("there was an error detecting the language: %s", err) } if len(langs) == 0 { if !interactive { return nil, "", ErrNoLanguageDetected } langs, err = promptLanguageSelection(supportedLanguages) if err != nil { return nil, "", fmt.Errorf("prompting for language: %w", err) } } for _, lang := range langs { log.Debugf("%s:\t%f (%s)", lang.Language, lang.Percent, lang.Color) // For now let's check here for weird stuff like go module support hasGoMod = true if interactive && lang.Language == "Go" { hasGo = true selection := &promptui.Select{ Label: "Linguist detected Go, do you use Go Modules?", Items: []string{"yes", "no"}, } _, selectResponse, err := selection.Run() if err != nil { return nil, "", err } hasGoMod = strings.EqualFold(selectResponse, "yes") } if interactive && lang.Language == "Java" { selection := &promptui.Select{ Label: "Linguist detected Java, are you using maven or gradle?", Items: []string{"maven", "gradle", "gradlew"}, } _, selectResponse, err := selection.Run() if err != nil { return nil, "", err } if selectResponse == "gradle" { lang.Language = "Gradle" } else if selectResponse == "gradlew" { lang.Language = "Gradlew" } } } log.Debugf("detected %d langs", len(langs)) if len(langs) == 0 { return nil, "", ErrNoLanguageDetected } } } if cc.createConfig.LanguageType != "" { log.Debug("using configuration language") lowerLang := strings.ToLower(cc.createConfig.LanguageType) langDockerfileTemplate, err := handlers.GetTemplate(fmt.Sprintf("dockerfile-%s", lowerLang), "", cc.dest, cc.templateWriter) if err != nil { return nil, "", err } if langDockerfileTemplate == nil { return nil, "", fmt.Errorf("could not find a template for %s", cc.createConfig.LanguageType) } return langDockerfileTemplate, lowerLang, nil } for _, lang := range langs { detectedLang := linguist.Alias(lang) log.Infof("--> Draft detected %s (%f%%)\n", detectedLang.Language, detectedLang.Percent) lowerLang := strings.ToLower(detectedLang.Language) if handlers.IsValidTemplate(fmt.Sprintf("dockerfile-%s", lowerLang)) { if lowerLang == "go" && hasGo && hasGoMod { log.Debug("detected go and go module") lowerLang = "gomodule" } langDockerfileTemplate, err := handlers.GetTemplate(fmt.Sprintf("dockerfile-%s", lowerLang), "", cc.dest, cc.templateWriter) if err != nil { return nil, "", err } if langDockerfileTemplate == nil { return nil, "", fmt.Errorf("could not find a template for detected language %s", detectedLang.Language) } return langDockerfileTemplate, lowerLang, nil } log.Infof("--> Could not find a pack for %s. Trying to find the next likely language match...", detectedLang.Language) } return nil, "", ErrNoLanguageDetected } func (cc *createCmd) generateDockerfile(dockerfileTemplate *handlers.Template, lowerLang string) error { log.Info("--- Dockerfile Creation ---") // Extract language-specific defaults from repo extractedValues, err := dockerfileTemplate.ExtractDefaults(lowerLang, cc.repoReader) if err != nil { return err } // Check for existing duplicate defaults for k, v := range extractedValues { variableExists := false for i, variable := range dockerfileTemplate.Config.Variables { if k == variable.Name { variableExists = true dockerfileTemplate.Config.Variables[i].Default.Value = v break } } if !variableExists { dockerfileTemplate.Config.Variables = append(dockerfileTemplate.Config.Variables, &config.BuilderVar{ Name: k, Default: config.BuilderVarDefault{ Value: v, }, }) } } if cc.createConfig.LanguageVariables != nil || !interactive { dockerfileTemplate.Config.ApplyDefaultVariables() err = validateConfigInputsToPrompts(dockerfileTemplate.Config, cc.createConfig.LanguageVariables) if err != nil { return err } } else { dockerfileTemplate.Config.VariableMapToDraftConfig(flagVariablesMap) if err = prompts.RunPromptsFromConfigWithSkips(dockerfileTemplate.Config); err != nil { return err } } if cc.templateVariableRecorder != nil { for _, variable := range dockerfileTemplate.Config.Variables { cc.templateVariableRecorder.Record(variable.Name, variable.Value) } } if err = dockerfileTemplate.Generate(); err != nil { return fmt.Errorf("there was an error when creating the Dockerfile for language %s: %w", cc.createConfig.LanguageType, err) } log.Info("--> Creating Dockerfile...\n") return nil } func (cc *createCmd) generateDeployment() error { log.Info("--- Deployment File Creation ---") var deployType string var deployTemplate *handlers.Template var err error if !interactive { if cc.createConfig.DeployType == "" { cc.createConfig.DeployType = "manifests" log.Debugf("using default deployment type %s", cc.createConfig.DeployType) } } if cc.createConfig.DeployType != "" { deployType = strings.ToLower(cc.createConfig.DeployType) deployTemplate, err = handlers.GetTemplate(fmt.Sprintf("deployment-%s", deployType), "", cc.dest, cc.templateWriter) if err != nil { return err } if deployTemplate == nil || deployTemplate.Config == nil { return errors.New("invalid deployment type") } deployTemplate.Config.VariableMapToDraftConfig(flagVariablesMap) if !interactive { currentDir, err := os.Getwd() if err != nil { return fmt.Errorf("getting current directory: %w", err) } defaultAppName := fmt.Sprintf("%s-workflow", filepath.Base(currentDir)) if validName, err := ToValidAppName(defaultAppName); err != nil { log.Debugf("unable to convert default app name %q to a valid name: %v", defaultAppName, err) log.Debugf("using default app name %q", "my-app") defaultAppName = "my-app" } else { defaultAppName = validName } appVar, err := deployTemplate.Config.GetVariable("APPNAME") if err != nil || appVar == nil { log.Debugf("unable to get APP_NAME variable: %v", err) } if err == nil { appVar.Default.Value = defaultAppName } deployTemplate.Config.ApplyDefaultVariables() } err = validateConfigInputsToPrompts(deployTemplate.Config, cc.createConfig.DeployVariables) if err != nil { return err } } else { if cc.deployType == "" { selection := &promptui.Select{ Label: "Select k8s Deployment Type", Items: []string{"manifests", "kustomize", "helm"}, } _, deployType, err = selection.Run() if err != nil { return err } } else { deployType = cc.deployType } deployTemplate, err = handlers.GetTemplate(fmt.Sprintf("deployment-%s", deployType), "", cc.dest, cc.templateWriter) if err != nil { return err } if deployTemplate == nil || deployTemplate.Config == nil { return errors.New("invalid deployment type") } deployTemplate.Config.VariableMapToDraftConfig(flagVariablesMap) err = prompts.RunPromptsFromConfigWithSkips(deployTemplate.Config) if err != nil { return err } } if cc.templateVariableRecorder != nil { for _, variable := range deployTemplate.Config.Variables { cc.templateVariableRecorder.Record(variable.Name, variable.Value) } } log.Infof("--> Creating %s Kubernetes resources...\n", deployType) return deployTemplate.Generate() } func (cc *createCmd) createFiles(detectedLangTempalte *handlers.Template, lowerLang string) error { // does no further checks without file detection if cc.dockerfileOnly && cc.deploymentOnly { return errors.New("can only pass in one of --dockerfile-only and --deployment-only") } if cc.skipFileDetection { if !cc.deploymentOnly { err := cc.generateDockerfile(detectedLangTempalte, lowerLang) if err != nil { return err } } if !cc.dockerfileOnly { err := cc.generateDeployment() if err != nil { return err } } return nil } // check if the local directory has dockerfile or charts hasDockerFile, hasDeploymentFiles, err := filematches.SearchDirectory(cc.dest) if err != nil { return err } // prompts user for dockerfile re-creation if hasDockerFile && !cc.deploymentOnly { if !interactive && !cc.skipFileDetection { return fmt.Errorf("dockerfile already exists in the directory '%s', use --skip-file-detection to overwrite", cc.dest) } selection := &promptui.Select{ Label: "We found Dockerfile in the directory, would you like to recreate the Dockerfile?", Items: []string{"yes", "no"}, } _, selectResponse, err := selection.Run() if err != nil { return err } hasDockerFile = strings.EqualFold(selectResponse, "no") } if cc.deploymentOnly { log.Info("--> --deployment-only=true, skipping Dockerfile creation...") } else if hasDockerFile { log.Info("--> Found Dockerfile in local directory, skipping Dockerfile creation...") } else if !cc.deploymentOnly { err := cc.generateDockerfile(detectedLangTempalte, lowerLang) if err != nil { return err } } // prompts user for deployment re-creation if hasDeploymentFiles && !cc.dockerfileOnly { if !interactive && !cc.skipFileDetection { return fmt.Errorf("deployment files already exist in the directory '%s', use --skip-file-detection to overwrite", cc.dest) } selection := &promptui.Select{ Label: "We found deployment files in the directory, would you like to create new deployment files?", Items: []string{"yes", "no"}, } _, selectResponse, err := selection.Run() if err != nil { return err } hasDeploymentFiles = strings.EqualFold(selectResponse, "no") } if cc.dockerfileOnly { log.Info("--> --dockerfile-only=true, skipping deployment file creation...") } else if hasDeploymentFiles { log.Info("--> Found deployment directory in local directory, skipping deployment file creation...") } else if !cc.dockerfileOnly { err := cc.generateDeployment() if err != nil { return err } } log.Info("Draft has successfully created deployment resources for your project 😃") log.Info("Use 'draft setup-gh' to set up Github OIDC.") return nil } func init() { rootCmd.AddCommand(newCreateCmd()) } func validateConfigInputsToPrompts(draftConfig *config.DraftConfig, provided []UserInputs) error { // set inputs to provided values for _, providedVar := range provided { draftConfig.SetVariable(providedVar.Name, providedVar.Value) } return nil } func promptLanguageSelection(supportedLanguages []string) ([]*linguist.Language, error) { selection := &promptui.Select{ Label: "Unable to detect a supported language, please select one:", Items: supportedLanguages, } _, selectResponse, err := selection.Run() if err != nil { return nil, fmt.Errorf("manually selecting language: %w", err) } langs := []*linguist.Language{{Language: selectResponse}} return langs, nil }