github.com/jenkins-x/jx/v2@v2.1.155/pkg/cmd/step/create/helmfile/create_helmfile.go (about)

     1  package helmfile
     2  
     3  import (
     4  	"fmt"
     5  	"io/ioutil"
     6  	"net/url"
     7  	"os"
     8  	"path"
     9  
    10  	"github.com/jenkins-x/jx/v2/pkg/config"
    11  	helmfile2 "github.com/jenkins-x/jx/v2/pkg/helmfile"
    12  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    13  
    14  	"github.com/google/uuid"
    15  	"github.com/jenkins-x/jx/v2/pkg/util"
    16  
    17  	"github.com/ghodss/yaml"
    18  
    19  	"github.com/jenkins-x/jx/v2/pkg/cmd/create/options"
    20  	"github.com/jenkins-x/jx/v2/pkg/cmd/helper"
    21  	"github.com/jenkins-x/jx/v2/pkg/cmd/opts"
    22  	"github.com/jenkins-x/jx/v2/pkg/cmd/templates"
    23  	"github.com/pkg/errors"
    24  	"github.com/spf13/cobra"
    25  )
    26  
    27  const (
    28  	helmfile = "helmfile.yaml"
    29  )
    30  
    31  var (
    32  	createHelmfileLong = templates.LongDesc(`
    33  		Creates a new helmfile.yaml from a jx-apps.yaml
    34  `)
    35  
    36  	createHelmfileExample = templates.Examples(`
    37  		# Create a new helmfile.yaml from a jx-apps.yaml
    38  		jx create helmfile
    39  	`)
    40  )
    41  
    42  // GeneratedValues is a struct that gets marshalled into helm values for creating namespaces via helm
    43  type GeneratedValues struct {
    44  	Namespaces []string `json:"namespaces"`
    45  }
    46  
    47  // CreateHelmfileOptions the options for the create helmfile command
    48  type CreateHelmfileOptions struct {
    49  	options.CreateOptions
    50  
    51  	dir        string
    52  	outputDir  string
    53  	valueFiles []string
    54  }
    55  
    56  // NewCmdCreateHelmfile  creates a command object for the "create" command
    57  func NewCmdCreateHelmfile(commonOpts *opts.CommonOptions) *cobra.Command {
    58  	o := &CreateHelmfileOptions{
    59  		CreateOptions: options.CreateOptions{
    60  			CommonOptions: commonOpts,
    61  		},
    62  	}
    63  
    64  	cmd := &cobra.Command{
    65  		Use:     "helmfile",
    66  		Short:   "Create a new helmfile",
    67  		Long:    createHelmfileLong,
    68  		Example: createHelmfileExample,
    69  		Run: func(cmd *cobra.Command, args []string) {
    70  			o.Cmd = cmd
    71  			o.Args = args
    72  			err := o.Run()
    73  			helper.CheckErr(err)
    74  		},
    75  	}
    76  	cmd.Flags().StringVarP(&o.dir, "dir", "", ".", "the directory to look for a 'jx-apps.yml' file")
    77  	cmd.Flags().StringVarP(&o.outputDir, "outputDir", "", "", "The directory to write the helmfile.yaml file")
    78  	cmd.Flags().StringArrayVarP(&o.valueFiles, "values", "", []string{""}, "specify values in a YAML file or a URL(can specify multiple)")
    79  
    80  	return cmd
    81  }
    82  
    83  // Run implements the command
    84  func (o *CreateHelmfileOptions) Run() error {
    85  
    86  	apps, err := config.LoadApplicationsConfig(o.dir)
    87  	if err != nil {
    88  		return errors.Wrap(err, "failed to load applications")
    89  	}
    90  
    91  	helm := o.Helm()
    92  	localHelmRepos, err := helm.ListRepos()
    93  	if err != nil {
    94  		return errors.Wrap(err, "failed listing helm repos")
    95  	}
    96  
    97  	// iterate over all apps and split them into phases to generate separate helmfiles for each
    98  	var applications []config.Application
    99  	var systemApplications []config.Application
   100  	for _, app := range apps.Applications {
   101  		// default phase is apps so set it in if empty
   102  		if app.Phase == "" || app.Phase == config.PhaseApps {
   103  			applications = append(applications, app)
   104  		}
   105  		if app.Phase == config.PhaseSystem {
   106  			systemApplications = append(systemApplications, app)
   107  		}
   108  	}
   109  
   110  	err = o.generateHelmFile(applications, err, localHelmRepos, apps, string(config.PhaseApps))
   111  	if err != nil {
   112  		return errors.Wrap(err, "failed to generate apps helmfile")
   113  	}
   114  	err = o.generateHelmFile(systemApplications, err, localHelmRepos, apps, string(config.PhaseSystem))
   115  	if err != nil {
   116  		return errors.Wrap(err, "failed to generate system helmfile")
   117  	}
   118  
   119  	return nil
   120  }
   121  
   122  func (o *CreateHelmfileOptions) generateHelmFile(applications []config.Application, err error, localHelmRepos map[string]string, apps *config.ApplicationConfig, phase string) error {
   123  	// contains the repo url and name to reference it by in the release spec
   124  	// use a map to dedupe repositories
   125  	repos := make(map[string]string)
   126  	for _, app := range applications {
   127  		_, err = url.ParseRequestURI(app.Repository)
   128  		if err != nil {
   129  			// if the repository isn't a valid URL lets just use whatever was supplied in the application repository field, probably it is a directory path
   130  			repos[app.Repository] = app.Repository
   131  		} else {
   132  			matched := false
   133  			// check if URL matches a repo in helms local list
   134  			for key, value := range localHelmRepos {
   135  				if app.Repository == value {
   136  					repos[app.Repository] = key
   137  					matched = true
   138  				}
   139  			}
   140  			if !matched {
   141  				repos[app.Repository] = uuid.New().String()
   142  			}
   143  		}
   144  	}
   145  	var repositories []helmfile2.RepositorySpec
   146  	var releases []helmfile2.ReleaseSpec
   147  	for repoURL, name := range repos {
   148  		_, err = url.ParseRequestURI(repoURL)
   149  		// skip non URLs as they're probably local directories which don't need to be in the helmfile.repository section
   150  		if err == nil {
   151  			repository := helmfile2.RepositorySpec{
   152  				Name: name,
   153  				URL:  repoURL,
   154  			}
   155  			repositories = append(repositories, repository)
   156  		}
   157  	}
   158  	for _, app := range applications {
   159  
   160  		if app.Namespace == "" {
   161  			app.Namespace = apps.DefaultNamespace
   162  		}
   163  
   164  		// check if a local directory and values file exists for the app
   165  		extraValuesFiles := o.valueFiles
   166  		extraValuesFiles = o.addExtraAppValues(app, extraValuesFiles, "values.yaml", phase)
   167  		extraValuesFiles = o.addExtraAppValues(app, extraValuesFiles, "values.yaml.gotmpl", phase)
   168  
   169  		chartName := fmt.Sprintf("%s/%s", repos[app.Repository], app.Name)
   170  		release := helmfile2.ReleaseSpec{
   171  			Name:      app.Name,
   172  			Namespace: app.Namespace,
   173  			Chart:     chartName,
   174  			Values:    extraValuesFiles,
   175  		}
   176  		releases = append(releases, release)
   177  	}
   178  
   179  	// ensure any namespaces referenced are created first, do this via an extra chart that creates namespaces
   180  	// so that helm manages the k8s resources, useful when cleaning up, this is a workaround for a helm 3 limitation
   181  	// which is expected to be fixed
   182  	repositories, releases, err = o.ensureNamespaceExist(repositories, releases, phase)
   183  	if err != nil {
   184  		return errors.Wrapf(err, "failed to check namespaces exists")
   185  	}
   186  	h := helmfile2.HelmState{
   187  		Bases: []string{"../environments.yaml"},
   188  		HelmDefaults: helmfile2.HelmSpec{
   189  			Atomic:  true,
   190  			Verify:  false,
   191  			Wait:    true,
   192  			Timeout: 180,
   193  			// need Force to be false https://github.com/helm/helm/issues/6378
   194  			Force: false,
   195  		},
   196  		Repositories: repositories,
   197  		Releases:     releases,
   198  	}
   199  	data, err := yaml.Marshal(h)
   200  	if err != nil {
   201  		return errors.Wrapf(err, "failed to marshal helmfile data")
   202  	}
   203  
   204  	err = o.writeHelmfile(err, phase, data)
   205  	if err != nil {
   206  		return errors.Wrapf(err, "failed to write helmfile")
   207  	}
   208  	return nil
   209  }
   210  
   211  func (o *CreateHelmfileOptions) writeHelmfile(err error, phase string, data []byte) error {
   212  	exists, err := util.DirExists(path.Join(o.outputDir, phase))
   213  	if err != nil || !exists {
   214  		err = os.MkdirAll(path.Join(o.outputDir, phase), os.ModePerm)
   215  		if err != nil {
   216  			return errors.Wrapf(err, "cannot create phase directory %s ", path.Join(o.outputDir, phase))
   217  		}
   218  	}
   219  	err = ioutil.WriteFile(path.Join(o.outputDir, phase, helmfile), data, util.DefaultWritePermissions)
   220  	if err != nil {
   221  		return errors.Wrapf(err, "failed to save file %s", helmfile)
   222  	}
   223  	return nil
   224  }
   225  
   226  func (o *CreateHelmfileOptions) addExtraAppValues(app config.Application, newValuesFiles []string, valuesFilename, phase string) []string {
   227  	fileName := path.Join(o.dir, phase, app.Name, valuesFilename)
   228  	exists, _ := util.FileExists(fileName)
   229  	if exists {
   230  		newValuesFiles = append(newValuesFiles, path.Join(app.Name, valuesFilename))
   231  	}
   232  	return newValuesFiles
   233  }
   234  
   235  // this is a temporary function that wont be needed once helm 3 supports creating namespaces
   236  func (o *CreateHelmfileOptions) ensureNamespaceExist(helmfileRepos []helmfile2.RepositorySpec, helmfileReleases []helmfile2.ReleaseSpec, phase string) ([]helmfile2.RepositorySpec, []helmfile2.ReleaseSpec, error) {
   237  
   238  	// start by deleting the existing generated directory
   239  	err := os.RemoveAll(path.Join(o.outputDir, phase, "generated"))
   240  	if err != nil {
   241  		return nil, nil, errors.Wrapf(err, "cannot delete generated values directory %s ", path.Join(phase, "generated"))
   242  	}
   243  
   244  	client, currentNamespace, err := o.KubeClientAndNamespace()
   245  	if err != nil {
   246  		return nil, nil, errors.Wrapf(err, "failed to create kube client")
   247  	}
   248  
   249  	namespaces, err := client.CoreV1().Namespaces().List(metav1.ListOptions{})
   250  	if err != nil {
   251  		return nil, nil, errors.Wrapf(err, "failed to list namespaces")
   252  	}
   253  
   254  	namespaceMatched := false
   255  	// loop over each application and check if the namespace it references exists, if not add the namespace creator chart to the helmfile
   256  	for k, release := range helmfileReleases {
   257  		for _, ns := range namespaces.Items {
   258  			if ns.Name == release.Namespace {
   259  				namespaceMatched = true
   260  			}
   261  		}
   262  		if !namespaceMatched {
   263  			existingCreateNamespaceChartFound := false
   264  			for _, release := range helmfileReleases {
   265  				if release.Name == "namespace-"+release.Namespace {
   266  					existingCreateNamespaceChartFound = true
   267  				}
   268  			}
   269  			if !existingCreateNamespaceChartFound {
   270  
   271  				err := o.writeGeneratedNamespaceValues(release.Namespace, phase)
   272  				if err != nil {
   273  					return nil, nil, errors.Wrapf(err, "failed to write generated namespace values file")
   274  				}
   275  
   276  				repository := helmfile2.RepositorySpec{
   277  					Name: "zloeber",
   278  					URL:  "git+https://github.com/zloeber/helm-namespace@chart",
   279  				}
   280  				helmfileRepos = append(helmfileRepos, repository)
   281  
   282  				createNamespaceChart := helmfile2.ReleaseSpec{
   283  					Name:      "namespace-" + release.Namespace,
   284  					Namespace: currentNamespace,
   285  					Chart:     "zloeber/namespace",
   286  
   287  					Values: []string{path.Join("generated", release.Namespace, "values.yaml")},
   288  				}
   289  
   290  				// add a dependency so that the create namespace chart is installed before the app chart
   291  				helmfileReleases[k].Needs = []string{fmt.Sprintf("%s/namespace-%s", currentNamespace, release.Namespace)}
   292  
   293  				helmfileReleases = append(helmfileReleases, createNamespaceChart)
   294  			}
   295  		}
   296  	}
   297  
   298  	return helmfileRepos, helmfileReleases, nil
   299  }
   300  
   301  func (o *CreateHelmfileOptions) writeGeneratedNamespaceValues(namespace, phase string) error {
   302  	// workaround with using []interface{} for values, this causes problems with (un)marshalling so lets write a file and
   303  	// add the file path to the []string values
   304  	err := os.MkdirAll(path.Join(o.outputDir, phase, "generated", namespace), os.ModePerm)
   305  	if err != nil {
   306  		return errors.Wrapf(err, "cannot create generated values directory %s ", path.Join(phase, "generated", namespace))
   307  	}
   308  	value := GeneratedValues{
   309  		Namespaces: []string{namespace},
   310  	}
   311  	data, err := yaml.Marshal(value)
   312  	if err != nil {
   313  		return err
   314  	}
   315  	err = ioutil.WriteFile(path.Join(o.outputDir, phase, "generated", namespace, "values.yaml"), data, util.DefaultWritePermissions)
   316  	return nil
   317  }