github.com/replicatedhq/ship@v0.55.0/pkg/lifecycle/render/helm/template.go (about)

     1  package helm
     2  
     3  import (
     4  	"bufio"
     5  	"bytes"
     6  	"fmt"
     7  	"os"
     8  	"path"
     9  	"path/filepath"
    10  	"regexp"
    11  	"strings"
    12  
    13  	"github.com/emosbaugh/yaml"
    14  	"github.com/go-kit/kit/log"
    15  	"github.com/go-kit/kit/log/level"
    16  	"github.com/pkg/errors"
    17  	"github.com/replicatedhq/libyaml"
    18  	"github.com/replicatedhq/ship/pkg/api"
    19  	"github.com/replicatedhq/ship/pkg/constants"
    20  	"github.com/replicatedhq/ship/pkg/lifecycle/render/root"
    21  	"github.com/replicatedhq/ship/pkg/process"
    22  	"github.com/replicatedhq/ship/pkg/state"
    23  	"github.com/replicatedhq/ship/pkg/templates"
    24  	"github.com/replicatedhq/ship/pkg/util"
    25  	"github.com/spf13/afero"
    26  	"github.com/spf13/viper"
    27  	"k8s.io/helm/pkg/chartutil"
    28  )
    29  
    30  // Templater is something that can consume and render a helm chart pulled by ship.
    31  // the chart should already be present at the specified path.
    32  type Templater interface {
    33  	Template(
    34  		chartRoot string,
    35  		rootFs root.Fs,
    36  		asset api.HelmAsset,
    37  		meta api.ReleaseMetadata,
    38  		configGroups []libyaml.ConfigGroup,
    39  		templateContext map[string]interface{},
    40  	) error
    41  }
    42  
    43  // NewTemplater returns a configured Templater that uses vendored libhelm to execute templating/etc
    44  func NewTemplater(
    45  	commands Commands,
    46  	logger log.Logger,
    47  	fs afero.Afero,
    48  	builderBuilder *templates.BuilderBuilder,
    49  	viper *viper.Viper,
    50  	stateManager state.Manager,
    51  ) Templater {
    52  	return &LocalTemplater{
    53  		Commands:       commands,
    54  		Logger:         logger,
    55  		FS:             fs,
    56  		BuilderBuilder: builderBuilder,
    57  		Viper:          viper,
    58  		StateManager:   stateManager,
    59  		process:        process.Process{Logger: logger},
    60  	}
    61  }
    62  
    63  var arrayLineRegex = regexp.MustCompile(`^\s*(env|args|volumes|initContainers):\s*$`)
    64  var valueLineRegex = regexp.MustCompile(`^\s*value:\s*$`)
    65  
    66  var nullValueLineRegex = regexp.MustCompile(`^(\s*value:)\s*null\s*$`)
    67  
    68  var templateFunctionRegex = regexp.MustCompile(`^\s*{{`)
    69  
    70  // LocalTemplater implements Templater by using the Commands interface
    71  // from pkg/helm and creating the chart in place
    72  type LocalTemplater struct {
    73  	Commands       Commands
    74  	Logger         log.Logger
    75  	FS             afero.Afero
    76  	BuilderBuilder *templates.BuilderBuilder
    77  	Viper          *viper.Viper
    78  	StateManager   state.Manager
    79  	process        process.Process
    80  }
    81  
    82  func (f *LocalTemplater) Template(
    83  	chartRoot string,
    84  	rootFs root.Fs,
    85  	asset api.HelmAsset,
    86  	meta api.ReleaseMetadata,
    87  	configGroups []libyaml.ConfigGroup,
    88  	templateContext map[string]interface{},
    89  ) error {
    90  	debug := level.Debug(
    91  		log.With(f.Logger,
    92  			"step.type", "render",
    93  			"render.phase", "execute",
    94  			"asset.type", "helm",
    95  			"chartRoot", chartRoot,
    96  			"dest", asset.Dest,
    97  			"description", asset.Description,
    98  		),
    99  	)
   100  
   101  	debug.Log("event", "mkdirall.attempt")
   102  	renderDest := path.Join(constants.ShipPathInternalTmp, "chartrendered")
   103  
   104  	err := f.FS.RemoveAll(renderDest)
   105  	if err != nil {
   106  		debug.Log("event", "removeall.fail", "err", err, "helmtempdir", renderDest)
   107  		return errors.Wrapf(err, "remove tmp directory in %s", constants.ShipPathInternalTmp)
   108  	}
   109  
   110  	err = f.FS.MkdirAll(renderDest, 0755)
   111  	if err != nil {
   112  		debug.Log("event", "mkdirall.fail", "err", err, "helmtempdir", renderDest)
   113  		return errors.Wrapf(err, "create tmp directory in %s", constants.ShipPathInternalTmp)
   114  	}
   115  
   116  	state, err := f.StateManager.CachedState()
   117  	if err != nil {
   118  		debug.Log("event", "tryloadState.fail", "err", err)
   119  		return errors.Wrapf(err, "try load state")
   120  	}
   121  
   122  	versioned := state.Versioned()
   123  	releaseName := versioned.CurrentReleaseName()
   124  	debug.Log("event", "releasename.resolve.fromState", "releasename", releaseName)
   125  
   126  	templateArgs := []string{
   127  		"--output-dir", renderDest,
   128  		"--name", releaseName,
   129  	}
   130  
   131  	if asset.HelmOpts != nil {
   132  		templateArgs = append(templateArgs, asset.HelmOpts...)
   133  	}
   134  
   135  	debug.Log("event", "helm.init")
   136  	if err := f.Commands.Init(); err != nil {
   137  		return errors.Wrap(err, "init helm client")
   138  	}
   139  
   140  	debug.Log("event", "helm.get.requirements")
   141  	requirements, err := f.getChartRequirements(chartRoot)
   142  	if err != nil {
   143  		return errors.Wrap(err, "get chart requirements")
   144  	}
   145  
   146  	debug.Log("event", "helm.repo.add")
   147  	absTempHelmHome, err := filepath.Abs(constants.InternalTempHelmHome)
   148  	if err != nil {
   149  		return errors.Wrap(err, "make absolute helm temp home")
   150  	}
   151  
   152  	depPaths, err := f.addDependencies(
   153  		requirements.Dependencies,
   154  		absTempHelmHome,
   155  		chartRoot,
   156  		asset,
   157  	)
   158  	if err != nil {
   159  		return errors.Wrapf(err, "add requirements deps for %s", asset.Upstream)
   160  	}
   161  
   162  	debug.Log("event", "helm.dependency.update")
   163  	if err := f.Commands.MaybeDependencyUpdate(chartRoot, requirements); err != nil {
   164  		return errors.Wrapf(err, "update helm dependencies for %s", asset.Upstream)
   165  	}
   166  
   167  	if asset.ValuesFrom != nil {
   168  		var valuesPath string
   169  		defaultValuesPath := path.Join(chartRoot, "values.yaml")
   170  
   171  		if asset.ValuesFrom.Path != "" {
   172  			valuesPath = path.Join(asset.ValuesFrom.Path, "values.yaml")
   173  		}
   174  
   175  		debug.Log("event", "writeTmpValues", "to", valuesPath, "default", defaultValuesPath)
   176  		if err := f.writeStateHelmValuesTo(valuesPath, defaultValuesPath); err != nil {
   177  			return errors.Wrapf(err, "copy state value to tmp directory %s", renderDest)
   178  		}
   179  		templateArgs = append(templateArgs,
   180  			"--values",
   181  			valuesPath,
   182  		)
   183  
   184  		if asset.ValuesFrom.SaveToState {
   185  			if err := f.writeMergedAndDefaultHelmValues(valuesPath, defaultValuesPath); err != nil {
   186  				return errors.Wrap(err, "write merged and default helm values")
   187  			}
   188  		}
   189  	}
   190  
   191  	if len(asset.Values) > 0 {
   192  		args, err := f.appendHelmValues(
   193  			meta,
   194  			configGroups,
   195  			templateContext,
   196  			asset,
   197  		)
   198  		if err != nil {
   199  			return errors.Wrap(err, "build helm values")
   200  		}
   201  		templateArgs = append(templateArgs, args...)
   202  	}
   203  
   204  	namespace := versioned.CurrentNamespace()
   205  	if len(namespace) > 0 {
   206  		templateArgs = addArgIfNotPresent(templateArgs, "--namespace", namespace)
   207  	} else {
   208  		templateArgs = addArgIfNotPresent(templateArgs, "--namespace", "default")
   209  	}
   210  
   211  	debug.Log("event", "helm.template")
   212  	if err := f.Commands.Template(chartRoot, templateArgs); err != nil {
   213  		debug.Log("event", "helm.template.err")
   214  		return errors.Wrap(err, "execute helm")
   215  	}
   216  
   217  	tempRenderedChartDir, err := f.getTempRenderedChartDirectoryName(renderDest, meta)
   218  	if err != nil {
   219  		return err
   220  	}
   221  	return f.cleanUpAndOutputRenderedFiles(rootFs, asset, tempRenderedChartDir, depPaths)
   222  }
   223  
   224  func (f *LocalTemplater) getChartRequirements(chartRoot string) (chartutil.Requirements, error) {
   225  	requirements := chartutil.Requirements{}
   226  
   227  	requirementsExists, err := f.FS.Exists(filepath.Join(chartRoot, "requirements.yaml"))
   228  	if err != nil {
   229  		return requirements, errors.Wrap(err, "check requirements yaml existence")
   230  	}
   231  
   232  	if !requirementsExists {
   233  		return requirements, nil
   234  	}
   235  
   236  	requirementsB, err := f.FS.ReadFile(filepath.Join(chartRoot, "requirements.yaml"))
   237  	if err != nil {
   238  		return requirements, errors.Wrap(err, "read requirements yaml")
   239  	}
   240  
   241  	if err := yaml.Unmarshal(requirementsB, &requirements); err != nil {
   242  		return requirements, errors.Wrap(err, "unmarshal requirements yaml")
   243  	}
   244  
   245  	return requirements, nil
   246  }
   247  
   248  // checks to see if the specified arg is present in the list. If it is not, adds it set to the specified value
   249  func addArgIfNotPresent(existingArgs []string, newArg string, newDefault string) []string {
   250  	for _, arg := range existingArgs {
   251  		if arg == newArg {
   252  			return existingArgs
   253  		}
   254  	}
   255  
   256  	return append(existingArgs, newArg, newDefault)
   257  }
   258  
   259  func (f *LocalTemplater) appendHelmValues(
   260  	meta api.ReleaseMetadata,
   261  	configGroups []libyaml.ConfigGroup,
   262  	templateContext map[string]interface{},
   263  	asset api.HelmAsset,
   264  ) ([]string, error) {
   265  	var cmdArgs []string
   266  	builder, err := f.BuilderBuilder.FullBuilder(
   267  		meta,
   268  		configGroups,
   269  		templateContext,
   270  	)
   271  	if err != nil {
   272  		return nil, errors.Wrap(err, "initialize template builder")
   273  	}
   274  
   275  	if asset.Values != nil {
   276  		for key, value := range asset.Values {
   277  			args, err := appendHelmValue(value, *builder, cmdArgs, key)
   278  			if err != nil {
   279  				return nil, errors.Wrapf(err, "append helm value %s", key)
   280  			}
   281  			cmdArgs = append(cmdArgs, args...)
   282  		}
   283  	}
   284  	return cmdArgs, nil
   285  }
   286  
   287  func appendHelmValue(
   288  	value interface{},
   289  	builder templates.Builder,
   290  	args []string,
   291  	key string,
   292  ) ([]string, error) {
   293  	stringValue, ok := value.(string)
   294  	if !ok {
   295  		args = append(args, "--set")
   296  		args = append(args, fmt.Sprintf("%s=%s", key, value))
   297  		return args, nil
   298  	}
   299  
   300  	renderedValue, err := builder.String(stringValue)
   301  	if err != nil {
   302  		return nil, errors.Wrapf(err, "render value for %s", key)
   303  	}
   304  	args = append(args, "--set")
   305  	args = append(args, fmt.Sprintf("%s=%s", key, renderedValue))
   306  	return args, nil
   307  }
   308  
   309  func (f *LocalTemplater) getTempRenderedChartDirectoryName(renderRoot string, meta api.ReleaseMetadata) (string, error) {
   310  	if meta.ShipAppMetadata.Name != "" {
   311  		return path.Join(renderRoot, meta.ShipAppMetadata.Name), nil
   312  	}
   313  
   314  	return util.FindOnlySubdir(renderRoot, f.FS)
   315  }
   316  
   317  func (f *LocalTemplater) cleanUpAndOutputRenderedFiles(
   318  	rootFs root.Fs,
   319  	asset api.HelmAsset,
   320  	tempRenderedChartDir string,
   321  	depPaths []string,
   322  ) error {
   323  	debug := level.Debug(log.With(f.Logger, "method", "cleanUpAndOutputRenderedFiles"))
   324  
   325  	subChartsDirName := "charts"
   326  	tempRenderedChartTemplatesDir := path.Join(tempRenderedChartDir, "templates")
   327  	tempRenderedSubChartsDir := path.Join(tempRenderedChartDir, subChartsDirName)
   328  
   329  	err := util.IsLegalPath(asset.Dest)
   330  	if err != nil {
   331  		return errors.Wrap(err, "write helm asset")
   332  	}
   333  
   334  	if f.Viper.GetBool("rm-asset-dest") {
   335  		debug.Log("event", "baseDir.rm", "path", asset.Dest)
   336  		if err := f.FS.RemoveAll(asset.Dest); err != nil {
   337  			return errors.Wrapf(err, "rm asset dest, remove %s", asset.Dest)
   338  		}
   339  	}
   340  
   341  	debug.Log("event", "bailIfPresent", "path", asset.Dest)
   342  	if err := util.BailIfPresent(f.FS, asset.Dest, f.Logger); err != nil {
   343  		return err
   344  	}
   345  
   346  	debug.Log("event", "mkdirall", "path", asset.Dest)
   347  	if err := rootFs.MkdirAll(asset.Dest, 0755); err != nil {
   348  		debug.Log("event", "mkdirall.fail", "path", asset.Dest)
   349  		return errors.Wrap(err, "failed to make asset destination base directory")
   350  	}
   351  
   352  	templatesDirExists, err := f.FS.DirExists(tempRenderedChartTemplatesDir)
   353  	if err != nil || !templatesDirExists {
   354  		// Sometimes the template dir doesn't exist
   355  		debug.Log("event", "templateDirNotFound")
   356  	}
   357  
   358  	if err := f.validateGeneratedFiles(f.FS, tempRenderedChartDir); err != nil {
   359  		return errors.Wrapf(err, "unable to validate chart dir")
   360  	}
   361  
   362  	if templatesDirExists {
   363  		debug.Log("event", "readdir", "folder", tempRenderedChartTemplatesDir)
   364  		files, err := f.FS.ReadDir(tempRenderedChartTemplatesDir)
   365  		if err != nil {
   366  			debug.Log("event", "readdir.fail", "folder", tempRenderedChartTemplatesDir)
   367  			return errors.Wrap(err, "failed to read temp rendered charts folder")
   368  		}
   369  		for _, file := range files {
   370  			originalPath := path.Join(tempRenderedChartTemplatesDir, file.Name())
   371  			renderedPath := path.Join(rootFs.RootPath, asset.Dest, file.Name())
   372  			if err := f.FS.Rename(originalPath, renderedPath); err != nil {
   373  				fileType := "file"
   374  				if file.IsDir() {
   375  					fileType = "directory"
   376  				}
   377  				return errors.Wrapf(err, "failed to rename %s at path %s", fileType, originalPath)
   378  			}
   379  		}
   380  	}
   381  
   382  	if subChartsExist, err := rootFs.IsDir(tempRenderedSubChartsDir); err == nil && subChartsExist {
   383  		debug.Log("event", "rename", "folder", tempRenderedSubChartsDir)
   384  		if err := rootFs.Rename(tempRenderedSubChartsDir, path.Join(asset.Dest, subChartsDirName)); err != nil {
   385  			return errors.Wrap(err, "failed to rename subcharts dir")
   386  		}
   387  	} else {
   388  		debug.Log("event", "rename", "folder", tempRenderedSubChartsDir, "message", "Folder does not exist")
   389  	}
   390  
   391  	debug.Log("event", "removeall", "path", constants.TempHelmValuesPath)
   392  	if err := f.FS.RemoveAll(constants.TempHelmValuesPath); err != nil {
   393  		debug.Log("event", "removeall.fail", "path", constants.TempHelmValuesPath)
   394  		return errors.Wrap(err, "failed to remove Helm values tmp dir")
   395  	}
   396  
   397  	for _, depPath := range depPaths {
   398  		debug.Log("event", "removeall", "path", depPath)
   399  		if err := f.FS.RemoveAll(depPath); err != nil {
   400  			return errors.Wrapf(err, "failed to remove chart dep %s", depPath)
   401  		}
   402  	}
   403  
   404  	return nil
   405  }
   406  
   407  func (f *LocalTemplater) writeMergedAndDefaultHelmValues(valuesPath, defaultValuesPath string) error {
   408  	valuesB, err := f.FS.ReadFile(valuesPath)
   409  	if err != nil {
   410  		return errors.Wrapf(err, "read values path %s", valuesPath)
   411  	}
   412  
   413  	defaultValuesB, err := f.FS.ReadFile(defaultValuesPath)
   414  	if err != nil {
   415  		return errors.Wrapf(err, "read default values path %s", defaultValuesPath)
   416  	}
   417  
   418  	if err := f.StateManager.SerializeHelmValues(string(valuesB), string(defaultValuesB)); err != nil {
   419  		return errors.Wrap(err, "serialize helm values")
   420  	}
   421  
   422  	return nil
   423  }
   424  
   425  // dest should be a path to a file, and its parent directory should already exist
   426  // if there are no values in state, defaultValuesPath will be copied into dest
   427  func (f *LocalTemplater) writeStateHelmValuesTo(dest string, defaultValuesPath string) error {
   428  	debug := level.Debug(log.With(f.Logger, "step.type", "helmValues", "resolveHelmValues"))
   429  	debug.Log("event", "tryLoadState")
   430  	editState, err := f.StateManager.CachedState()
   431  	if err != nil {
   432  		return errors.Wrap(err, "try load state")
   433  	}
   434  	helmValues := editState.CurrentHelmValues()
   435  	defaultHelmValues := editState.CurrentHelmValuesDefaults()
   436  
   437  	defaultValuesShippedWithChartBytes, err := f.FS.ReadFile(defaultValuesPath)
   438  	if err != nil {
   439  		return errors.Wrapf(err, "read helm values from %s", defaultValuesPath)
   440  	}
   441  	defaultValuesShippedWithChart := string(defaultValuesShippedWithChartBytes)
   442  
   443  	if defaultHelmValues == "" {
   444  		debug.Log("event", "values.load", "message", "No default helm values in state; using helm values from state.")
   445  		defaultHelmValues = defaultValuesShippedWithChart
   446  	}
   447  
   448  	mergedValues, err := MergeHelmValues(defaultHelmValues, helmValues, defaultValuesShippedWithChart, false)
   449  	if err != nil {
   450  		return errors.Wrap(err, "merge helm values")
   451  	}
   452  
   453  	err = f.FS.MkdirAll(constants.TempHelmValuesPath, 0700)
   454  	if err != nil {
   455  		return errors.Wrapf(err, "make dir %s", constants.TempHelmValuesPath)
   456  	}
   457  	debug.Log("event", "writeTempValuesYaml", "dest", dest)
   458  	err = f.FS.WriteFile(dest, []byte(mergedValues), 0644)
   459  	if err != nil {
   460  		return errors.Wrapf(err, "write values.yaml to %s", dest)
   461  	}
   462  
   463  	return nil
   464  }
   465  
   466  // validate each file to make sure that it conforms to the yaml spec
   467  // TODO replace this with an actual validation tool
   468  func (f *LocalTemplater) validateGeneratedFiles(
   469  	fs afero.Afero,
   470  	dir string,
   471  ) error {
   472  	debug := level.Debug(log.With(f.Logger, "method", "validateGeneratedFiles"))
   473  
   474  	debug.Log("event", "readdir", "folder", dir)
   475  	files, err := fs.ReadDir(dir)
   476  	if err != nil {
   477  		debug.Log("event", "readdir.fail", "folder", dir)
   478  		return errors.Wrapf(err, "failed to read folder %s", dir)
   479  	}
   480  
   481  	for _, file := range files {
   482  		thisPath := filepath.Join(dir, file.Name())
   483  		if file.IsDir() {
   484  			err := f.validateGeneratedFiles(fs, thisPath)
   485  			if err != nil {
   486  				return err
   487  			}
   488  		} else {
   489  			err := fixFile(fs, thisPath, file.Mode())
   490  			if err != nil {
   491  				return err
   492  			}
   493  		}
   494  	}
   495  
   496  	return nil
   497  }
   498  
   499  func fixFile(fs afero.Afero, thisPath string, mode os.FileMode) error {
   500  	contents, err := fs.ReadFile(thisPath)
   501  	if err != nil {
   502  		return errors.Wrapf(err, "failed to read file %s", thisPath)
   503  	}
   504  
   505  	scanner := bufio.NewScanner(bytes.NewReader(contents))
   506  
   507  	lines := []string{}
   508  	for scanner.Scan() {
   509  		lines = append(lines, scanner.Text())
   510  	}
   511  	if err := scanner.Err(); err != nil {
   512  		return errors.Wrapf(err, "failed to read lines from file %s", thisPath)
   513  	}
   514  
   515  	lines = fixLines(lines)
   516  
   517  	var outputFile bytes.Buffer
   518  	for idx, line := range lines {
   519  		if idx+1 != len(lines) || contents[len(contents)-1] == '\n' {
   520  			fmt.Fprintln(&outputFile, line)
   521  		} else {
   522  			// avoid adding trailing newlines
   523  			fmt.Fprint(&outputFile, line)
   524  		}
   525  	}
   526  
   527  	err = fs.WriteFile(thisPath, outputFile.Bytes(), mode)
   528  	if err != nil {
   529  		return errors.Wrapf(err, "failed to write file %s after fixup", thisPath)
   530  	}
   531  
   532  	return nil
   533  }
   534  
   535  // applies all fixes to all lines provided
   536  func fixLines(lines []string) []string {
   537  	for idx, line := range lines {
   538  		if arrayLineRegex.MatchString(line) {
   539  			// line has `key:` and nothing else but whitespace
   540  			if !checkIsChild(line, nextLine(idx, lines)) {
   541  				// next line is not a child, so this key has no contents, add an empty array
   542  				lines[idx] = line + " []"
   543  			}
   544  		} else if valueLineRegex.MatchString(line) {
   545  			// line has `value:` and nothing else but whitespace
   546  			if !checkIsChild(line, nextLine(idx, lines)) {
   547  				// next line is not a child, so value has no contents, add an empty string
   548  				lines[idx] = line + ` ""`
   549  			}
   550  		} else if nullValueLineRegex.MatchString(line) {
   551  			// line has `value: null`
   552  			matches := nullValueLineRegex.FindStringSubmatch(line)
   553  
   554  			if len(matches) >= 2 && matches[0] == line {
   555  				lines[idx] = matches[1] + ` ""`
   556  			}
   557  		}
   558  	}
   559  
   560  	return lines
   561  }
   562  
   563  // returns true if the second line is a child of the first
   564  func checkIsChild(firstLine, secondLine string) bool {
   565  	cutset := " \t"
   566  	firstIndentation := len(firstLine) - len(strings.TrimLeft(firstLine, cutset))
   567  	secondIndentation := len(secondLine) - len(strings.TrimLeft(secondLine, cutset))
   568  
   569  	if firstIndentation < secondIndentation {
   570  		// if the next line is more indented, it's a child
   571  		return true
   572  	}
   573  
   574  	if templateFunctionRegex.MatchString(secondLine) {
   575  		// next line is a template function
   576  		return true
   577  	}
   578  
   579  	if firstIndentation == secondIndentation {
   580  		if secondLine[secondIndentation] == '-' {
   581  			// if the next line starts with '-' and is on the same indentation, it's a child
   582  			return true
   583  		}
   584  	}
   585  
   586  	return false
   587  }
   588  
   589  // returns the next line after idx that is not entirely whitespace or a comment. If there are no lines meeting these criteria, returns ""
   590  func nextLine(idx int, lines []string) string {
   591  	if idx+1 >= len(lines) {
   592  		return ""
   593  	}
   594  
   595  	if len(strings.TrimSpace(lines[idx+1])) > 0 {
   596  		if strings.TrimSpace(lines[idx+1])[0] != '#' {
   597  			return lines[idx+1]
   598  		}
   599  	}
   600  
   601  	return nextLine(idx+1, lines)
   602  }