Files
helmfile/pkg/helmexec/exec.go
T
Yusuke Kuokaandyxxhero 6664f01596 Use goccy/go-yaml for v1 / Prep bringing back go-yaml v2 for v0.x (#604)
This is a successor to #596. We need a smooth migration path from `gopkg.in/yaml.v2`, and this pull request moves it forward with `goccy/go-yaml` instead of `gopkg.in/yaml.v3`. Merging this unblocks users stuck in Helmfile v0.146.x or earlier due to #435, so that they can upgrade to 0.147.x or greater without updating their helmfile configs.

We previously tried to upgrade to `yaml.v3` (https://github.com/helmfile/helmfile/issues/394) in Helmfile v0.x, presuming it won't break anything. Apparently, it broke use-cases where you want to layer release's `values` field over three or more release templates and releases (#435).

We then tried to bring back `yaml.v2` for Helmfile v0.x and keep `yaml.v3` for the upcoming Helmfile v1. However, it failed due to incompatibility in the Unmarshaller interface between `yaml.v2` and `yaml.v3` (https://github.com/helmfile/helmfile/pull/596).

`goccy/go-yaml` is, from my observation, a well-maintained alternative to `yaml.v2`. One of its premises is that it enables us to swap the implementation from `gopkg.in/yaml.v2` to `goccy/go-yaml` just by replacing the import directive. It seems to use the same `Unmarshaller` interface as yaml.v2 too.

Once this PR gets merged, I'd like to follow-up with adding a new build-time variable and an envvar to set the proper default for the yaml parser Helmfile uses and the ability to switch the parser at runtime. All in all, the next Helmfile release, v0.150.0 will get reverted to use `gopkg.in/yaml.v2` by default which resolves #435.

New users who started using Helmfile since any of v0.148.0, v0.148.1, and v0.149.0 might be already relying on the new behavior, They might need to specify a new envvar to enable `goccy/go-yaml`.

Signed-off-by: yxxhero <aiopsclub@163.com>
Signed-off-by: yxxhero <aiopsclub@163.com>
Co-authored-by: yxxhero <aiopsclub@163.com>
2022-12-27 10:14:35 +09:00

653 lines
19 KiB
Go

package helmexec
import (
"bytes"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"github.com/Masterminds/semver/v3"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/plugin"
"github.com/helmfile/helmfile/pkg/envvar"
"github.com/helmfile/helmfile/pkg/yaml"
)
type decryptedSecret struct {
mutex sync.RWMutex
bytes []byte
err error
}
type execer struct {
helmBinary string
enableLiveOutput bool
version semver.Version
runner Runner
logger *zap.SugaredLogger
kubeContext string
extra []string
postRenderer string
decryptedSecretMutex sync.Mutex
decryptedSecrets map[string]*decryptedSecret
writeTempFile func([]byte) (string, error)
}
func NewLogger(writer io.Writer, logLevel string) *zap.SugaredLogger {
var cfg zapcore.EncoderConfig
cfg.MessageKey = "message"
out := zapcore.AddSync(writer)
var level zapcore.Level
err := level.Set(logLevel)
if err != nil {
panic(err)
}
core := zapcore.NewCore(
zapcore.NewConsoleEncoder(cfg),
out,
level,
)
return zap.New(core).Sugar()
}
func parseHelmVersion(versionStr string) (semver.Version, error) {
if len(versionStr) == 0 {
return semver.Version{}, nil
}
versionStr = strings.TrimLeft(versionStr, "Client: ")
versionStr = strings.TrimRight(versionStr, "\n")
ver, err := semver.NewVersion(versionStr)
if err != nil {
return semver.Version{}, fmt.Errorf("error parsing helm version '%s'", versionStr)
}
// Support explicit helm3 opt-in via environment variable
if os.Getenv(envvar.Helm3) != "" && ver.Major() < 3 {
return *semver.MustParse("v3.0.0"), nil
}
return *ver, nil
}
func GetHelmVersion(helmBinary string, runner Runner) (semver.Version, error) {
// Autodetect from `helm version`
outBytes, err := runner.Execute(helmBinary, []string{"version", "--client", "--short"}, nil, false)
if err != nil {
return semver.Version{}, fmt.Errorf("error determining helm version: %w", err)
}
return parseHelmVersion(string(outBytes))
}
func GetPluginVersion(name, pluginsDir string) (*semver.Version, error) {
plugins, err := plugin.FindPlugins(pluginsDir)
if err != nil {
return nil, err
}
for _, plugin := range plugins {
if plugin.Metadata.Name == name {
return semver.NewVersion(plugin.Metadata.Version)
}
}
return nil, fmt.Errorf("plugin %s not installed", name)
}
func redactedURL(chart string) string {
chartURL, err := url.ParseRequestURI(chart)
if err != nil {
return chart
}
return chartURL.Redacted()
}
// New for running helm commands
func New(helmBinary string, enableLiveOutput bool, logger *zap.SugaredLogger, kubeContext string, runner Runner) *execer {
// TODO: proper error handling
version, err := GetHelmVersion(helmBinary, runner)
if err != nil {
panic(err)
}
return &execer{
helmBinary: helmBinary,
enableLiveOutput: enableLiveOutput,
version: version,
logger: logger,
kubeContext: kubeContext,
runner: runner,
decryptedSecrets: make(map[string]*decryptedSecret),
}
}
func (helm *execer) SetExtraArgs(args ...string) {
helm.extra = args
}
func (helm *execer) SetHelmBinary(bin string) {
helm.helmBinary = bin
}
func (helm *execer) SetEnableLiveOutput(enableLiveOutput bool) {
helm.enableLiveOutput = enableLiveOutput
}
func (helm *execer) SetPostRenderer(postRenderer string) {
helm.postRenderer = postRenderer
}
func (helm *execer) GetPostRenderer() string {
return helm.postRenderer
}
func (helm *execer) AddRepo(name, repository, cafile, certfile, keyfile, username, password string, managed string, passCredentials string, skipTLSVerify string) error {
var args []string
var out []byte
var err error
if name == "" && repository != "" {
helm.logger.Infof("empty field name\n")
return fmt.Errorf("empty field name")
}
switch managed {
case "acr":
helm.logger.Infof("Adding repo %v (acr)", name)
out, err = helm.azcli(name)
case "":
args = append(args, "repo", "add", name, repository)
// See https://github.com/helm/helm/pull/8777
if cons, err := semver.NewConstraint(">= 3.3.2"); err == nil {
if cons.Check(&helm.version) {
args = append(args, "--force-update")
}
} else {
panic(err)
}
if certfile != "" && keyfile != "" {
args = append(args, "--cert-file", certfile, "--key-file", keyfile)
}
if cafile != "" {
args = append(args, "--ca-file", cafile)
}
if username != "" && password != "" {
args = append(args, "--username", username, "--password", password)
}
if passCredentials == "true" {
args = append(args, "--pass-credentials")
}
if skipTLSVerify == "true" {
args = append(args, "--insecure-skip-tls-verify")
}
helm.logger.Infof("Adding repo %v %v", name, repository)
out, err = helm.exec(args, map[string]string{}, nil)
default:
helm.logger.Errorf("ERROR: unknown type '%v' for repository %v", managed, name)
out = nil
err = nil
}
helm.info(out)
return err
}
func (helm *execer) UpdateRepo() error {
helm.logger.Info("Updating repo")
out, err := helm.exec([]string{"repo", "update"}, map[string]string{}, nil)
helm.info(out)
return err
}
func (helm *execer) RegistryLogin(repository string, username string, password string) error {
helm.logger.Info("Logging in to registry")
args := []string{
"registry",
"login",
repository,
"--username",
username,
"--password-stdin",
}
buffer := bytes.Buffer{}
buffer.Write([]byte(fmt.Sprintf("%s\n", password)))
out, err := helm.execStdIn(args, map[string]string{"HELM_EXPERIMENTAL_OCI": "1"}, &buffer)
helm.info(out)
return err
}
func (helm *execer) BuildDeps(name, chart string, flags ...string) error {
helm.logger.Infof("Building dependency release=%v, chart=%v", name, chart)
args := []string{
"dependency",
"build",
chart,
}
args = append(args, flags...)
out, err := helm.exec(args, map[string]string{}, nil)
helm.info(out)
return err
}
func (helm *execer) UpdateDeps(chart string) error {
helm.logger.Infof("Updating dependency %v", chart)
out, err := helm.exec([]string{"dependency", "update", chart}, map[string]string{}, nil)
helm.info(out)
return err
}
func (helm *execer) SyncRelease(context HelmContext, name, chart string, flags ...string) error {
helm.logger.Infof("Upgrading release=%v, chart=%v", name, redactedURL(chart))
preArgs := context.GetTillerlessArgs(helm)
env := context.getTillerlessEnv()
if helm.IsHelm3() {
flags = append(flags, "--history-max", strconv.Itoa(context.HistoryMax))
} else {
env["HELM_TILLER_HISTORY_MAX"] = strconv.Itoa(context.HistoryMax)
}
out, err := helm.exec(append(append(preArgs, "upgrade", "--install", name, chart), flags...), env, nil)
helm.write(nil, out)
return err
}
func (helm *execer) ReleaseStatus(context HelmContext, name string, flags ...string) error {
helm.logger.Infof("Getting status %v", name)
preArgs := context.GetTillerlessArgs(helm)
env := context.getTillerlessEnv()
out, err := helm.exec(append(append(preArgs, "status", name), flags...), env, nil)
helm.write(nil, out)
return err
}
func (helm *execer) List(context HelmContext, filter string, flags ...string) (string, error) {
helm.logger.Infof("Listing releases matching %v", filter)
preArgs := context.GetTillerlessArgs(helm)
env := context.getTillerlessEnv()
var args []string
if helm.IsHelm3() {
args = []string{"list", "--filter", filter}
} else {
args = []string{"list", filter}
}
enableLiveOutput := false
out, err := helm.exec(append(append(preArgs, args...), flags...), env, &enableLiveOutput)
// In v2 we have been expecting `helm list FILTER` prints nothing.
// In v3 helm still prints the header like `NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION`,
// which confuses helmfile's existing logic that treats any non-empty output from `helm list` is considered as the indication
// of the release to exist.
//
// This fixes it by removing the header from the v3 output, so that the output is formatted the same as that of v2.
if helm.IsHelm3() {
lines := strings.Split(string(out), "\n")
lines = lines[1:]
out = []byte(strings.Join(lines, "\n"))
}
helm.write(nil, out)
return string(out), err
}
func (helm *execer) DecryptSecret(context HelmContext, name string, flags ...string) (string, error) {
absPath, err := filepath.Abs(name)
if err != nil {
return "", err
}
helm.logger.Debugf("Preparing to decrypt secret %v", absPath)
helm.decryptedSecretMutex.Lock()
secret, ok := helm.decryptedSecrets[absPath]
// Cache miss
if !ok {
secret = &decryptedSecret{}
helm.decryptedSecrets[absPath] = secret
secret.mutex.Lock()
defer secret.mutex.Unlock()
helm.decryptedSecretMutex.Unlock()
helm.logger.Infof("Decrypting secret %v", absPath)
preArgs := context.GetTillerlessArgs(helm)
env := context.getTillerlessEnv()
settings := cli.New()
pluginVersion, err := GetPluginVersion("secrets", settings.PluginsDirectory)
if err != nil {
secret.err = err
return "", err
}
secretArg := "view"
// helm secret view command. The helm secret decrypt command is a drop-in replacement in 4.0.0 version
if pluginVersion.Major() > 3 {
secretArg = "decrypt"
}
enableLiveOutput := false
secretBytes, err := helm.exec(append(append(preArgs, "secrets", secretArg, absPath), flags...), env, &enableLiveOutput)
if err != nil {
secret.err = err
return "", err
}
secret.bytes = secretBytes
} else {
// Cache hit
helm.logger.Debugf("Found secret in cache %v", absPath)
secret.mutex.RLock()
helm.decryptedSecretMutex.Unlock()
defer secret.mutex.RUnlock()
if secret.err != nil {
return "", secret.err
}
}
tempFile := helm.writeTempFile
if tempFile == nil {
tempFile = func(content []byte) (string, error) {
dir := filepath.Dir(name)
extension := filepath.Ext(name)
tmpFile, err := os.CreateTemp(dir, "secret*"+extension)
if err != nil {
return "", err
}
defer func() {
_ = tmpFile.Close()
}()
_, err = tmpFile.Write(content)
if err != nil {
return "", err
}
return tmpFile.Name(), nil
}
}
tmpFileName, err := tempFile(secret.bytes)
if err != nil {
return "", err
}
helm.logger.Debugf("Decrypted %s into %s", absPath, tmpFileName)
return tmpFileName, err
}
func (helm *execer) TemplateRelease(name string, chart string, flags ...string) error {
helm.logger.Infof("Templating release=%v, chart=%v", name, redactedURL(chart))
var args []string
if helm.IsHelm3() {
args = []string{"template", name, chart}
} else {
args = []string{"template", chart, "--name", name}
}
out, err := helm.exec(append(args, flags...), map[string]string{}, nil)
var outputToFile bool
for _, f := range flags {
if strings.HasPrefix("--output-dir", f) {
outputToFile = true
break
}
}
if outputToFile {
// With --output-dir is passed to helm-template,
// we can safely direct all the logs from it to our logger.
//
// It's safe because anything written to stdout by helm-template with output-dir is logs,
// like excessive `wrote path/to/output/dir/chart/template/file.yaml` messages,
// but manifets.
//
// See https://github.com/roboll/helmfile/pull/1691#issuecomment-805636021 for more information.
helm.info(out)
} else {
// Always write to stdout for use with e.g. `helmfile template | kubectl apply -f -`
helm.write(nil, out)
}
return err
}
func (helm *execer) DiffRelease(context HelmContext, name, chart string, suppressDiff bool, flags ...string) error {
if context.Writer != nil {
fmt.Fprintf(context.Writer, "Comparing release=%v, chart=%v\n", name, redactedURL(chart))
} else {
helm.logger.Infof("Comparing release=%v, chart=%v", name, redactedURL(chart))
}
preArgs := context.GetTillerlessArgs(helm)
env := context.getTillerlessEnv()
var overrideEnableLiveOutput *bool = nil
if suppressDiff {
enableLiveOutput := false
overrideEnableLiveOutput = &enableLiveOutput
}
out, err := helm.exec(append(append(preArgs, "diff", "upgrade", "--allow-unreleased", name, chart), flags...), env, overrideEnableLiveOutput)
// Do our best to write STDOUT only when diff existed
// Unfortunately, this works only when you run helmfile with `--detailed-exitcode`
detailedExitcodeEnabled := false
for _, f := range flags {
if strings.Contains(f, "detailed-exitcode") {
detailedExitcodeEnabled = true
break
}
}
if detailedExitcodeEnabled {
e, ok := err.(ExitError)
if ok && e.ExitStatus() == 2 {
if !(suppressDiff) {
helm.write(context.Writer, out)
}
return err
}
} else if !(suppressDiff) {
helm.write(context.Writer, out)
}
return err
}
func (helm *execer) Lint(name, chart string, flags ...string) error {
helm.logger.Infof("Linting release=%v, chart=%v", name, chart)
out, err := helm.exec(append([]string{"lint", chart}, flags...), map[string]string{}, nil)
helm.write(nil, out)
return err
}
func (helm *execer) Fetch(chart string, flags ...string) error {
helm.logger.Infof("Fetching %v", redactedURL(chart))
out, err := helm.exec(append([]string{"fetch", chart}, flags...), map[string]string{}, nil)
helm.info(out)
return err
}
func (helm *execer) ChartPull(chart string, path string, flags ...string) error {
var helmArgs []string
helm.logger.Infof("Pulling %v", chart)
helmVersionConstraint, _ := semver.NewConstraint(">= 3.7.0")
if helmVersionConstraint.Check(&helm.version) {
// in the 3.7.0 version, the chart pull has been replaced with helm pull
// https://github.com/helm/helm/releases/tag/v3.7.0
ociChartURL, ociChartTag := resolveOciChart(chart)
helmArgs = []string{"pull", ociChartURL, "--version", ociChartTag, "--destination", path, "--untar"}
} else {
helmArgs = []string{"chart", "pull", chart}
}
out, err := helm.exec(append(helmArgs, flags...), map[string]string{"HELM_EXPERIMENTAL_OCI": "1"}, nil)
helm.info(out)
return err
}
func (helm *execer) ChartExport(chart string, path string, flags ...string) error {
helmVersionConstraint, _ := semver.NewConstraint(">= 3.7.0")
if helmVersionConstraint.Check(&helm.version) {
// in the 3.7.0 version, the chart export has been removed
// https://github.com/helm/helm/releases/tag/v3.7.0
return nil
}
var helmArgs []string
helm.logger.Infof("Exporting %v", chart)
helmArgs = []string{"chart", "export", chart, "--destination", path}
out, err := helm.exec(append(helmArgs, flags...), map[string]string{"HELM_EXPERIMENTAL_OCI": "1"}, nil)
helm.info(out)
return err
}
func (helm *execer) DeleteRelease(context HelmContext, name string, flags ...string) error {
helm.logger.Infof("Deleting %v", name)
preArgs := context.GetTillerlessArgs(helm)
env := context.getTillerlessEnv()
out, err := helm.exec(append(append(preArgs, "delete", name), flags...), env, nil)
helm.write(nil, out)
return err
}
func (helm *execer) TestRelease(context HelmContext, name string, flags ...string) error {
helm.logger.Infof("Testing %v", name)
preArgs := context.GetTillerlessArgs(helm)
env := context.getTillerlessEnv()
args := []string{"test", name}
out, err := helm.exec(append(append(preArgs, args...), flags...), env, nil)
helm.write(nil, out)
return err
}
func (helm *execer) AddPlugin(name, path, version string) error {
helm.logger.Infof("Install helm plugin %v", name)
out, err := helm.exec([]string{"plugin", "install", path, "--version", version}, map[string]string{}, nil)
helm.info(out)
return err
}
func (helm *execer) UpdatePlugin(name string) error {
helm.logger.Infof("Update helm plugin %v", name)
out, err := helm.exec([]string{"plugin", "update", name}, map[string]string{}, nil)
helm.info(out)
return err
}
func (helm *execer) exec(args []string, env map[string]string, overrideEnableLiveOutput *bool) ([]byte, error) {
cmdargs := args
if len(helm.extra) > 0 {
cmdargs = append(cmdargs, helm.extra...)
}
if helm.kubeContext != "" {
cmdargs = append([]string{"--kube-context", helm.kubeContext}, cmdargs...)
}
cmd := fmt.Sprintf("exec: %s %s", helm.helmBinary, strings.Join(cmdargs, " "))
helm.logger.Debug(cmd)
enableLiveOutput := helm.enableLiveOutput
if overrideEnableLiveOutput != nil {
enableLiveOutput = *overrideEnableLiveOutput
}
outBytes, err := helm.runner.Execute(helm.helmBinary, cmdargs, env, enableLiveOutput)
return outBytes, err
}
func (helm *execer) execStdIn(args []string, env map[string]string, stdin io.Reader) ([]byte, error) {
cmdargs := args
if len(helm.extra) > 0 {
cmdargs = append(cmdargs, helm.extra...)
}
if helm.kubeContext != "" {
cmdargs = append([]string{"--kube-context", helm.kubeContext}, cmdargs...)
}
cmd := fmt.Sprintf("exec: %s %s", helm.helmBinary, strings.Join(cmdargs, " "))
helm.logger.Debug(cmd)
outBytes, err := helm.runner.ExecuteStdIn(helm.helmBinary, cmdargs, env, stdin)
return outBytes, err
}
func (helm *execer) azcli(name string) ([]byte, error) {
cmdargs := append(strings.Split("acr helm repo add --name", " "), name)
cmd := fmt.Sprintf("exec: az %s", strings.Join(cmdargs, " "))
helm.logger.Debug(cmd)
outBytes, err := helm.runner.Execute("az", cmdargs, map[string]string{}, false)
helm.logger.Debugf("%s: %s", cmd, outBytes)
return outBytes, err
}
func (helm *execer) info(out []byte) {
if len(out) > 0 {
helm.logger.Infof("%s", out)
}
}
func (helm *execer) write(w io.Writer, out []byte) {
if len(out) > 0 {
if w == nil {
w = os.Stdout
}
fmt.Fprintf(w, "%s\n", out)
}
}
func (helm *execer) IsHelm3() bool {
return helm.version.Major() == 3
}
func (helm *execer) GetVersion() Version {
return Version{
Major: int(helm.version.Major()),
Minor: int(helm.version.Minor()),
Patch: int(helm.version.Patch()),
}
}
func (helm *execer) IsVersionAtLeast(versionStr string) bool {
ver := semver.MustParse(versionStr)
return helm.version.Equal(ver) || helm.version.GreaterThan(ver)
}
func resolveOciChart(ociChart string) (ociChartURL, ociChartTag string) {
var urlTagIndex int
// Get the last : index
// e.g.,
// 1. registry:443/helm-charts
// 2. registry/helm-charts:latest
// 3. registry:443/helm-charts:latest
if strings.LastIndex(ociChart, ":") <= strings.LastIndex(ociChart, "/") {
urlTagIndex = len(ociChart)
ociChartTag = ""
} else {
urlTagIndex = strings.LastIndex(ociChart, ":")
ociChartTag = ociChart[urlTagIndex+1:]
}
ociChartURL = fmt.Sprintf("oci://%s", ociChart[:urlTagIndex])
return ociChartURL, ociChartTag
}
func (helm *execer) ShowChart(chartPath string) (chart.Metadata, error) {
if !helm.IsHelm3() {
// show chart command isn't supported in helm2
return chart.Metadata{}, fmt.Errorf("helm show isn't supported in helm2")
}
var helmArgs = []string{"show", "chart", chartPath}
out, error := helm.exec(helmArgs, map[string]string{}, nil)
if error != nil {
return chart.Metadata{}, error
}
var metadata chart.Metadata
error = yaml.Unmarshal(out, &metadata)
if error != nil {
return chart.Metadata{}, error
}
return metadata, nil
}