helmcli/release.go (170 lines of code) (raw):

// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. package helmcli import ( "context" "encoding/json" "fmt" "time" "gopkg.in/yaml.v3" "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/storage/driver" "helm.sh/helm/v3/pkg/strvals" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/klog/v2" ) var debugLog action.DebugLog = func(fmt string, args ...interface{}) { klog.V(2).Infof(fmt, args...) } // ValuesApplier is to apply new key/values to existing chart's values. type ValuesApplier func(values map[string]interface{}) error // StringPathValuesApplier applies key/values by string path. // // For instance, x.y.z=1 is the same to that YAML value: // // ```yaml // // x: // y: // z: 1 // // ``` func StringPathValuesApplier(values ...string) ValuesApplier { return func(to map[string]interface{}) error { for _, v := range values { if err := strvals.ParseInto(v, to); err != nil { return fmt.Errorf("failed to parse (%s) into values: %w", v, err) } } return nil } } // YAMLValuesApplier applies key/values by YAML. func YAMLValuesApplier(yamlValues string) (ValuesApplier, error) { values := make(map[string]interface{}) err := yaml.Unmarshal([]byte(yamlValues), &values) if err != nil { return nil, err } return func(to map[string]interface{}) error { return applyValues(to, values) }, nil } func applyValues(to, from map[string]interface{}) error { for k, v := range from { // If 'to' doesn't have key 'k' if _, checkKey := to[k]; !checkKey { to[k] = v continue } // If 'to' has key 'k' switch v := v.(type) { case map[string]interface{}: // If 'v' is of type map[string]interface{} if toMap, checkKey := to[k].(map[string]interface{}); checkKey { if err := applyValues(toMap, v); err != nil { return err } } else { to[k] = v } default: // If 'v' is not of type map[string]interface{} to[k] = v } } return nil } // ReleaseCli is a client to deploy helm chart with secret storage. type ReleaseCli struct { namespace string name string cfg *action.Configuration ch *chart.Chart values map[string]interface{} labels map[string]string } // NewReleaseCli returns new ReleaseCli instance. // // TODO: // 1. add flag to disable Wait func NewReleaseCli( kubeconfigPath string, namespace string, name string, ch *chart.Chart, labels map[string]string, valuesAppliers ...ValuesApplier, ) (*ReleaseCli, error) { // build default values values, err := copyValues(ch.Values) if err != nil { return nil, err } for _, applier := range valuesAppliers { if err := applier(values); err != nil { return nil, fmt.Errorf("failed to apply: %w", err) } } actionCfg := new(action.Configuration) if err := actionCfg.Init( &genericclioptions.ConfigFlags{ KubeConfig: &kubeconfigPath, }, namespace, "secret", debugLog, ); err != nil { return nil, fmt.Errorf("failed to init action config: %w", err) } return &ReleaseCli{ namespace: namespace, name: name, cfg: actionCfg, ch: ch, values: values, labels: labels, }, nil } // Deploy will install or upgrade that release. func (cli *ReleaseCli) Deploy(ctx context.Context, timeout time.Duration, valuesAppliers ...ValuesApplier) error { values, err := cli.initValues(valuesAppliers...) if err != nil { return err } // NOTE: Maintain only one history record just in case that there are // too many secret records which causes ETCD OutOfSpace. histCli := action.NewHistory(cli.cfg) histCli.Max = 1 if _, err = histCli.Run(cli.name); err == driver.ErrReleaseNotFound { installCli := action.NewInstall(cli.cfg) installCli.CreateNamespace = true installCli.Atomic = true installCli.Namespace = cli.namespace installCli.ReleaseName = cli.name installCli.IsUpgrade = true installCli.Timeout = timeout installCli.Labels = cli.labels installCli.Wait = true release, err := installCli.RunWithContext(ctx, cli.ch, values) if err != nil { return fmt.Errorf("failed to install that release %s: %w", cli.name, err) } cli.values = release.Config return nil } upgradeCli := action.NewUpgrade(cli.cfg) upgradeCli.Namespace = cli.namespace upgradeCli.Atomic = true upgradeCli.Timeout = timeout upgradeCli.MaxHistory = 1 upgradeCli.Wait = true upgradeCli.Labels = cli.labels release, err := upgradeCli.RunWithContext(ctx, cli.name, cli.ch, values) if err != nil { return fmt.Errorf("failed to upgrade that release %s: %w", cli.name, err) } cli.values = release.Config return nil } // Uninstall deletes that release. func (cli *ReleaseCli) Uninstall() error { uninstallCli := action.NewUninstall(cli.cfg) _, err := uninstallCli.Run(cli.name) return err } // initValues is to apply valuesAppliers into copied values. Just in case that // we can rollback if valuesApplier returns error. func (cli *ReleaseCli) initValues(valuesAppliers ...ValuesApplier) (map[string]interface{}, error) { values, err := copyValues(cli.values) if err != nil { return nil, fmt.Errorf("failed to copy values: %w", err) } for _, applier := range valuesAppliers { if err := applier(values); err != nil { return nil, fmt.Errorf("failed to apply: %w", err) } } return values, nil } func copyValues(src map[string]interface{}) (map[string]interface{}, error) { data, err := json.Marshal(src) if err != nil { return nil, fmt.Errorf("failed to json.Marshal original values: %w", err) } newValues := make(map[string]interface{}) if err := json.Unmarshal(data, &newValues); err != nil { return nil, fmt.Errorf("failed to use json.Unmarshal to copy values: %w", err) } return newValues, nil }