github.com/pluralsh/plural-cli@v0.9.5/pkg/scaffold/helm.go (about)

     1  package scaffold
     2  
     3  import (
     4  	"bytes"
     5  	"fmt"
     6  	"os"
     7  	"path/filepath"
     8  	"sort"
     9  	"strings"
    10  	ttpl "text/template"
    11  
    12  	"gopkg.in/yaml.v2"
    13  
    14  	"github.com/pluralsh/plural-cli/pkg/api"
    15  	"github.com/pluralsh/plural-cli/pkg/config"
    16  	"github.com/pluralsh/plural-cli/pkg/manifest"
    17  	"github.com/pluralsh/plural-cli/pkg/provider"
    18  	scftmpl "github.com/pluralsh/plural-cli/pkg/scaffold/template"
    19  	"github.com/pluralsh/plural-cli/pkg/template"
    20  	"github.com/pluralsh/plural-cli/pkg/utils"
    21  	"github.com/pluralsh/plural-cli/pkg/utils/errors"
    22  	"github.com/pluralsh/plural-cli/pkg/utils/git"
    23  	"github.com/pluralsh/plural-cli/pkg/utils/pathing"
    24  	"github.com/pluralsh/plural-cli/pkg/wkspace"
    25  )
    26  
    27  type dependency struct {
    28  	Name       string
    29  	Version    string
    30  	Repository string
    31  	Condition  string
    32  }
    33  
    34  type chart struct {
    35  	ApiVersion   string `yaml:"apiVersion"`
    36  	Name         string
    37  	Description  string
    38  	Version      string
    39  	AppVersion   string `yaml:"appVersion"`
    40  	Dependencies []dependency
    41  }
    42  
    43  func (s *Scaffold) handleHelm(wk *wkspace.Workspace) error {
    44  	if err := s.createChart(wk); err != nil {
    45  		return err
    46  	}
    47  
    48  	if err := s.buildChartValues(wk); err != nil {
    49  		return err
    50  	}
    51  
    52  	return nil
    53  }
    54  
    55  func (s *Scaffold) chartDependencies(w *wkspace.Workspace) []dependency {
    56  	dependencies := make([]dependency, len(w.Charts))
    57  	repo := w.Installation.Repository
    58  	for i, chartInstallation := range w.Charts {
    59  		dependencies[i] = dependency{
    60  			chartInstallation.Chart.Name,
    61  			chartInstallation.Version.Version,
    62  			repoUrl(w, repo.Name, chartInstallation.Chart.Name),
    63  			fmt.Sprintf("%s.enabled", chartInstallation.Chart.Name),
    64  		}
    65  	}
    66  	sort.SliceStable(dependencies, func(i, j int) bool {
    67  		return dependencies[i].Name < dependencies[j].Name
    68  	})
    69  	return dependencies
    70  }
    71  
    72  func Notes(installation *api.Installation) error {
    73  	repoRoot, err := git.Root()
    74  	if err != nil {
    75  		return err
    76  	}
    77  
    78  	if installation.Repository != nil && installation.Repository.Notes == "" {
    79  		return nil
    80  	}
    81  
    82  	context, err := manifest.ReadContext(manifest.ContextPath())
    83  	if err != nil {
    84  		return err
    85  	}
    86  
    87  	prov, err := provider.GetProvider()
    88  	if err != nil {
    89  		return err
    90  	}
    91  
    92  	repo := installation.Repository.Name
    93  	ctx, _ := context.Repo(installation.Repository.Name)
    94  
    95  	vals := map[string]interface{}{
    96  		"Values":        ctx,
    97  		"Configuration": context.Configuration,
    98  		"License":       installation.LicenseKey,
    99  		"OIDC":          installation.OIDCProvider,
   100  		"Region":        prov.Region(),
   101  		"Project":       prov.Project(),
   102  		"Cluster":       prov.Cluster(),
   103  		"Config":        config.Read(),
   104  		"Provider":      prov.Name(),
   105  		"Context":       prov.Context(),
   106  		"Applications":  BuildApplications(repoRoot),
   107  	}
   108  
   109  	if context.Globals != nil {
   110  		vals["Globals"] = context.Globals
   111  	}
   112  
   113  	if context.SMTP != nil {
   114  		vals["SMTP"] = context.SMTP.Configuration()
   115  	}
   116  
   117  	if installation.AcmeKeyId != "" {
   118  		vals["Acme"] = map[string]string{
   119  			"KeyId":  installation.AcmeKeyId,
   120  			"Secret": installation.AcmeSecret,
   121  		}
   122  	}
   123  
   124  	apps := &Applications{Root: repoRoot}
   125  	values, err := apps.HelmValues(repo)
   126  	if err != nil {
   127  		return err
   128  	}
   129  
   130  	for k, v := range values {
   131  		vals[k] = v
   132  	}
   133  
   134  	tmpl, err := template.MakeTemplate(installation.Repository.Notes)
   135  	if err != nil {
   136  		return err
   137  	}
   138  
   139  	var buf bytes.Buffer
   140  	buf.Grow(5 * 1024)
   141  	if err := tmpl.Execute(&buf, vals); err != nil {
   142  		return err
   143  	}
   144  
   145  	fmt.Println(buf.String())
   146  	return nil
   147  }
   148  
   149  func (s *Scaffold) buildChartValues(w *wkspace.Workspace) error {
   150  	ctx, _ := w.Context.Repo(w.Installation.Repository.Name)
   151  	valuesFile := pathing.SanitizeFilepath(filepath.Join(s.Root, "values.yaml"))
   152  	defaultValuesFile := pathing.SanitizeFilepath(filepath.Join(s.Root, "default-values.yaml"))
   153  	defaultPrevVals, _ := prevValues(defaultValuesFile)
   154  	prevVals, _ := prevValues(valuesFile)
   155  
   156  	if !utils.Exists(valuesFile) {
   157  		if err := os.WriteFile(valuesFile, []byte("{}\n"), 0644); err != nil {
   158  			return err
   159  		}
   160  	}
   161  
   162  	conf := config.Read()
   163  
   164  	apps, err := NewApplications()
   165  	if err != nil {
   166  		return err
   167  	}
   168  
   169  	proj, err := manifest.FetchProject()
   170  	if err != nil {
   171  		return err
   172  	}
   173  
   174  	vals := map[string]interface{}{
   175  		"Values":        ctx,
   176  		"Configuration": w.Context.Configuration,
   177  		"License":       w.Installation.LicenseKey,
   178  		"OIDC":          w.Installation.OIDCProvider,
   179  		"Region":        w.Provider.Region(),
   180  		"Project":       w.Provider.Project(),
   181  		"Cluster":       w.Provider.Cluster(),
   182  		"Config":        conf,
   183  		"Provider":      w.Provider.Name(),
   184  		"Context":       w.Provider.Context(),
   185  		"ClusterAPI":    proj.ClusterAPI,
   186  		"Network":       proj.Network,
   187  		"Applications":  apps,
   188  	}
   189  
   190  	if proj.AvailabilityZones != nil {
   191  		vals["AvailabilityZones"] = proj.AvailabilityZones
   192  	}
   193  
   194  	if w.Context.SMTP != nil {
   195  		vals["SMTP"] = w.Context.SMTP.Configuration()
   196  	}
   197  
   198  	if w.Context.Globals != nil {
   199  		vals["Globals"] = w.Context.Globals
   200  	}
   201  
   202  	if w.Installation.AcmeKeyId != "" {
   203  		vals["Acme"] = map[string]string{
   204  			"KeyId":  w.Installation.AcmeKeyId,
   205  			"Secret": w.Installation.AcmeSecret,
   206  		}
   207  	}
   208  
   209  	// get previous values from default-values.yaml if exists otherwise from values.yaml
   210  	if utils.Exists(defaultValuesFile) {
   211  		for k, v := range defaultPrevVals {
   212  			vals[k] = v
   213  		}
   214  	} else {
   215  		for k, v := range prevVals {
   216  			vals[k] = v
   217  		}
   218  	}
   219  	defaultValues, err := scftmpl.BuildValuesFromTemplate(vals, w)
   220  	if err != nil {
   221  		return err
   222  	}
   223  
   224  	io, err := yaml.Marshal(defaultValues)
   225  	if err != nil {
   226  		return err
   227  	}
   228  
   229  	// TODO: Remove this after testing. It is deprecated as values.yaml migration should not longer be required.
   230  	// mapValues, err := getValues(valuesFile)
   231  	// if err != nil {
   232  	//	return err
   233  	// }
   234  	// patchValues, err := utils.PatchInterfaceMap(defaultValues, mapValues)
   235  	// if err != nil {
   236  	//	return err
   237  	// }
   238  	//
   239  	// values, err := yaml.Marshal(patchValues)
   240  	// if err != nil {
   241  	//	return err
   242  	// }
   243  	// if err := utils.WriteFile(valuesFile, values); err != nil {
   244  	//	return err
   245  	// }
   246  
   247  	return utils.WriteFile(defaultValuesFile, io)
   248  }
   249  
   250  //nolint:golint,unused
   251  func getValues(path string) (map[string]map[string]interface{}, error) {
   252  	values := map[string]map[string]interface{}{}
   253  	valuesFromFile, err := os.ReadFile(path)
   254  	if err != nil {
   255  		return nil, err
   256  	}
   257  	if err := yaml.Unmarshal(valuesFromFile, &values); err != nil {
   258  		return nil, err
   259  	}
   260  	return values, nil
   261  }
   262  
   263  func prevValues(filename string) (map[string]map[string]interface{}, error) {
   264  	vals := make(map[string]map[interface{}]interface{})
   265  	parsed := make(map[string]map[string]interface{})
   266  	if !utils.Exists(filename) {
   267  		return parsed, nil
   268  	}
   269  
   270  	contents, err := os.ReadFile(filename)
   271  	if err != nil {
   272  		return parsed, err
   273  	}
   274  	if err := yaml.Unmarshal(contents, &vals); err != nil {
   275  		return parsed, err
   276  	}
   277  
   278  	for k, v := range vals {
   279  		parsed[k] = utils.CleanUpInterfaceMap(v)
   280  	}
   281  
   282  	return parsed, nil
   283  }
   284  
   285  func (s *Scaffold) createChart(w *wkspace.Workspace) error {
   286  	repo := w.Installation.Repository
   287  	if len(w.Charts) == 0 {
   288  		return utils.HighlightError(fmt.Errorf("No charts installed for this repository. You might need to run `plural bundle install %s <bundle-name>`.", repo.Name))
   289  	}
   290  
   291  	version := "0.1.0"
   292  	filename := pathing.SanitizeFilepath(filepath.Join(s.Root, ChartfileName))
   293  
   294  	if utils.Exists(filename) {
   295  		content, err := os.ReadFile(filename)
   296  		if err != nil {
   297  			return errors.ErrorWrap(err, "Failed to read existing Chart.yaml")
   298  		}
   299  
   300  		chart := chart{}
   301  		if err := yaml.Unmarshal(content, &chart); err != nil {
   302  			return errors.ErrorWrap(err, "Existing Chart.yaml has invalid yaml formatting")
   303  		}
   304  
   305  		version = chart.Version
   306  	}
   307  
   308  	appVersion := appVersion(w.Charts)
   309  	chart := &chart{
   310  		ApiVersion:   "v2",
   311  		Name:         repo.Name,
   312  		Description:  fmt.Sprintf("A helm chart for %s", repo.Name),
   313  		Version:      version,
   314  		AppVersion:   appVersion,
   315  		Dependencies: s.chartDependencies(w),
   316  	}
   317  
   318  	chartFile, err := yaml.Marshal(chart)
   319  	if err != nil {
   320  		return err
   321  	}
   322  
   323  	if err := utils.WriteFile(filename, chartFile); err != nil {
   324  		return err
   325  	}
   326  
   327  	files := []struct {
   328  		path    string
   329  		content []byte
   330  		force   bool
   331  	}{
   332  		{
   333  			// .helmignore
   334  			path:    pathing.SanitizeFilepath(filepath.Join(s.Root, IgnorefileName)),
   335  			content: []byte(defaultIgnore),
   336  		},
   337  		{
   338  			// NOTES.txt
   339  			path:    pathing.SanitizeFilepath(filepath.Join(s.Root, NotesName)),
   340  			content: []byte(defaultNotes),
   341  			force:   true,
   342  		},
   343  		{
   344  			// templates/secret.yaml
   345  			path:    pathing.SanitizeFilepath(filepath.Join(s.Root, LicenseSecretName)),
   346  			content: []byte(licenseSecret),
   347  			force:   true,
   348  		},
   349  		{
   350  			// templates/licnse.yaml
   351  			path:    pathing.SanitizeFilepath(filepath.Join(s.Root, LicenseCrdName)),
   352  			content: []byte(fmt.Sprintf(license, repo.Name)),
   353  			force:   true,
   354  		},
   355  	}
   356  
   357  	for _, file := range files {
   358  		if !file.force {
   359  			if _, err := os.Stat(file.path); err == nil {
   360  				// File exists and is okay. Skip it.
   361  				continue
   362  			}
   363  		}
   364  		if err := utils.WriteFile(file.path, file.content); err != nil {
   365  			return err
   366  		}
   367  	}
   368  
   369  	// remove old requirements.yaml files to fully migrate to helm v3
   370  	reqsFile := pathing.SanitizeFilepath(filepath.Join(s.Root, "requirements.yaml"))
   371  	if utils.Exists(reqsFile) {
   372  		if err := os.Remove(reqsFile); err != nil {
   373  			return err
   374  		}
   375  	}
   376  
   377  	tpl, err := ttpl.New("gotpl").Parse(defaultApplication)
   378  	if err != nil {
   379  		return err
   380  	}
   381  
   382  	var appBuffer bytes.Buffer
   383  	vars := map[string]string{
   384  		"Name":        repo.Name,
   385  		"Version":     appVersion,
   386  		"Description": repo.Description,
   387  		"Icon":        repo.Icon,
   388  		"DarkIcon":    repo.DarkIcon,
   389  	}
   390  	if err := tpl.Execute(&appBuffer, vars); err != nil {
   391  		return err
   392  	}
   393  	appBuffer.WriteString(appTemplate)
   394  
   395  	if err := utils.WriteFile(pathing.SanitizeFilepath(filepath.Join(s.Root, ApplicationName)), appBuffer.Bytes()); err != nil {
   396  		return err
   397  	}
   398  
   399  	// Need to add the ChartsDir explicitly as it does not contain any file OOTB
   400  	if err := os.MkdirAll(pathing.SanitizeFilepath(filepath.Join(s.Root, ChartsDir)), 0755); err != nil {
   401  		return err
   402  	}
   403  
   404  	return nil
   405  }
   406  
   407  func repoUrl(w *wkspace.Workspace, repo string, chart string) string {
   408  	if w.Links != nil {
   409  		if path, ok := w.Links.Helm[chart]; ok {
   410  			return fmt.Sprintf("file://%s", path)
   411  		}
   412  	}
   413  	url := strings.ReplaceAll(w.Config.BaseUrl(), "https", "cm")
   414  	return fmt.Sprintf("%s/cm/%s", url, repo)
   415  }
   416  
   417  func appVersion(charts []*api.ChartInstallation) string {
   418  	for _, inst := range charts {
   419  		if inst.Chart.Dependencies.Application {
   420  			if inst.Version.Helm != nil {
   421  				if vsn, ok := inst.Version.Helm["appVersion"]; ok {
   422  					if v, ok := vsn.(string); ok {
   423  						return v
   424  					}
   425  				}
   426  			}
   427  			return inst.Version.Version
   428  		}
   429  	}
   430  
   431  	return "0.1.0"
   432  }