Repository: replicatedhq/kots
Pull request #3079 (2022-08-18 21:14:48)

fix: add isSnapshotSupported to LicenseFieldValue template function
<!--  Thanks for sending a pull request!  Here are some tips for you:

1. If this is your first time, please read our contributor guidelines here:
https://github.com/replicatedhq/kots/blob/main/CONTRIBUTING.md.
2. Ensure you have added appropriate tests for your PR. For more information read here:
https://github.com/replicatedhq/kots/blob/main/CONTRIBUTING.md#testing
3. If the PR is unfinished, please mark it as a draft.
-->

#### What this PR does / why we need it:

This PR fixes the `'{{repl LicenseFieldValue "isSnapshotSupported" }}'` template function.  Currently, this template function will return an empty string since it does not appear in the switch statement.

#### Which issue(s) this PR fixes:
<!--
*Automatically closes linked issue when PR is merged.
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
-->
Fixes [SC-56285](https://app.shortcut.com/replicated/story/56285/issnapshotsupported-value-for-licensefieldvalue-template-function-returns-an-empty-string)

#### Special notes for your reviewer:
<!--
Any additional special notes for your reviewer.
-->

#### Steps to reproduce
<!---
Please provide minimum instructions for how someone can view/test/verify your changes.
-->
Upstream:
```
apiVersion: v1
kind: ConfigMap
metadata:
  name: example-configmap
data:
  isSnapshotSupported: '{{repl LicenseFieldValue "isSnapshotSupported" }}'
```

Base:
```
apiVersion: v1
kind: ConfigMap
metadata:
  name: example-configmap
data:
  isSnapshotSupported: ''
```

#### Does this PR introduce a user-facing change?
<!--
If no, just write "NONE" in the release-note block below.
If yes, a release note is required:
-->
```release-note
Fixes an issue where the [LicenseFieldValue](https://docs.replicated.com/reference/template-functions-license-context#licensefieldvalue) template function in the License Context was always returning an empty string for the `isSnapshotSupported` value.
```

#### Does this PR require documentation?
<!--
If no, just write "NONE" below.
If yes, link to the related https://github.com/replicatedhq/kots.io documentation PR:
-->
NONE


Patch:
diff --git a/pkg/app/types/helm_app.go b/pkg/app/types/helm_app.go
index 0b382a23b..6959caae6 100644
--- a/pkg/app/types/helm_app.go
+++ b/pkg/app/types/helm_app.go
@@ -3,6 +3,7 @@ package types
 import (
 	"time"
 
+	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
 	helmrelease "helm.sh/helm/v3/pkg/release"
 )
 
@@ -14,6 +15,9 @@ type HelmApp struct {
 	IsConfigurable    bool
 	ChartPath         string
 	CreationTimestamp time.Time
+
+	// TODO: This is values the user is editing on the Config screen. This is a temporary solution while we figure out the UX.
+	TempConfigValues map[string]kotsv1beta1.ConfigValue
 }
 
 func (a *HelmApp) GetID() string {
diff --git a/pkg/handlers/config.go b/pkg/handlers/config.go
index ce55feb41..a1e6c64ad 100644
--- a/pkg/handlers/config.go
+++ b/pkg/handlers/config.go
@@ -32,7 +32,6 @@ import (
 	"github.com/replicatedhq/kots/pkg/template"
 	"github.com/replicatedhq/kots/pkg/util"
 	"github.com/replicatedhq/kots/pkg/version"
-	kuberneteserrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/client-go/kubernetes/scheme"
 )
@@ -160,20 +159,7 @@ func (h *Handler) UpdateAppConfig(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		configValues, err := helm.GetTempConfigValues(helmApp)
-		if err != nil && !kuberneteserrors.IsNotFound(errors.Cause(err)) {
-			logger.Error(errors.Wrap(err, "failed to get temp config values"))
-			w.WriteHeader(http.StatusInternalServerError)
-			return
-		}
-
-		configValues.Spec.Values = updateAppConfigValues(configValues.Spec.Values, updateAppConfigRequest.ConfigGroups)
-		err = helm.SetTempConfigValues(helmApp, updateAppConfigRequest.Sequence, configValues)
-		if err != nil {
-			logger.Error(errors.Wrap(err, "failed to update temp config values"))
-			w.WriteHeader(http.StatusInternalServerError)
-			return
-		}
+		helmApp.TempConfigValues = updateAppConfigValues(helmApp.TempConfigValues, updateAppConfigRequest.ConfigGroups)
 
 		JSON(w, http.StatusOK, UpdateAppConfigResponse{Success: true})
 		return
@@ -377,7 +363,7 @@ func (h *Handler) CurrentAppConfig(w http.ResponseWriter, r *http.Request) {
 	}
 
 	appSlug := mux.Vars(r)["appSlug"]
-	sequence, err := strconv.Atoi(mux.Vars(r)["sequence"])
+	sequence, err := strconv.ParseInt(mux.Vars(r)["sequence"], 10, 64)
 	if err != nil {
 		logger.Error(err)
 		currentAppConfigResponse.Error = "failed to parse app sequence"
@@ -400,9 +386,9 @@ func (h *Handler) CurrentAppConfig(w http.ResponseWriter, r *http.Request) {
 		}
 		app = helmApp
 
-		k, err := helm.GetKotsKinds(helmApp)
+		k, err := helm.GetKotsKindsForRevision(helmApp.Release.Name, sequence)
 		if err != nil {
-			logger.Error(errors.Wrap(err, "faile to get kots kinds for helm"))
+			logger.Error(errors.Wrap(err, "failed to get kots kinds for helm"))
 			w.WriteHeader(http.StatusInternalServerError)
 			return
 		}
@@ -418,7 +404,7 @@ func (h *Handler) CurrentAppConfig(w http.ResponseWriter, r *http.Request) {
 		}
 		app = foundApp
 
-		status, err := store.GetStore().GetDownstreamVersionStatus(foundApp.ID, int64(sequence))
+		status, err := store.GetStore().GetDownstreamVersionStatus(foundApp.ID, sequence)
 		if err != nil {
 			logger.Error(err)
 			currentAppConfigResponse.Error = "failed to get downstream version status"
@@ -450,7 +436,7 @@ func (h *Handler) CurrentAppConfig(w http.ResponseWriter, r *http.Request) {
 		}
 		defer os.RemoveAll(archiveDir)
 
-		err = store.GetStore().GetAppVersionArchive(foundApp.ID, int64(sequence), archiveDir)
+		err = store.GetStore().GetAppVersionArchive(foundApp.ID, sequence, archiveDir)
 		if err != nil {
 			logger.Error(err)
 			currentAppConfigResponse.Error = "failed to get app version archive"
@@ -498,7 +484,7 @@ func (h *Handler) CurrentAppConfig(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
-	versionInfo := template.VersionInfoFromInstallation(int64(sequence)+1, app.GetIsAirgap(), kotsKinds.Installation.Spec) // sequence +1 because the sequence will be incremented on save (and we want the preview to be accurate)
+	versionInfo := template.VersionInfoFromInstallation(sequence+1, app.GetIsAirgap(), kotsKinds.Installation.Spec) // sequence +1 because the sequence will be incremented on save (and we want the preview to be accurate)
 	appInfo := template.ApplicationInfo{Slug: app.GetSlug()}
 	renderedConfig, err := kotsconfig.TemplateConfigObjects(kotsKinds.Config, configValues, license, &kotsKinds.KotsApplication, localRegistry, &versionInfo, &appInfo, kotsKinds.IdentityConfig, util.PodNamespace, false)
 	if err != nil {
diff --git a/pkg/handlers/helm.go b/pkg/handlers/helm.go
index f42ea828d..a14c6821a 100644
--- a/pkg/handlers/helm.go
+++ b/pkg/handlers/helm.go
@@ -15,7 +15,7 @@ import (
 	"github.com/replicatedhq/kots/pkg/logger"
 	"github.com/replicatedhq/kots/pkg/util"
 	yaml "github.com/replicatedhq/yaml/v3"
-	kuberneteserrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
 // IsHelmManagedResponse - response body for the is helm managed endpoint
@@ -24,10 +24,6 @@ type IsHelmManagedResponse struct {
 	IsHelmManaged bool `json:"isHelmManaged"`
 }
 
-type GetAppValuesFileResponse struct {
-	Success bool `json:"success"`
-}
-
 //  IsHelmManaged - report whether or not kots is running in helm managed mode
 func (h *Handler) IsHelmManaged(w http.ResponseWriter, r *http.Request) {
 	helmManagedResponse := IsHelmManagedResponse{
@@ -39,9 +35,6 @@ func (h *Handler) IsHelmManaged(w http.ResponseWriter, r *http.Request) {
 }
 
 func (h *Handler) GetAppValuesFile(w http.ResponseWriter, r *http.Request) {
-	getAppValuesFileResponse := GetAppValuesFileResponse{
-		Success: false,
-	}
 	appSlug := mux.Vars(r)["appSlug"]
 	helmApp := helm.GetHelmApp(appSlug)
 	if helmApp == nil {
@@ -62,13 +55,6 @@ func (h *Handler) GetAppValuesFile(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	configValues, err := helm.GetTempConfigValues(helmApp)
-	if err != nil && !kuberneteserrors.IsNotFound(errors.Cause(err)) {
-		logger.Error(errors.Wrap(err, "failed to get temp config values"))
-		w.WriteHeader(http.StatusInternalServerError)
-		return
-	}
-
 	helmChart, err := kotsbase.ParseHelmChart(appSecret.Data["chart"])
 	if err != nil {
 		logger.Error(errors.Wrap(err, "failed to parse HelmChart file"))
@@ -84,11 +70,25 @@ func (h *Handler) GetAppValuesFile(w http.ResponseWriter, r *http.Request) {
 	}
 
 	kotsKinds, err := helm.GetKotsKinds(helmApp)
-	kotsKinds.ConfigValues = configValues
+	if err != nil {
+		logger.Error(errors.Wrap(err, "failed to get kotskinds values"))
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+	// keeping this assignment out of GetKotsKinds because this is specific to file download endpoint
+	kotsKinds.ConfigValues = &kotsv1beta1.ConfigValues{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "kots.io/v1beta1",
+			Kind:       "ConfigValues",
+		},
+		Spec: kotsv1beta1.ConfigValuesSpec{
+			Values: helmApp.TempConfigValues,
+		},
+	}
 
 	renderedValues, err := kotshelm.RenderValuesFromConfig(helmApp, &kotsKinds, appSecret.Data["chart"])
 	if err != nil {
-		logger.Error(err)
+		logger.Error(errors.Wrap(err, "failed to get render values from config"))
 		w.WriteHeader(http.StatusInternalServerError)
 		return
 	}
@@ -98,11 +98,27 @@ func (h *Handler) GetAppValuesFile(w http.ResponseWriter, r *http.Request) {
 
 	mergedHelmValues, err := kotshelm.GetMergedValues(helmApp.Release.Chart.Values, intersectVals)
 	if err != nil {
-		logger.Error(err)
+		logger.Error(errors.Wrap(err, "failed to merge values with templated values"))
 		w.WriteHeader(http.StatusInternalServerError)
 		return
 	}
 
+	if kotsKinds.ConfigValues != nil {
+		v, err := kotshelm.GetConfigValuesMap(kotsKinds.ConfigValues)
+		if err != nil {
+			logger.Error(errors.Wrap(err, "failed to get app config values sub-map"))
+			w.WriteHeader(http.StatusInternalServerError)
+			return
+		}
+		m, err := kotshelm.GetMergedValues(mergedHelmValues, v)
+		if err != nil {
+			logger.Error(errors.Wrap(err, "failed to merge app config to helm values"))
+			w.WriteHeader(http.StatusInternalServerError)
+			return
+		}
+		mergedHelmValues = m
+	}
+
 	b, err := yaml.Marshal(mergedHelmValues)
 	if err != nil {
 		logger.Error(err)
@@ -110,13 +126,11 @@ func (h *Handler) GetAppValuesFile(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	getAppValuesFileResponse.Success = true
 	w.Header().Set("Content-Type", "application/octet-stream")
 	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s-values.yaml", appSlug))
 	w.Header().Set("Content-Length", strconv.Itoa(len(b)))
 	w.WriteHeader(http.StatusOK)
 	w.Write(b)
-	JSON(w, http.StatusOK, getAppValuesFileResponse)
 }
 
 func getCompatibleAppFromHelmApp(helmApp *apptypes.HelmApp) (*apptypes.App, error) {
diff --git a/pkg/handlers/license.go b/pkg/handlers/license.go
index 3348347aa..4e08e0c9e 100644
--- a/pkg/handlers/license.go
+++ b/pkg/handlers/license.go
@@ -130,7 +130,7 @@ func (h *Handler) SyncLicense(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		currentLicense, err := helm.GetChartLicenseFromSecret(helmApp)
+		currentLicense, err := helm.GetChartLicenseFromSecretOrDownload(helmApp)
 		if err != nil {
 			syncLicenseResponse.Error = "failed to get license from secret"
 			logger.Error(errors.Wrap(err, syncLicenseResponse.Error))
@@ -175,7 +175,7 @@ func (h *Handler) SyncLicense(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		err = helm.SaveChartLicenseInSecret(helmApp, latestLicense)
+		err = helm.SaveChartLicenseInSecret(helmApp, licenseData.LicenseBytes)
 		if err != nil {
 			syncLicenseResponse.Error = "failed to update helm license"
 			logger.Error(errors.Wrap(err, syncLicenseResponse.Error))
@@ -255,7 +255,7 @@ func (h *Handler) GetLicense(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		currentLicense, err := helm.GetChartLicenseFromSecret(helmApp)
+		currentLicense, err := helm.GetChartLicenseFromSecretOrDownload(helmApp)
 		if err != nil {
 			getLicenseResponse.Error = "failed to get license from secret"
 			logger.Error(errors.Wrap(err, getLicenseResponse.Error))
diff --git a/pkg/handlers/update.go b/pkg/handlers/update.go
index a1dfdcaa9..32a91a537 100644
--- a/pkg/handlers/update.go
+++ b/pkg/handlers/update.go
@@ -60,7 +60,7 @@ func (h *Handler) AppUpdateCheck(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		license, err := helm.GetChartLicenseFromSecret(helmApp)
+		license, err := helm.GetChartLicenseFromSecretOrDownload(helmApp)
 		if err != nil {
 			logger.Errorf("failed to get license for helm app: %v", err)
 			w.WriteHeader(http.StatusInternalServerError)
diff --git a/pkg/helm/cache.go b/pkg/helm/cache.go
index 85424ef5d..d85e56efb 100644
--- a/pkg/helm/cache.go
+++ b/pkg/helm/cache.go
@@ -1,7 +1,6 @@
 package helm
 
 import (
-	"bytes"
 	"context"
 	"io/ioutil"
 	"strconv"
@@ -9,9 +8,9 @@ import (
 	"time"
 
 	"github.com/pkg/errors"
+	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
 	apptypes "github.com/replicatedhq/kots/pkg/app/types"
 	"github.com/replicatedhq/kots/pkg/k8sutil"
-	kotslicense "github.com/replicatedhq/kots/pkg/license"
 	"github.com/replicatedhq/kots/pkg/logger"
 	"github.com/replicatedhq/kots/pkg/util"
 	helmrelease "helm.sh/helm/v3/pkg/release"
@@ -19,9 +18,7 @@ import (
 	kuberneteserrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/labels"
-	serializer "k8s.io/apimachinery/pkg/runtime/serializer/json"
 	"k8s.io/apimachinery/pkg/watch"
-	"k8s.io/client-go/kubernetes/scheme"
 )
 
 // TODO: Support same releases names in different namespaces
@@ -81,6 +78,9 @@ func Init(ctx context.Context) error {
 			if releaseInfo == nil {
 				continue
 			}
+			if releaseInfo.Release.Chart.Values["replicated"] == nil {
+				continue
+			}
 
 			AddHelmApp(releaseInfo.Release.Name, releaseInfo)
 		}
@@ -133,11 +133,6 @@ func helmAppFromSecret(secret *corev1.Secret) (*apptypes.HelmApp, error) {
 		return nil, errors.Wrap(err, "failed to get helm release from secret")
 	}
 
-	licenseID := GetKotsLicenseID(helmRelease)
-	if licenseID == "" { // not a kots managed chart
-		return nil, nil
-	}
-
 	version, err := strconv.ParseInt(secret.Labels["version"], 10, 64)
 	if err != nil {
 		return nil, errors.Wrap(err, "failed to parse release version")
@@ -149,6 +144,7 @@ func helmAppFromSecret(secret *corev1.Secret) (*apptypes.HelmApp, error) {
 		Version:           version,
 		CreationTimestamp: secret.CreationTimestamp.Time,
 		Namespace:         secret.Namespace,
+		TempConfigValues:  map[string]kotsv1beta1.ConfigValue{},
 	}
 
 	configSecret, err := GetChartConfigSecret(helmApp)
@@ -163,23 +159,6 @@ func helmAppFromSecret(secret *corev1.Secret) (*apptypes.HelmApp, error) {
 	_, helmApp.IsConfigurable = configSecret.Data["config"] // TODO: also check if there are any config items
 	helmApp.ChartPath = string(configSecret.Data["chartPath"])
 
-	if configSecret.Data["license"] == nil {
-		// This block does not return an error.
-		// This allows the app cache to be populated and app be accessible via Admin Console.
-		// If there is no license, the license card will be empty.
-		// License data can be healed by syncing the license from Admin Console.
-		licenseData, err := kotslicense.GetLatestLicenseForHelm(licenseID)
-		if err != nil {
-			logger.Warnf("failed to get license for helm chart %s: %v", helmRelease.Name, err)
-		} else {
-			configSecret.Data["license"] = licenseData.LicenseBytes
-			err := UpdateChartConfig(configSecret)
-			if err != nil {
-				logger.Warnf("failed to save license for helm chart %s: %v", helmRelease.Name, err)
-			}
-		}
-	}
-
 	return helmApp, nil
 }
 
@@ -250,13 +229,12 @@ func watchSecrets(ctx context.Context, namespace string, labelSelector string) e
 				if helmApp == nil {
 					break
 				}
+				if helmApp.Release.Chart.Values["replicated"] == nil {
+					break
+				}
 
 				removeFromCachedUpdates(helmApp.ChartPath, helmApp.Release.Chart.Metadata.Version)
 
-				if err := finalizeChartConfig(helmApp); err != nil {
-					logger.Errorf("failed to copy chart config from temp secret into helm release: %v", err)
-				}
-
 				logger.Debugf("adding secret %s to cache", secret.Name)
 				AddHelmApp(helmApp.Release.Name, helmApp)
 
@@ -294,77 +272,3 @@ func watchSecrets(ctx context.Context, namespace string, labelSelector string) e
 		time.Sleep(time.Second * 5)
 	}
 }
-
-// Find replicated secret in helm release's templates, decode it, add config values to it, save helm release
-func finalizeChartConfig(helmApp *apptypes.HelmApp) error {
-	configValues, err := GetTempConfigValues(helmApp)
-	if err != nil {
-		if kuberneteserrors.IsNotFound(errors.Cause(err)) {
-			return nil
-		}
-		return errors.Wrap(err, "failed to get temp config values")
-	}
-
-	s := serializer.NewYAMLSerializer(serializer.DefaultMetaFactory, scheme.Scheme, scheme.Scheme)
-	var configValuesBuffer bytes.Buffer
-	if err := s.Encode(configValues, &configValuesBuffer); err != nil {
-		return errors.Wrap(err, "failed to encode config values")
-	}
-	configValuesData := configValuesBuffer.Bytes()
-
-	for _, template := range helmApp.Release.Chart.Templates {
-		if template.Name != "templates/_replicated/secret.yaml" {
-			continue
-		}
-
-		decode := scheme.Codecs.UniversalDeserializer().Decode
-		decoded, gvk, err := decode(template.Data, nil, nil)
-		if err != nil {
-			return errors.Wrap(err, "failed to decode replicated secret template")
-		}
-
-		if gvk.Group != "" || gvk.Version != "v1" || gvk.Kind != "Secret" {
-			return errors.Errorf("%q is not a valid Secret GVK", gvk.String())
-		}
-
-		replicatedSecret := decoded.(*corev1.Secret)
-		if _, ok := replicatedSecret.Data["configValues"]; ok {
-			return errors.Errorf("replicated secret for chart %s in ns %s already has configValues", helmApp.Release.Name, helmApp.Namespace)
-		}
-
-		replicatedSecret.Data["configValues"] = configValuesData
-
-		var replicatedSecretData bytes.Buffer
-		if err := s.Encode(replicatedSecret, &replicatedSecretData); err != nil {
-			return errors.Wrap(err, "failed to encode config values")
-		}
-
-		template.Data = replicatedSecretData.Bytes()
-		break
-	}
-
-	// Deleting first because saving Helm secret will send another update event here
-	if err := deleteTempConfigValues(helmApp); err != nil {
-		return errors.Wrap(err, "failed to delete temp config values")
-	}
-
-	if err := saveHelmApp(helmApp); err != nil {
-		return errors.Wrap(err, "failed to save helm release")
-	}
-
-	configSecret, err := GetChartConfigSecret(helmApp)
-	if err != nil {
-		return errors.Wrap(err, "failed to get config secret for license update")
-	}
-
-	if configSecret == nil {
-		return errors.Errorf("secret not found")
-	}
-
-	configSecret.Data["configValues"] = configValuesData
-	if err := UpdateChartConfig(configSecret); err != nil {
-		return errors.Wrap(err, "failed to update config secret")
-	}
-
-	return nil
-}
diff --git a/pkg/helm/charts.go b/pkg/helm/charts.go
index 761878961..ae1962c00 100644
--- a/pkg/helm/charts.go
+++ b/pkg/helm/charts.go
@@ -21,13 +21,15 @@ import (
 	apptypes "github.com/replicatedhq/kots/pkg/app/types"
 	"github.com/replicatedhq/kots/pkg/k8sutil"
 	"github.com/replicatedhq/kots/pkg/kotsutil"
+	kotslicense "github.com/replicatedhq/kots/pkg/license"
 	"github.com/replicatedhq/kots/pkg/logger"
+	"github.com/replicatedhq/kots/pkg/util"
+	"gopkg.in/yaml.v2"
 	helmrelease "helm.sh/helm/v3/pkg/release"
 	corev1 "k8s.io/api/core/v1"
 	kuberneteserrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/labels"
-	serializer "k8s.io/apimachinery/pkg/runtime/serializer/json"
 )
 
 var (
@@ -223,50 +225,58 @@ func GetChartConfigSecret(helmApp *apptypes.HelmApp) (*corev1.Secret, error) {
 	return secret, nil
 }
 
-func UpdateChartConfig(secret *corev1.Secret) error {
-	clientSet, err := k8sutil.GetClientset()
+func GetChartLicenseFromSecretOrDownload(helmApp *apptypes.HelmApp) (*kotsv1beta1.License, error) {
+	configSecret, err := GetChartConfigSecret(helmApp)
 	if err != nil {
-		return errors.Wrap(err, "failed to get clientset")
+		return nil, errors.Wrap(err, "failed to get helm config secret")
 	}
 
-	_, err = clientSet.CoreV1().Secrets(secret.Namespace).Update(context.TODO(), secret, metav1.UpdateOptions{})
-	if err != nil {
-		// TODO: retry on IsConflict
-		return errors.Wrap(err, "failed to update secret")
+	if configSecret == nil {
+		return nil, errors.Errorf("no config secret found for release %s", helmApp.Release.Name)
 	}
 
-	return nil
-}
+	if licenseData := configSecret.Data["license"]; len(licenseData) > 0 {
+		decode := kotsscheme.Codecs.UniversalDeserializer().Decode
+		obj, gvk, err := decode(licenseData, nil, nil)
+		if err != nil {
+			return nil, errors.Wrap(err, "failed to decode license data")
+		}
 
-func GetChartLicenseFromSecret(helmApp *apptypes.HelmApp) (*kotsv1beta1.License, error) {
-	secret, err := GetChartConfigSecret(helmApp)
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to get helm config secret")
+		if gvk.Group != "kots.io" || gvk.Version != "v1beta1" || gvk.Kind != "License" {
+			return nil, errors.Errorf("unexpected GVK: %s", gvk.String())
+		}
+
+		return obj.(*kotsv1beta1.License), nil
 	}
 
-	if secret == nil {
-		return nil, nil
+	license, err := downloadAppLicense(helmApp)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to download app license")
 	}
 
-	licenseData := secret.Data["license"]
-	if len(licenseData) == 0 {
-		return nil, nil
+	return license, nil
+}
+
+func downloadAppLicense(helmApp *apptypes.HelmApp) (*kotsv1beta1.License, error) {
+	licenseID := GetKotsLicenseID(&helmApp.Release)
+	if licenseID == "" {
+		return nil, errors.Errorf("no license and no license ID found for release %s", helmApp.Release.Name)
 	}
 
-	decode := kotsscheme.Codecs.UniversalDeserializer().Decode
-	obj, gvk, err := decode(licenseData, nil, nil)
+	licenseData, err := kotslicense.GetLatestLicenseForHelm(licenseID)
 	if err != nil {
-		return nil, errors.Wrap(err, "failed to decode license data")
+		return nil, errors.Wrapf(err, "failed to get license for helm chart %s", helmApp.Release.Name)
 	}
 
-	if gvk.Group != "kots.io" || gvk.Version != "v1beta1" || gvk.Kind != "License" {
-		return nil, errors.Errorf("unexpected GVK: %s", gvk.String())
+	if err := SaveChartLicenseInSecret(helmApp, licenseData.LicenseBytes); err != nil {
+		return nil, errors.Wrapf(err, "failed save license in config for chart %s", helmApp.Release.Name)
 	}
 
-	return obj.(*kotsv1beta1.License), nil
+	return licenseData.License, nil
 }
 
-func SaveChartLicenseInSecret(helmApp *apptypes.HelmApp, license *kotsv1beta1.License) error {
+// Always save original data returned from the server without remarshaling.
+func SaveChartLicenseInSecret(helmApp *apptypes.HelmApp, licenseData []byte) error {
 	configSecretMutex.Lock()
 	defer configSecretMutex.Unlock()
 
@@ -279,16 +289,17 @@ func SaveChartLicenseInSecret(helmApp *apptypes.HelmApp, license *kotsv1beta1.Li
 		return errors.Errorf("secret not found")
 	}
 
-	s := serializer.NewYAMLSerializer(serializer.DefaultMetaFactory, kotsscheme.Scheme, kotsscheme.Scheme)
-	var b bytes.Buffer
-	if err := s.Encode(license, &b); err != nil {
-		return errors.Wrap(err, "failed to encode kots license")
-	}
+	secret.Data["license"] = licenseData
 
-	secret.Data["license"] = b.Bytes()
+	clientSet, err := k8sutil.GetClientset()
+	if err != nil {
+		return errors.Wrap(err, "failed to get clientset")
+	}
 
-	if err := UpdateChartConfig(secret); err != nil {
-		return errors.Wrap(err, "failed to update config secret with new license")
+	_, err = clientSet.CoreV1().Secrets(secret.Namespace).Update(context.TODO(), secret, metav1.UpdateOptions{})
+	if err != nil {
+		// TODO: retry on IsConflict
+		return errors.Wrap(err, "failed to update secret")
 	}
 
 	return nil
@@ -307,7 +318,13 @@ func GetKotsKinds(helmApp *apptypes.HelmApp) (kotsutil.KotsKinds, error) {
 	}
 
 	licenseData := secret.Data["license"]
-	if len(licenseData) != 0 {
+	if len(licenseData) == 0 {
+		license, err := downloadAppLicense(helmApp)
+		if err != nil {
+			return kotsKinds, errors.Wrap(err, "failed to download license")
+		}
+		kotsKinds.License = license
+	} else {
 		license, err := kotsutil.LoadLicenseFromBytes(licenseData)
 		if err != nil {
 			return kotsKinds, errors.Wrap(err, "failed to load license from data")
@@ -336,43 +353,79 @@ func GetKotsKinds(helmApp *apptypes.HelmApp) (kotsutil.KotsKinds, error) {
 	return kotsKinds, nil
 }
 
-// TODO: this function is not threadsafe
-func saveHelmApp(helmApp *apptypes.HelmApp) error {
+func GetKotsKindsForRevision(releaseName string, revision int64) (kotsutil.KotsKinds, error) {
+	kotsKinds := kotsutil.EmptyKotsKinds()
+
 	clientSet, err := k8sutil.GetClientset()
 	if err != nil {
-		return errors.Wrap(err, "failed to get clientset")
+		return kotsKinds, errors.Wrap(err, "failed to get clientset")
 	}
 
 	selectorLabels := map[string]string{
 		"owner":   "helm",
-		"name":    helmApp.Release.Name,
-		"version": helmApp.Release.Chart.Metadata.Version,
+		"version": fmt.Sprintf("%d", revision),
+		"name":    releaseName,
 	}
 	listOpts := metav1.ListOptions{
 		LabelSelector: labels.SelectorFromSet(selectorLabels).String(),
 	}
 
-	secrets, err := clientSet.CoreV1().Secrets(helmApp.Namespace).List(context.TODO(), listOpts)
+	secrets, err := clientSet.CoreV1().Secrets(util.PodNamespace).List(context.TODO(), listOpts)
 	if err != nil {
-		return errors.Wrapf(err, "failed to find release secret with options %v", selectorLabels)
+		return kotsKinds, errors.Wrap(err, "failed to list secrets")
 	}
 
 	if len(secrets.Items) != 1 {
-		return errors.Wrapf(err, "expected 1 secret but found %d with options %v", len(secrets.Items), selectorLabels)
+		return kotsKinds, errors.Errorf("expected to match 1 secret, but found %d", len(secrets.Items))
 	}
 
-	data, err := HelmReleaseToSecretData(&helmApp.Release)
+	chartSecret := secrets.Items[0]
+	helmApp, err := helmAppFromSecret(&chartSecret)
 	if err != nil {
-		return errors.Wrap(err, "failed to marshal release to helm data")
+		return kotsKinds, errors.Wrap(err, "failed to convert secret to helm app")
 	}
 
-	secret := secrets.Items[0]
-	secret.Data["release"] = data
+	license, err := GetChartLicenseFromSecretOrDownload(helmApp)
+	if err != nil {
+		return kotsKinds, errors.Wrap(err, "failed to get license")
+	}
+	kotsKinds.License = license
+
+	// "Config" object is in the template secret.
+	for _, template := range helmApp.Release.Chart.Templates {
+		if template.Name != "templates/_replicated/secret.yaml" {
+			continue
+		}
 
-	_, err = clientSet.CoreV1().Secrets(helmApp.Namespace).Update(context.TODO(), &secret, metav1.UpdateOptions{})
+		// Can't use client-go decoder here because raw data contains templates.
+		secret := map[string]interface{}{}
+		if err := yaml.Unmarshal(template.Data, &secret); err != nil {
+			return kotsKinds, errors.Wrap(err, "failed to unmarshal secret data")
+		}
+		encodedConfig := util.GetValueFromMapPath(secret, []string{"data", "config"})
+
+		configData, err := util.Base64DecodeInterface(encodedConfig)
+		if err != nil {
+			return kotsKinds, errors.Wrap(err, "failed to base64 decode config from chart templates")
+		}
+		kotsKinds.Config, err = kotsutil.LoadConfigFromBytes(configData)
+		if err != nil {
+			return kotsKinds, errors.Wrap(err, "failed to get config from chart templates")
+		}
+
+		break
+	}
+
+	encodedConfigValues := util.GetValueFromMapPath(helmApp.Release.Config, []string{"replicated", "app", "configValues"})
+	configValuesData, err := util.Base64DecodeInterface(encodedConfigValues)
 	if err != nil {
-		return errors.Wrap(err, "failed to update release secret")
+		return kotsKinds, errors.Wrap(err, "failed to base64 decode config values from chart release")
 	}
 
-	return nil
+	kotsKinds.ConfigValues, err = kotsutil.LoadConfigValuesFromBytes(configValuesData)
+	if err != nil {
+		return kotsKinds, errors.Wrap(err, "failed to get config values from chart values")
+	}
+
+	return kotsKinds, nil
 }
diff --git a/pkg/helm/config_values.go b/pkg/helm/config_values.go
deleted file mode 100644
index 82a159b36..000000000
--- a/pkg/helm/config_values.go
+++ /dev/null
@@ -1,139 +0,0 @@
-package helm
-
-import (
-	"bytes"
-	"context"
-	"fmt"
-
-	"github.com/pkg/errors"
-	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
-	kotsscheme "github.com/replicatedhq/kots/kotskinds/client/kotsclientset/scheme"
-	apptypes "github.com/replicatedhq/kots/pkg/app/types"
-	"github.com/replicatedhq/kots/pkg/k8sutil"
-	corev1 "k8s.io/api/core/v1"
-	kuberneteserrors "k8s.io/apimachinery/pkg/api/errors"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	serializer "k8s.io/apimachinery/pkg/runtime/serializer/json"
-	"k8s.io/client-go/kubernetes/scheme"
-)
-
-func init() {
-	kotsscheme.AddToScheme(scheme.Scheme)
-}
-
-func GetTempConfigValues(helmApp *apptypes.HelmApp) (*kotsv1beta1.ConfigValues, error) {
-	clientSet, err := k8sutil.GetClientset()
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to get clientset")
-	}
-
-	configValues := &kotsv1beta1.ConfigValues{
-		TypeMeta: metav1.TypeMeta{
-			APIVersion: "kots.io/v1beta1",
-			Kind:       "ConfigValues",
-		},
-		Spec: kotsv1beta1.ConfigValuesSpec{
-			Values: map[string]kotsv1beta1.ConfigValue{},
-		},
-	}
-
-	// Note that this must be chart name, not release name
-	secretName := fmt.Sprintf("kots-%s-temp-values", helmApp.Release.Chart.Name())
-	secret, err := clientSet.CoreV1().Secrets(helmApp.Namespace).Get(context.TODO(), secretName, metav1.GetOptions{})
-	if err != nil {
-		return configValues, errors.Wrap(err, "failed to get secret")
-	}
-
-	encodedValues, ok := secret.Data["values"]
-	if !ok {
-		return configValues, nil
-	}
-
-	decode := scheme.Codecs.UniversalDeserializer().Decode
-	decoded, gvk, err := decode(encodedValues, nil, nil)
-	if err != nil {
-		return configValues, errors.Wrap(err, "failed to decode config values")
-	}
-
-	if gvk.Group != "kots.io" || gvk.Version != "v1beta1" || gvk.Kind != "ConfigValues" {
-		return configValues, errors.Errorf("%q is not a valid ConfigValues GVK", gvk.String())
-	}
-
-	return decoded.(*kotsv1beta1.ConfigValues), nil
-}
-
-// TODO: this function is not thread safe
-func SetTempConfigValues(helmApp *apptypes.HelmApp, revision int64, configValues *kotsv1beta1.ConfigValues) error {
-	s := serializer.NewYAMLSerializer(serializer.DefaultMetaFactory, scheme.Scheme, scheme.Scheme)
-	var b bytes.Buffer
-	if err := s.Encode(configValues, &b); err != nil {
-		return errors.Wrap(err, "failed to encode config values")
-	}
-
-	clientSet, err := k8sutil.GetClientset()
-	if err != nil {
-		return errors.Wrap(err, "failed to get clientset")
-	}
-
-	// Note that this must be chart name, not release name
-	secretName := fmt.Sprintf("kots-%s-temp-values", helmApp.Release.Chart.Name())
-	secret, err := clientSet.CoreV1().Secrets(helmApp.Namespace).Get(context.TODO(), secretName, metav1.GetOptions{})
-	if err != nil && !kuberneteserrors.IsNotFound(err) {
-		return errors.Wrap(err, "failed to get secret")
-	}
-
-	if kuberneteserrors.IsNotFound(err) {
-		secret = &corev1.Secret{
-			TypeMeta: metav1.TypeMeta{
-				APIVersion: "v1",
-				Kind:       "Secret",
-			},
-			ObjectMeta: metav1.ObjectMeta{
-				Name:      secretName,
-				Namespace: helmApp.Namespace,
-				// Annotations: secretAnnotations,
-				Labels: map[string]string{
-					"releaseName":  helmApp.Release.Name,
-					"baseRevision": fmt.Sprintf("%d", revision),
-				},
-			},
-			Type: corev1.SecretTypeOpaque,
-			Data: map[string][]byte{
-				"values": b.Bytes(),
-			},
-		}
-
-		_, err = clientSet.CoreV1().Secrets(helmApp.Namespace).Create(context.TODO(), secret, metav1.CreateOptions{})
-		if err != nil {
-			return errors.Wrap(err, "failed to create config values secret")
-		}
-
-		return nil
-	}
-
-	secret.ObjectMeta.Labels["baseRevision"] = fmt.Sprintf("%d", revision)
-	secret.Data["values"] = b.Bytes()
-
-	_, err = clientSet.CoreV1().Secrets(helmApp.Namespace).Update(context.TODO(), secret, metav1.UpdateOptions{})
-	if err != nil {
-		return errors.Wrap(err, "failed to update dockerhub secret")
-	}
-
-	return nil
-}
-
-func deleteTempConfigValues(helmApp *apptypes.HelmApp) error {
-	clientSet, err := k8sutil.GetClientset()
-	if err != nil {
-		return errors.Wrap(err, "failed to get clientset")
-	}
-
-	// Note that this must be chart name, not release name
-	secretName := fmt.Sprintf("kots-%s-temp-values", helmApp.Release.Chart.Name())
-	err = clientSet.CoreV1().Secrets(helmApp.Namespace).Delete(context.TODO(), secretName, metav1.DeleteOptions{})
-	if err != nil && !kuberneteserrors.IsNotFound(err) {
-		return errors.Wrap(err, "failed to delete secret")
-	}
-
-	return nil
-}
diff --git a/pkg/helm/helm.go b/pkg/helm/helm.go
index 5e86440f4..d019f9fd9 100644
--- a/pkg/helm/helm.go
+++ b/pkg/helm/helm.go
@@ -1,6 +1,7 @@
 package helm
 
 import (
+	"bytes"
 	"encoding/base64"
 	"encoding/json"
 	"fmt"
@@ -23,6 +24,8 @@ import (
 	helmval "helm.sh/helm/v3/pkg/cli/values"
 	"helm.sh/helm/v3/pkg/helmpath"
 	helmregistry "helm.sh/helm/v3/pkg/registry"
+	serializer "k8s.io/apimachinery/pkg/runtime/serializer/json"
+	"k8s.io/client-go/kubernetes/scheme"
 )
 
 func RenderValuesFromConfig(helmApp *apptypes.HelmApp, kotsKinds *kotsutil.KotsKinds, chart []byte) (map[string]interface{}, error) {
@@ -43,11 +46,11 @@ func RenderValuesFromConfig(helmApp *apptypes.HelmApp, kotsKinds *kotsutil.KotsK
 
 	mergedValues := kotsHelmChart.Spec.Values
 	for _, optionalValues := range kotsHelmChart.Spec.OptionalValues {
-		parsedBool, err := strconv.ParseBool(optionalValues.When)
+		include, err := strconv.ParseBool(optionalValues.When)
 		if err != nil {
 			return nil, err
 		}
-		if !parsedBool {
+		if !include {
 			continue
 		}
 		if optionalValues.RecursiveMerge {
@@ -140,3 +143,21 @@ func CreateHelmRegistryCreds(username string, password string, url string) error
 
 	return nil
 }
+
+func GetConfigValuesMap(configValues *kotsv1beta1.ConfigValues) (map[string]interface{}, error) {
+	s := serializer.NewYAMLSerializer(serializer.DefaultMetaFactory, scheme.Scheme, scheme.Scheme)
+	var configValuesBuffer bytes.Buffer
+	if err := s.Encode(configValues, &configValuesBuffer); err != nil {
+		return nil, errors.Wrap(err, "failed to encode config values")
+	}
+
+	configValuesMap := map[string]interface{}{
+		"replicated": map[string]interface{}{
+			"app": map[string][]byte{ // "byte" for base64 encoding
+				"configValues": configValuesBuffer.Bytes(),
+			},
+		},
+	}
+
+	return configValuesMap, nil
+}
diff --git a/pkg/supportbundle/spec.go b/pkg/supportbundle/spec.go
index abdc0889b..01d00e142 100644
--- a/pkg/supportbundle/spec.go
+++ b/pkg/supportbundle/spec.go
@@ -461,7 +461,7 @@ func getDefaultDynamicCollectors(app apptypes.AppType, imageName string, pullSec
 			logger.Errorf("Failed to load license data from store: %v", err)
 		}
 	case *apptypes.HelmApp:
-		license, err = helm.GetChartLicenseFromSecret(a)
+		license, err = helm.GetChartLicenseFromSecretOrDownload(a)
 		if err != nil {
 			logger.Errorf("Failed to load license data from helm: %v", err)
 		}
diff --git a/pkg/supportbundle/supportbundle.go b/pkg/supportbundle/supportbundle.go
index c2c08bdf6..72811a8fc 100644
--- a/pkg/supportbundle/supportbundle.go
+++ b/pkg/supportbundle/supportbundle.go
@@ -204,7 +204,7 @@ func getKotsKindsForApp(app *apptypes.App, sequence int64) (*kotsutil.KotsKinds,
 }
 
 func getKotsKindsForHelmApp(app *apptypes.HelmApp) (*kotsutil.KotsKinds, error) {
-	license, err := helm.GetChartLicenseFromSecret(app)
+	license, err := helm.GetChartLicenseFromSecretOrDownload(app)
 	if err != nil {
 		return nil, errors.Wrap(err, "failed to get license from secret")
 	}
diff --git a/pkg/template/license_context.go b/pkg/template/license_context.go
index 0fbb5e5e0..a6c232660 100644
--- a/pkg/template/license_context.go
+++ b/pkg/template/license_context.go
@@ -31,6 +31,8 @@ func (ctx licenseCtx) licenseFieldValue(name string) string {
 	// Update docs at https://github.com/replicatedhq/kots.io/blob/main/content/reference/template-functions/license-context.md
 	// when adding new values
 	switch name {
+	case "isSnapshotSupported":
+		return strconv.FormatBool(ctx.License.Spec.IsSnapshotSupported)
 	case "isGitOpsSupported":
 		return strconv.FormatBool(ctx.License.Spec.IsGitOpsSupported)
 	case "isSupportBundleUploadSupported":
diff --git a/pkg/util/util.go b/pkg/util/util.go
index 4fe5f9150..385a37a05 100644
--- a/pkg/util/util.go
+++ b/pkg/util/util.go
@@ -3,12 +3,15 @@ package util
 import (
 	"bytes"
 	crand "crypto/rand"
+	"encoding/base64"
 	"fmt"
 	"math/big"
 	rand "math/rand"
 	"net/url"
 	"os"
 	"time"
+
+	"github.com/pkg/errors"
 )
 
 func init() {
@@ -161,3 +164,57 @@ func HomeDir() string {
 func IsHelmManaged() bool {
 	return os.Getenv("IS_HELM_MANAGED") == "true"
 }
+
+func GetValueFromMapPath(m interface{}, path []string) interface{} {
+	if len(path) == 0 {
+		return nil
+	}
+
+	key := path[0]
+	if ms, ok := m.(map[string]interface{}); ok {
+		for k, v := range ms {
+			if k != key {
+				continue
+			}
+			if len(path) == 1 {
+				return v
+			}
+			return GetValueFromMapPath(v, path[1:])
+		}
+		return nil
+	}
+
+	if mi, ok := m.(map[interface{}]interface{}); ok {
+		for k, v := range mi {
+			if s, ok := k.(string); !ok || s != key {
+				continue
+			}
+			if len(path) == 1 {
+				return v
+			}
+			return GetValueFromMapPath(v, path[1:])
+		}
+		return nil
+	}
+
+	return nil
+}
+
+func Base64DecodeInterface(d interface{}) ([]byte, error) {
+	var bytes []byte
+	switch d := d.(type) {
+	case string:
+		bytes = []byte(d)
+	case []byte:
+		bytes = d
+	default:
+		return nil, errors.Errorf("cannot base64 decode %T", d)
+	}
+
+	decoded := make([]byte, base64.StdEncoding.DecodedLen(len(bytes)))
+	n, err := base64.StdEncoding.Decode(decoded, []byte(bytes))
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to bse64 decode interface data")
+	}
+	return decoded[:n], nil
+}


Test patch:
diff --git a/pkg/template/license_context_test.go b/pkg/template/license_context_test.go
index f19f2a828..5c9a812a5 100644
--- a/pkg/template/license_context_test.go
+++ b/pkg/template/license_context_test.go
@@ -115,6 +115,16 @@ func TestLicenseCtx_licenseFieldValue(t *testing.T) {
 			fieldName: "isIdentityServiceSupported",
 			want:      "true",
 		},
+		{
+			name: "built-in isSnapshotSupported",
+			License: &kotsv1beta1.License{
+				Spec: kotsv1beta1.LicenseSpec{
+					IsSnapshotSupported: true,
+				},
+			},
+			fieldName: "isSnapshotSupported",
+			want:      "true",
+		},
 		{
 			name: "built-in isGeoaxisSupported",
 			License: &kotsv1beta1.License{
diff --git a/pkg/util/util_test.go b/pkg/util/util_test.go
index 660b7764b..f5c34c98b 100644
--- a/pkg/util/util_test.go
+++ b/pkg/util/util_test.go
@@ -183,3 +183,98 @@ func TestCompareStringArrays(t *testing.T) {
 		})
 	}
 }
+
+func TestGetValueFromMapPath(t *testing.T) {
+	tests := []struct {
+		name   string
+		object interface{}
+		path   []string
+		want   interface{}
+	}{
+		{
+			name: "empty path",
+			object: map[string]interface{}{
+				"key": "val",
+			},
+			path: []string{},
+			want: nil,
+		},
+		{
+			name:   "not a map",
+			object: 5,
+			path:   []string{"key1", "key2"},
+			want:   nil,
+		},
+		{
+			name: "valid path",
+			object: map[string]interface{}{
+				"key1": map[interface{}]interface{}{
+					"key2": map[string]interface{}{
+						"key3": "abc",
+					},
+				},
+			},
+			path: []string{"key1", "key2", "key3"},
+			want: "abc",
+		},
+		{
+			name: "invalid path",
+			object: map[string]interface{}{
+				"key1": map[interface{}]interface{}{
+					"key2": map[string]interface{}{
+						"key3": "abc",
+					},
+				},
+			},
+			path: []string{"key1", "key2", "key4"},
+			want: nil,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			req := require.New(t)
+			got := GetValueFromMapPath(tt.object, tt.path)
+			req.Equal(tt.want, got)
+		})
+	}
+}
+
+func TestBase64DecodeInterface(t *testing.T) {
+	tests := []struct {
+		name    string
+		encoded interface{}
+		want    []byte
+		isError bool
+	}{
+		{
+			name:    "test string",
+			encoded: "YWJj", // "abc"
+			want:    []byte("abc"),
+			isError: false,
+		},
+		{
+			name:    "test bytes",
+			encoded: []byte("eHl6"), // "xyz"
+			want:    []byte("xyz"),
+			isError: false,
+		},
+		{
+			name:    "test invalid",
+			encoded: 5,
+			want:    nil,
+			isError: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			req := require.New(t)
+			got, err := Base64DecodeInterface(tt.encoded)
+			if tt.isError {
+				req.Error(err)
+			} else {
+				req.NoError(err)
+			}
+			req.Equal(tt.want, got)
+		})
+	}
+}