package generator import ( "bytes" "errors" "fmt" "log" "os" "os/exec" "path/filepath" "regexp" "strings" "time" "repo.katenary.io/katenary/katenary/internal/generator/extrafiles" "repo.katenary.io/katenary/katenary/internal/generator/katenaryfile" "repo.katenary.io/katenary/katenary/internal/generator/labels" "repo.katenary.io/katenary/katenary/internal/generator/labels/labelstructs" "repo.katenary.io/katenary/katenary/internal/parser" "repo.katenary.io/katenary/katenary/internal/utils" "github.com/compose-spec/compose-go/types" ) const ingressClassHelp = `# Default value for ingress.class annotation # class: "-" # If the value is "-", controller will not set ingressClassName # If the value is "", Ingress will be set to an empty string, so # controller will use the default value for ingressClass # If the value is specified, controller will set the named class e.g. "nginx" ` const storageClassHelp = `# Storage class to use for PVCs # storageClass: "-" means use default # storageClass: "" means do not specify # storageClass: "foo" means use that storageClass ` const headerHelp = `# This file is autogenerated by katenary # # DO NOT EDIT IT BY HAND UNLESS YOU KNOW WHAT YOU ARE DOING # # If you want to change the content of this file, you should edit the # compose file and run katenary again. # If you need to override some values, you can do it in a override file # and use the -f flag to specify it when running the helm command. ` const imagePullSecretHelp = ` # imagePullSecrets allows you to specify a name of an image pull secret. # You must provide a list of object with the name field set to the name of the # e.g. # pullSecrets: # - name: regcred # You are, for now, responsible for creating the secret. ` const imagePullPolicyHelp = `# imagePullPolicy allows you to specify a policy to cache or always pull an image. # You must provide a string value with one of the following values: # - Always -> will always pull the image # - Never -> will never pull the image, the image should be present on the node # - IfNotPresent -> will pull the image only if it is not present on the node ` const resourceHelp = `# Resources allows you to specify the resource requests and limits for a service. # Resources are used to specify the amount of CPU and memory that # a container needs. # # e.g. # resources: # requests: # memory: "64Mi" # cpu: "250m" # limits: # memory: "128Mi" # cpu: "500m" ` const mainTagAppDoc = `This is the version of the main application. Leave it to blank to use the Chart "AppVersion" value.` var unwantedLines = []string{ "creationTimestamp:", "status:", } var ingressTLSHelp = `# Ingress TLS configuration # If enabled, a secret containing the certificate and the key should be # created by the ingress controller. If the name if emtpy, so the secret # name is generated. You can specify the secret name to use your own secret. ` // keyRegExp checks if the line starts by a # var keyRegExp = regexp.MustCompile(`^\s*[^#]+:.*`) // Convert a compose (docker, podman...) project to a helm chart. // It calls Generate() to generate the chart and then write it to the disk. func Convert(config ConvertOptions, dockerComposeFile ...string) error { var ( templateDir = filepath.Join(config.OutputDir, "templates") helpersPath = filepath.Join(config.OutputDir, "templates", "_helpers.tpl") chartPath = filepath.Join(config.OutputDir, "Chart.yaml") valuesPath = filepath.Join(config.OutputDir, "values.yaml") readmePath = filepath.Join(config.OutputDir, "README.md") notesPath = filepath.Join(templateDir, "NOTES.txt") ) // the current working directory is the directory currentDir, _ := os.Getwd() // go to the root of the project if err := os.Chdir(filepath.Dir(dockerComposeFile[0])); err != nil { fmt.Println(utils.IconFailure, err) return err } defer func() { if err := os.Chdir(currentDir); err != nil { // after the generation, go back to the original directory log.Fatal(err) } }() // repove the directory part of the docker-compose files for i, f := range dockerComposeFile { dockerComposeFile[i] = filepath.Base(f) } // parse the compose files project, err := parser.Parse(config.Profiles, config.EnvFiles, dockerComposeFile...) if err != nil { fmt.Println(err) return err } // check older version of labels if err := checkOldLabels(project); err != nil { fmt.Println(utils.IconFailure, err) return err } // TODO: use katenary.yaml file here to set the labels katenaryfile.OverrideWithConfig(project) if !config.Force { // check if the chart directory exists // if yes, prevent the user from overwriting it and ask for confirmation if _, err := os.Stat(config.OutputDir); err == nil { overwrite := utils.Confirm( "The chart directory "+config.OutputDir+" already exists, do you want to overwrite it?", utils.IconWarning, ) if !overwrite { fmt.Println("Aborting") return nil } } fmt.Println() // clean line } // Build the objects ! chart, err := Generate(project) if err != nil { fmt.Println(err) return err } // if the app version is set from the command line, use it if config.AppVersion != nil { chart.AppVersion = *config.AppVersion } chart.Version = config.ChartVersion // remove the chart directory if it exists os.RemoveAll(config.OutputDir) // create the chart directory if err := os.MkdirAll(templateDir, utils.DirectoryPermission); err != nil { return err } // add icon from the command line if config.Icon != "" { chart.Icon = config.Icon } // write the templates to the disk chart.SaveTemplates(templateDir) // write the Chart.yaml file buildCharYamlFile(chart, project, chartPath) // build and write the values.yaml file buildValues(chart, project, valuesPath) // write the _helpers.tpl to the disk writeContent(helpersPath, []byte(chart.Helper)) // write the readme to the disk readme := extrafiles.ReadMeFile(chart.Name, chart.Description, chart.Values) writeContent(readmePath, []byte(readme)) // get the list of services to write in the notes buildNotesFile(project, notesPath) // call helm update if needed callHelmUpdate(config) return nil } func addChartDoc(values []byte, project *types.Project) []byte { chartDoc := fmt.Sprintf(`# This is the main values.yaml file for the %s chart. # More information can be found in the chart's README.md file. # `, project.Name) lines := strings.Split(string(values), "\n") for i, line := range lines { if regexp.MustCompile(`(?m)^name:`).MatchString(line) { doc := "\n# Name of the chart (required), basically the name of the project.\n" lines[i] = doc + line } else if regexp.MustCompile(`(?m)^version:`).MatchString(line) { doc := "\n# Version of the chart (required)\n" lines[i] = doc + line } else if strings.Contains(line, "appVersion:") { spaces := utils.CountStartingSpaces(line) doc := fmt.Sprintf( "\n%s# Version of the application (required).\n%s# This should be the main application version.\n", strings.Repeat(" ", spaces), strings.Repeat(" ", spaces), ) lines[i] = doc + line } else if strings.Contains(line, "dependencies:") { spaces := utils.CountStartingSpaces(line) doc := fmt.Sprintf("\n"+ "%s# Dependencies are external charts that this chart will depend on.\n"+ "%s# More information can be found in the chart's README.md file.\n", strings.Repeat(" ", spaces), strings.Repeat(" ", spaces), ) lines[i] = doc + line } } return []byte(chartDoc + strings.Join(lines, "\n")) } func addCommentsToValues(values []byte) []byte { lines := strings.Split(string(values), "\n") for i, line := range lines { if strings.Contains(line, "ingress:") { spaces := utils.CountStartingSpaces(line) spacesString := strings.Repeat(" ", spaces) // indent ingressClassHelper comment ingressClassHelp := strings.ReplaceAll(ingressClassHelp, "\n", "\n"+spacesString) ingressClassHelp = strings.TrimRight(ingressClassHelp, " ") ingressClassHelp = spacesString + ingressClassHelp lines[i] = ingressClassHelp + line } } return []byte(strings.Join(lines, "\n")) } func addDependencyDescription(values []byte, dependencies []labelstructs.Dependency) []byte { for _, d := range dependencies { name := d.Name if d.Alias != "" { name = d.Alias } values = regexp.MustCompile( `(?m)^`+name+`:$`, ).ReplaceAll( values, []byte("\n# "+d.Name+" helm dependency configuration\n"+name+":"), ) } return values } // addDescriptions adds the description from the label to the values.yaml file on top // of the service definition. func addDescriptions(values []byte, project types.Project) []byte { for _, service := range project.Services { if description, ok := service.Labels[labels.LabelDescription]; ok { // set it as comment description = "\n# " + strings.ReplaceAll(description, "\n", "\n# ") values = regexp.MustCompile( `(?m)^`+service.Name+`:$`, ).ReplaceAll(values, []byte(description+"\n"+service.Name+":")) } else { // set it as comment description = "\n# " + service.Name + " configuration" values = regexp.MustCompile( `(?m)^`+service.Name+`:$`, ).ReplaceAll( values, []byte(description+"\n"+service.Name+":"), ) } } return values } func addDocToVariable(service types.ServiceConfig, lines []string) []string { currentService := "" variables := utils.GetValuesFromLabel(service, labels.LabelValues) for i, line := range lines { // if the line is a service, it is a name followed by a colon if regexp.MustCompile(`(?m)^` + service.Name + `:`).MatchString(line) { currentService = service.Name } // for each variable in the service, add the description for varname, variable := range variables { if variable == nil { continue } spaces := utils.CountStartingSpaces(line) if regexp.MustCompile(`(?m)\s*`+varname+`:`).MatchString(line) && currentService == service.Name { // add # to the beginning of the Description doc := strings.ReplaceAll("\n"+variable.Description, "\n", "\n"+strings.Repeat(" ", spaces)+"# ") doc = strings.TrimRight(doc, " ") doc += "\n" + line lines[i] = doc } } } return lines } func addImagePullPolicyHelp(values []byte) []byte { // add imagePullPolicy help lines := strings.Split(string(values), "\n") for i, line := range lines { if strings.Contains(line, "imagePullPolicy:") { spaces := utils.CountStartingSpaces(line) spacesString := strings.Repeat(" ", spaces) // indent imagePullPolicyHelp comment imagePullPolicyHelp := strings.ReplaceAll(imagePullPolicyHelp, "\n", "\n"+spacesString) imagePullPolicyHelp = strings.TrimRight(imagePullPolicyHelp, " ") imagePullPolicyHelp = spacesString + imagePullPolicyHelp lines[i] = imagePullPolicyHelp + line } } return []byte(strings.Join(lines, "\n")) } func addImagePullSecretsHelp(values []byte) []byte { // add imagePullSecrets help lines := strings.Split(string(values), "\n") for i, line := range lines { if strings.Contains(line, "pullSecrets:") { spaces := utils.CountStartingSpaces(line) spacesString := strings.Repeat(" ", spaces) // indent imagePullSecretHelp comment imagePullSecretHelp := strings.ReplaceAll(imagePullSecretHelp, "\n", "\n"+spacesString) imagePullSecretHelp = strings.TrimRight(imagePullSecretHelp, " ") imagePullSecretHelp = spacesString + imagePullSecretHelp lines[i] = imagePullSecretHelp + line } } return []byte(strings.Join(lines, "\n")) } func addMainAppDoc(lines []string, service types.ServiceConfig) []string { inService := false inRegistry := false for i, line := range lines { if regexp.MustCompile(`^` + service.Name + `:`).MatchString(line) { inService = true } if inService && regexp.MustCompile(`^\s*repository:.*`).MatchString(line) { inRegistry = true } if inService && inRegistry { if regexp.MustCompile(`^\s*tag: .*`).MatchString(line) { spaces := utils.CountStartingSpaces(line) doc := strings.ReplaceAll(mainTagAppDoc, "\n", "\n"+strings.Repeat(" ", spaces)+"# ") doc = strings.Repeat(" ", spaces) + "# " + doc lines[i] = doc + "\n" + line + "\n" break } } } return lines } func addMainTagAppDoc(values []byte, project *types.Project) []byte { lines := strings.Split(string(values), "\n") for _, service := range project.Services { // read the label LabelMainApp if v, ok := service.Labels[labels.LabelMainApp]; !ok { continue } else if v == "false" || v == "no" || v == "0" { continue } else { fmt.Printf("%s Adding main tag app doc %s\n", utils.IconConfig, service.Name) } lines = addMainAppDoc(lines, service) } return []byte(strings.Join(lines, "\n")) } func addResourceHelp(values []byte) []byte { lines := strings.Split(string(values), "\n") for i, line := range lines { if strings.Contains(line, "resources:") { spaces := utils.CountStartingSpaces(line) spacesString := strings.Repeat(" ", spaces) // indent resourceHelp comment resourceHelp := strings.ReplaceAll(resourceHelp, "\n", "\n"+spacesString) resourceHelp = strings.TrimRight(resourceHelp, " ") resourceHelp = spacesString + resourceHelp lines[i] = resourceHelp + line } } return []byte(strings.Join(lines, "\n")) } // addStorageClassHelp adds a comment to the values.yaml file to explain how to // use the storageClass option. func addStorageClassHelp(values []byte) []byte { lines := strings.Split(string(values), "\n") for i, line := range lines { if strings.Contains(line, "storageClass:") { spaces := utils.CountStartingSpaces(line) spacesString := strings.Repeat(" ", spaces) // indent ingressClassHelper comment storageClassHelp := strings.ReplaceAll(storageClassHelp, "\n", "\n"+spacesString) storageClassHelp = strings.TrimRight(storageClassHelp, " ") storageClassHelp = spacesString + storageClassHelp lines[i] = storageClassHelp + line } } return []byte(strings.Join(lines, "\n")) } func addVariablesDoc(values []byte, project *types.Project) []byte { lines := strings.Split(string(values), "\n") for _, service := range project.Services { lines = addDocToVariable(service, lines) } return []byte(strings.Join(lines, "\n")) } // addYAMLSelectorPath adds a selector path to the yaml file for each key // as comment. E.g. foo.ingress.host func addYAMLSelectorPath(values []byte) []byte { lines := strings.Split(string(values), "\n") currentKey := "" currentLevel := 0 toReturn := []string{} for _, line := range lines { // if the line is a not a key, continue if !keyRegExp.MatchString(line) { toReturn = append(toReturn, line) continue } // get the key key := strings.TrimSpace(strings.Split(line, ":")[0]) // get the spaces spaces := utils.CountStartingSpaces(line) if spaces/2 > currentLevel { currentLevel++ } else if spaces/2 < currentLevel { currentLevel-- } currentKey = strings.Join(strings.Split(currentKey, ".")[:spaces/2], ".") if currentLevel == 0 { currentKey = key toReturn = append(toReturn, line) continue } // if the key is not empty, add the selector path if currentKey != "" { currentKey += "." } currentKey += key // add the selector path as comment toReturn = append( toReturn, strings.Repeat(" ", spaces)+"# key: "+currentKey+"\n"+line, ) } return []byte(strings.Join(toReturn, "\n")) } // addTLSHelp adds a comment to the values.yaml file to explain how to // use the tls option. func addTLSHelp(values []byte) []byte { lines := strings.Split(string(values), "\n") for i, line := range lines { if strings.Contains(line, "tls:") { spaces := utils.CountStartingSpaces(line) spacesString := strings.Repeat(" ", spaces) // indent ingressClassHelper comment ingressTLSHelp := strings.ReplaceAll(ingressTLSHelp, "\n", "\n"+spacesString) ingressTLSHelp = strings.TrimRight(ingressTLSHelp, " ") ingressTLSHelp = spacesString + ingressTLSHelp lines[i] = ingressTLSHelp + line } } return []byte(strings.Join(lines, "\n")) } func buildCharYamlFile(chart *HelmChart, project *types.Project, chartPath string) { // calculate the sha1 hash of the services yamlChart, err := utils.EncodeBasicYaml(chart) if err != nil { fmt.Println(err) os.Exit(1) } // concat chart adding a comment with hash of services on top yamlChart = append(fmt.Appendf(nil, "# compose hash (sha1): %s\n", *chart.composeHash), yamlChart...) // add the list of compose files files := []string{} for _, file := range project.ComposeFiles { base := filepath.Base(file) files = append(files, base) } yamlChart = append(fmt.Appendf(nil, "# compose files: %s\n", strings.Join(files, ", ")), yamlChart...) // add generated date yamlChart = append(fmt.Appendf(nil, "# generated at: %s\n", time.Now().Format(time.RFC3339)), yamlChart...) // document Chart.yaml file yamlChart = addChartDoc(yamlChart, project) writeContent(chartPath, yamlChart) } func buildNotesFile(project *types.Project, notesPath string) { // get the list of services to write in the notes services := make([]string, 0) for _, service := range project.Services { services = append(services, service.Name) } // write the notes to the disk notes := extrafiles.NotesFile(services) writeContent(notesPath, []byte(notes)) } func buildValues(chart *HelmChart, project *types.Project, valuesPath string) { values, err := utils.EncodeBasicYaml(&chart.Values) if err != nil { fmt.Println(err) os.Exit(1) } values = addDescriptions(values, *project) values = addDependencyDescription(values, chart.Dependencies) values = addCommentsToValues(values) values = addStorageClassHelp(values) values = addImagePullSecretsHelp(values) values = addImagePullPolicyHelp(values) values = addVariablesDoc(values, project) values = addMainTagAppDoc(values, project) values = addResourceHelp(values) values = addTLSHelp(values) values = addYAMLSelectorPath(values) values = append([]byte(headerHelp), values...) // add vim modeline values = append(values, []byte("\n# vim: ft=yaml\n")...) // write the values to the disk writeContent(valuesPath, values) } func callHelmUpdate(config ConvertOptions) { executeAndHandleError := func(fn func(ConvertOptions) error, config ConvertOptions, message string) { if err := fn(config); err != nil { fmt.Println(utils.IconFailure, err) os.Exit(1) } fmt.Println(utils.IconSuccess, message) } if config.HelmUpdate { executeAndHandleError(helmUpdate, config, "Helm dependencies updated") executeAndHandleError(helmLint, config, "Helm chart linted") fmt.Println(utils.IconSuccess, "Helm chart created successfully") } } func removeNewlinesInsideBrackets(values []byte) []byte { re, err := regexp.Compile(`(?s)\{\{(.*?)\}\}`) if err != nil { log.Fatal(err) } return re.ReplaceAllFunc(values, func(b []byte) []byte { // get the first match matches := re.FindSubmatch(b) replacement := bytes.ReplaceAll(matches[1], []byte("\n"), []byte(" ")) // remove repeated spaces replacement = regexp.MustCompile(`\s+`).ReplaceAll(replacement, []byte(" ")) // remove newlines inside brackets return bytes.ReplaceAll(b, matches[1], replacement) }) } func removeUnwantedLines(values []byte) []byte { lines := strings.Split(string(values), "\n") output := []string{} for _, line := range lines { next := false for _, unwanted := range unwantedLines { if strings.Contains(line, unwanted) { next = true } } if !next { output = append(output, line) } } return []byte(strings.Join(output, "\n")) } func writeContent(path string, content []byte) { f, err := os.Create(path) if err != nil { fmt.Println(utils.IconFailure, err) os.Exit(1) } defer f.Close() defer func() { if _, err := f.Write(content); err != nil { log.Fatal(err) } }() } // helmLint runs "helm lint" on the output directory. func helmLint(config ConvertOptions) error { fmt.Println(utils.IconInfo, "Linting...") helm, err := exec.LookPath("helm") if err != nil { fmt.Println(utils.IconFailure, err) os.Exit(1) } cmd := exec.Command(helm, "lint", config.OutputDir) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } // helmUpdate runs "helm dependency update" on the output directory. func helmUpdate(config ConvertOptions) error { // lookup for "helm" binary fmt.Println(utils.IconInfo, "Updating helm dependencies...") helm, err := exec.LookPath("helm") if err != nil { fmt.Println(utils.IconFailure, err) os.Exit(1) } // run "helm dependency update" cmd := exec.Command(helm, "dependency", "update", config.OutputDir) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } // check if the project makes use of older labels (kanetary.[^v3]) func checkOldLabels(project *types.Project) error { badServices := make([]string, 0) for _, service := range project.Services { for label := range service.Labels { if strings.Contains(label, "katenary.") && !strings.Contains(label, labels.KatenaryLabelPrefix) { badServices = append(badServices, fmt.Sprintf("- %s: %s", service.Name, label)) } } } if len(badServices) > 0 { message := fmt.Sprintf(` Old labels detected in project "%s". The current version of katenary uses labels with the prefix "%s" which are not compatible with previous versions. Your project is not compatible with this version. Please upgrade your labels to follow the current version Services to upgrade: %s`, project.Name, labels.KatenaryLabelPrefix[0:len(labels.KatenaryLabelPrefix)-1], strings.Join(badServices, "\n"), ) return errors.New(utils.WordWrap(message, 80)) } return nil }