github.com/simonferquel/app@v0.6.1-0.20181012141724-68b7cccf26ac/internal/helm/helm.go (about)

     1  package helm
     2  
     3  import (
     4  	"fmt"
     5  	"io/ioutil"
     6  	"os"
     7  	"path/filepath"
     8  	"regexp"
     9  	"strings"
    10  
    11  	"github.com/docker/app/internal"
    12  	"github.com/docker/app/internal/compose"
    13  	"github.com/docker/app/internal/helm/templateconversion"
    14  	"github.com/docker/app/internal/helm/templateloader"
    15  	"github.com/docker/app/internal/helm/templatetypes"
    16  	"github.com/docker/app/internal/helm/templatev1beta2"
    17  	"github.com/docker/app/internal/slices"
    18  	"github.com/docker/app/internal/yaml"
    19  	"github.com/docker/app/render"
    20  	"github.com/docker/app/types"
    21  	"github.com/docker/app/types/metadata"
    22  	"github.com/docker/app/types/settings"
    23  	"github.com/docker/cli/cli/command/stack/kubernetes"
    24  	"github.com/docker/cli/cli/compose/loader"
    25  	"github.com/docker/cli/kubernetes/compose/v1beta1"
    26  	"github.com/docker/cli/kubernetes/compose/v1beta2"
    27  	"github.com/pkg/errors"
    28  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    29  )
    30  
    31  /* Helm rendering with template preservation.
    32  
    33  We modify compose.Type (in templatetypes) by replacing all bool by BoolOrTemplate,
    34  all *uint64 with UInt64OrTemplate, etc.  so that we can store both a value or
    35  a templated string.
    36  We modify compose.Loader (in templateloader) to provide a new LoadTemplate that
    37  skips schema validation and variable interpolation. MapStructure hooks are
    38  provided for our *OrTemplate structs.
    39  We modify v1beta2 Stack and associated structures (in templatev1beta2) in sync
    40  with the changes in compose.Type, with the addition that all *OrTemplate structs
    41  are yaml-serialized with a name prefied by 'template_'.
    42  This package then invokes LoadTemplate, then templatev1beta2.convert, and
    43  post-process the serialized yaml to replace all 'template_'-prefixed keys
    44  with the appropriate content (value or template)
    45  */
    46  
    47  // v1beta1StackSpec is a copy of v1beta1.StackSpec with the proper YAML annotations
    48  type v1beta1StackSpec struct {
    49  	ComposeFile string `json:"composeFile,omitempty" yaml:"composeFile,omitempty"`
    50  }
    51  
    52  // v1beta1Stack is a copy of v1beta1.Stack with the proper YAML annotations
    53  type v1beta1Stack struct {
    54  	templatev1beta2.TypeMeta `yaml:",inline" json:",inline"`
    55  	metav1.ObjectMeta        `json:"metadata,omitempty" yaml:"metadata,omitempty"`
    56  
    57  	Spec   v1beta1StackSpec    `json:"spec,omitempty" yaml:"spec,omitempty"`
    58  	Status v1beta1.StackStatus `json:"status,omitempty" yaml:"status,omitempty"`
    59  }
    60  
    61  // v1beta2Stack is a copy of v1beta2.Stack with the proper YAML annotations
    62  type v1beta2Stack struct {
    63  	templatev1beta2.TypeMeta `json:",inline" yaml:",inline"`
    64  	metav1.ObjectMeta        `json:"metadata,omitempty" yaml:"metadata,omitempty"`
    65  
    66  	Spec   *v1beta2.StackSpec   `json:"spec,omitempty" yaml:"spec,omitempty"`
    67  	Status *v1beta2.StackStatus `json:"status,omitempty" yaml:"status,omitempty"`
    68  }
    69  
    70  // Helm renders an app as an Helm Chart
    71  func Helm(app *types.App, env map[string]string, shouldRender bool, stackVersion string) error {
    72  	targetDir := internal.AppNameFromDir(app.Name) + ".chart"
    73  	if err := os.MkdirAll(targetDir, 0755); err != nil {
    74  		return errors.Wrap(err, "failed to create Chart directory")
    75  	}
    76  	meta := app.Metadata()
    77  	if err := makeChart(&meta, targetDir); err != nil {
    78  		return err
    79  	}
    80  	if shouldRender {
    81  		return helmRender(app, targetDir, env, stackVersion)
    82  	}
    83  	// FIXME(vdemeester) support multiple file for helm
    84  	if len(app.Composes()) > 1 {
    85  		return errors.New("helm rendering doesn't support multiple composefiles")
    86  	}
    87  	data := app.Composes()[0]
    88  	// FIXME(vdemeester): remove the need to create this slice
    89  	variables := []string{}
    90  	vars, err := compose.ExtractVariables(data, render.Pattern)
    91  	if err != nil {
    92  		return err
    93  	}
    94  	for k := range vars {
    95  		variables = append(variables, k)
    96  	}
    97  	if err := makeStack(app.Name, targetDir, data, stackVersion); err != nil {
    98  		return err
    99  	}
   100  	return makeValues(app, targetDir, env, variables)
   101  }
   102  
   103  // makeValues updates helm values.yaml with used variables from settings and env
   104  func makeValues(app *types.App, targetDir string, env map[string]string, variables []string) error {
   105  	// merge our variables into Values.yaml
   106  	s := app.Settings()
   107  	metaPrefixed, err := settings.Load(app.MetadataRaw(), settings.WithPrefix("app"))
   108  	if err != nil {
   109  		return err
   110  	}
   111  	envSettings, err := settings.FromFlatten(env)
   112  	if err != nil {
   113  		return err
   114  	}
   115  	s, err = settings.Merge(s, metaPrefixed, envSettings)
   116  	if err != nil {
   117  		return errors.Wrap(err, "failed to merge settings")
   118  	}
   119  	filterVariables(s, variables, "")
   120  	// merge settings with existing values.yml
   121  	values := make(map[interface{}]interface{})
   122  	if valuesCur, err := ioutil.ReadFile(filepath.Join(targetDir, "values.yaml")); err == nil {
   123  		err = yaml.Unmarshal(valuesCur, values)
   124  		if err != nil {
   125  			return errors.Wrap(err, "failed to parse existing values.yaml")
   126  		}
   127  	}
   128  	mergeValues(values, s)
   129  	valuesRaw, err := yaml.Marshal(values)
   130  	if err != nil {
   131  		return errors.Wrap(err, "failed to generate values.yaml")
   132  	}
   133  	return ioutil.WriteFile(filepath.Join(targetDir, "values.yaml"), valuesRaw, 0644)
   134  }
   135  
   136  // makeStack converts data into a helm template for a stack
   137  func makeStack(appname string, targetDir string, data []byte, stackVersion string) error {
   138  	parsed, err := loader.ParseYAML(data)
   139  	if err != nil {
   140  		return errors.Wrap(err, "failed to parse template compose")
   141  	}
   142  	rendered, err := templateloader.LoadTemplate(parsed)
   143  	if err != nil {
   144  		return errors.Wrap(err, "failed to load template compose")
   145  	}
   146  	if err := os.MkdirAll(filepath.Join(targetDir, "templates"), 0755); err != nil {
   147  		return err
   148  	}
   149  	var stackData []byte
   150  	switch stackVersion {
   151  	case V1Beta2:
   152  		stackSpec := templateconversion.FromComposeConfig(rendered)
   153  		stack := templatev1beta2.Stack{
   154  			TypeMeta:   typeMeta(stackVersion),
   155  			ObjectMeta: objectMeta(appname),
   156  			Spec:       stackSpec,
   157  		}
   158  		templatetypes.ProcessTemplate = toGoTemplate
   159  		stackData, err = yaml.Marshal(stack)
   160  		if err != nil {
   161  			return err
   162  		}
   163  	case V1Beta1:
   164  		templatetypes.ProcessTemplate = toGoTemplate
   165  		composeFile, err := yaml.Marshal(rendered)
   166  		if err != nil {
   167  			return err
   168  		}
   169  		stack := v1beta1Stack{
   170  			TypeMeta:   typeMeta(stackVersion),
   171  			ObjectMeta: objectMeta(appname),
   172  			Spec: v1beta1StackSpec{
   173  				ComposeFile: string(composeFile),
   174  			},
   175  		}
   176  		stackData, err = yaml.Marshal(stack)
   177  		if err != nil {
   178  			return errors.Wrap(err, "failed to marshal final stack")
   179  		}
   180  	default:
   181  		return fmt.Errorf("invalid stack version %q", stackVersion)
   182  	}
   183  	stackData = unquote(stackData)
   184  	return ioutil.WriteFile(filepath.Join(targetDir, "templates", "stack.yaml"), stackData, 0644)
   185  }
   186  
   187  func helmRender(app *types.App, targetDir string, env map[string]string, stackVersion string) error {
   188  	rendered, err := render.Render(app, env)
   189  	if err != nil {
   190  		return err
   191  	}
   192  	converter, err := kubernetes.NewStackConverter(stackVersion)
   193  	if err != nil {
   194  		return err
   195  	}
   196  	name := internal.AppNameFromDir(app.Path)
   197  	s, err := converter.FromCompose(ioutil.Discard, name, rendered)
   198  	if err != nil {
   199  		return err
   200  	}
   201  	var stack interface{}
   202  	switch stackVersion {
   203  	case V1Beta2:
   204  		stack = v1beta2Stack{
   205  			TypeMeta:   typeMeta(stackVersion),
   206  			ObjectMeta: objectMeta(app.Path),
   207  			Spec:       s.Spec,
   208  		}
   209  	case V1Beta1:
   210  		stack = v1beta1Stack{
   211  			TypeMeta:   typeMeta(stackVersion),
   212  			ObjectMeta: objectMeta(app.Path),
   213  			Spec: v1beta1StackSpec{
   214  				ComposeFile: s.ComposeFile,
   215  			},
   216  		}
   217  	default:
   218  		return fmt.Errorf("invalid stack version %q", stackVersion)
   219  	}
   220  	stackData, err := yaml.Marshal(stack)
   221  	if err != nil {
   222  		return errors.Wrap(err, "failed to marshal stack data")
   223  	}
   224  	return ioutil.WriteFile(filepath.Join(targetDir, "templates", "stack.yaml"), stackData, 0644)
   225  }
   226  
   227  func makeChart(meta *metadata.AppMetadata, targetDir string) error {
   228  	hmeta, err := toHelmMeta(meta)
   229  	if err != nil {
   230  		return errors.Wrap(err, "failed to convert application metadata")
   231  	}
   232  	chart := make(map[interface{}]interface{})
   233  	prevChartRaw, err := ioutil.ReadFile(filepath.Join(targetDir, "Chart.yaml"))
   234  	if err == nil {
   235  		err = yaml.Unmarshal(prevChartRaw, chart)
   236  		if err != nil {
   237  			return errors.Wrap(err, "failed to unmarshal current Chart.yaml")
   238  		}
   239  	}
   240  	chart["name"] = hmeta.Name
   241  	chart["version"] = hmeta.Version
   242  	chart["description"] = hmeta.Description
   243  	chart["keywords"] = hmeta.Keywords
   244  	chart["maintainers"] = hmeta.Maintainers
   245  	hmetadata, err := yaml.Marshal(chart)
   246  	if err != nil {
   247  		return errors.Wrap(err, "failed to marshal Chart")
   248  	}
   249  	return ioutil.WriteFile(filepath.Join(targetDir, "Chart.yaml"), hmetadata, 0644)
   250  }
   251  
   252  func typeMeta(stackVersion string) templatev1beta2.TypeMeta {
   253  	return templatev1beta2.TypeMeta{
   254  		Kind:       "Stack",
   255  		APIVersion: "compose.docker.com/" + stackVersion,
   256  	}
   257  }
   258  
   259  func objectMeta(appname string) metav1.ObjectMeta {
   260  	return metav1.ObjectMeta{
   261  		Name: internal.AppNameFromDir(appname),
   262  	}
   263  }
   264  
   265  const (
   266  	// V1Beta1 is the string identifier for the v1beta1 version of the stack spec
   267  	V1Beta1 = "v1beta1"
   268  	// V1Beta2 is the string identifier for the v1beta2 version of the stack spec
   269  	V1Beta2 = "v1beta2"
   270  )
   271  
   272  type helmMaintainer struct {
   273  	Name string
   274  }
   275  
   276  type helmMeta struct {
   277  	Name        string
   278  	Version     string
   279  	Description string
   280  	Keywords    []string
   281  	Maintainers []helmMaintainer
   282  }
   283  
   284  func toHelmMeta(meta *metadata.AppMetadata) (*helmMeta, error) {
   285  	res := &helmMeta{
   286  		Name:        meta.Name,
   287  		Version:     meta.Version,
   288  		Description: meta.Description,
   289  	}
   290  	for _, m := range meta.Maintainers {
   291  		res.Maintainers = append(res.Maintainers,
   292  			helmMaintainer{Name: m.Name + " <" + m.Email + ">"},
   293  		)
   294  	}
   295  	return res, nil
   296  }
   297  
   298  func mergeValues(target map[interface{}]interface{}, source map[string]interface{}) {
   299  	for k, v := range source {
   300  		tv, ok := target[k]
   301  		if !ok {
   302  			target[k] = v
   303  			continue
   304  		}
   305  		switch tvv := tv.(type) {
   306  		case map[interface{}]interface{}:
   307  			mergeValues(tvv, v.(map[string]interface{}))
   308  		default:
   309  			target[k] = v
   310  		}
   311  	}
   312  }
   313  
   314  // remove from settings all stuff that is not in variables
   315  func filterVariables(s map[string]interface{}, variables []string, prefix string) {
   316  	for k, v := range s {
   317  		switch vv := v.(type) {
   318  		case map[string]interface{}:
   319  			filterVariables(vv, variables, prefix+k+".")
   320  			if len(vv) == 0 {
   321  				delete(s, k)
   322  			}
   323  		default:
   324  			if !slices.ContainsString(variables, prefix+k) {
   325  				delete(s, k)
   326  			}
   327  		}
   328  	}
   329  }
   330  
   331  // unquote unquotes gotemplates in template
   332  func unquote(template []byte) []byte {
   333  	re := regexp.MustCompile(`'(\{\{[^'}]*\}\})'`)
   334  	return re.ReplaceAll(template, []byte("$1"))
   335  }
   336  
   337  // toGoTemplate converts $foo and ${foo} into {{.foo}}
   338  func toGoTemplate(template string) (string, error) {
   339  	re := regexp.MustCompile(`(^|[^$])\${?([a-zA-Z0-9_.]+)}?`)
   340  	template = re.ReplaceAllString(template, "$1{{.Values.$2}}")
   341  	template = strings.Replace(template, "$$", "$", -1)
   342  	return template, nil
   343  }