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 }