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 }