Repository: replicatedhq/kots
Pull request #3525 (2022-12-21 18:05:17)

fix: airgap releases with required config items
<!--  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 reapplies the changes from https://github.com/replicatedhq/kots/pull/3452 along with a change that fixes an airgap upload issue when there are required config values. This is the diff that is relevant to the bug fix: https://github.com/replicatedhq/kots/compare/main...ed18c6b
* Additionally, this PR refactors `WriteMidstream` to a common function in order to remove duplicate code. This commit contains the refactor: https://github.com/replicatedhq/kots/pull/3525/commits/88b5b99c2c557b56e90dc9acabb57b6e41a55317


#### 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-55103](https://app.shortcut.com/replicated/story/55103/cannot-deploy-a-helm-chart-using-kots-install-when-there-are-required-fields)

#### Special notes for your reviewer:
<!--
Any additional special notes for your reviewer.
-->

Used the modified `RegistrySettings` struct as the general type for registry settings instead of having other duplicate structs elsewhere containing the same data.

#### Steps to reproduce
<!---
Please provide minimum instructions for how someone can view/test/verify your changes.
-->

Install an application containing a helm chart with required values, but no defaults.  Add a config option that maps to this value, which is also required and does not have a default value.  The application should install successfully after the user provides the required configuration.

#### 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 that caused the license upload to fail for applications that include Helm charts with [required](https://helm.sh/docs/howto/charts_tips_and_tricks/#using-the-required-function) values missing from configuration
```

#### 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/cmd/kots/cli/pull.go b/cmd/kots/cli/pull.go
index d2c2b08e5..1388be519 100644
--- a/cmd/kots/cli/pull.go
+++ b/cmd/kots/cli/pull.go
@@ -12,6 +12,7 @@ import (
 	"github.com/replicatedhq/kots/pkg/k8sutil"
 	"github.com/replicatedhq/kots/pkg/logger"
 	"github.com/replicatedhq/kots/pkg/pull"
+	registrytypes "github.com/replicatedhq/kots/pkg/registry/types"
 	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
 )
@@ -68,8 +69,8 @@ func PullCmd() *cobra.Command {
 				CreateAppDir:        true,
 				SkipHelmChartCheck:  true, // this check cannot be performed from CLI because previous release is not available
 				RewriteImages:       v.GetBool("rewrite-images"),
-				RewriteImageOptions: pull.RewriteImageOptions{
-					Host:      v.GetString("registry-endpoint"),
+				RewriteImageOptions: registrytypes.RegistrySettings{
+					Hostname:  v.GetString("registry-endpoint"),
 					Namespace: v.GetString("image-namespace"),
 					Username:  v.GetString("registry-username"),
 					Password:  v.GetString("registry-password"),
diff --git a/pkg/airgap/airgap.go b/pkg/airgap/airgap.go
index 582c3394e..3f0a77b3c 100644
--- a/pkg/airgap/airgap.go
+++ b/pkg/airgap/airgap.go
@@ -220,8 +220,8 @@ func CreateAppFromAirgap(opts CreateAirgapAppOpts) (finalError error) {
 		ExcludeAdminConsole: true,
 		RewriteImages:       true,
 		ReportWriter:        pipeWriter,
-		RewriteImageOptions: pull.RewriteImageOptions{
-			Host:       opts.RegistryHost,
+		RewriteImageOptions: registrytypes.RegistrySettings{
+			Hostname:   opts.RegistryHost,
 			Namespace:  opts.RegistryNamespace,
 			Username:   opts.RegistryUsername,
 			Password:   opts.RegistryPassword,
@@ -234,7 +234,9 @@ func CreateAppFromAirgap(opts CreateAirgapAppOpts) (finalError error) {
 	}
 
 	if _, err := pull.Pull(fmt.Sprintf("replicated://%s", license.Spec.AppSlug), pullOptions); err != nil {
-		return errors.Wrap(err, "failed to pull")
+		if errors.Cause(err) != pull.ErrConfigNeeded {
+			return errors.Wrap(err, "failed to pull")
+		}
 	}
 
 	if err := store.GetStore().AddAppToAllDownstreams(opts.PendingApp.ID); err != nil {
diff --git a/pkg/airgap/update.go b/pkg/airgap/update.go
index 51faced21..bf3b02bb3 100644
--- a/pkg/airgap/update.go
+++ b/pkg/airgap/update.go
@@ -47,7 +47,11 @@ func UpdateAppFromAirgap(a *apptypes.App, airgapBundlePath string, deploy bool,
 	defer os.RemoveAll(airgapRoot)
 
 	err = UpdateAppFromPath(a, airgapRoot, airgapBundlePath, deploy, skipPreflights, skipCompatibilityCheck)
-	return errors.Wrap(err, "failed to update app")
+	if err != nil {
+		return errors.Wrap(err, "failed to update app")
+	}
+
+	return nil
 }
 
 func UpdateAppFromPath(a *apptypes.App, airgapRoot string, airgapBundlePath string, deploy bool, skipPreflights bool, skipCompatibilityCheck bool) error {
@@ -137,36 +141,36 @@ func UpdateAppFromPath(a *apptypes.App, airgapRoot string, airgapBundlePath stri
 		return errors.Wrap(err, "failed to get stat identity config file")
 	}
 
+	if err := pull.CleanBaseArchive(archiveDir); err != nil {
+		return errors.Wrap(err, "failed to clean base archive")
+	}
+
 	pullOptions := pull.PullOptions{
-		LicenseObj:          license,
-		Namespace:           appNamespace,
-		ConfigFile:          filepath.Join(archiveDir, "upstream", "userdata", "config.yaml"),
-		IdentityConfigFile:  identityConfigFile,
-		AirgapRoot:          airgapRoot,
-		AirgapBundle:        airgapBundlePath,
-		InstallationFile:    filepath.Join(archiveDir, "upstream", "userdata", "installation.yaml"),
-		UpdateCursor:        beforeKotsKinds.Installation.Spec.UpdateCursor,
-		RootDir:             archiveDir,
-		ExcludeKotsKinds:    true,
-		ExcludeAdminConsole: true,
-		CreateAppDir:        false,
-		ReportWriter:        pipeWriter,
-		Silent:              true,
-		RewriteImages:       true,
-		RewriteImageOptions: pull.RewriteImageOptions{
-			Host:       registrySettings.Hostname,
-			Namespace:  registrySettings.Namespace,
-			Username:   registrySettings.Username,
-			Password:   registrySettings.Password,
-			IsReadOnly: registrySettings.IsReadOnly,
-		},
+		LicenseObj:             license,
+		Namespace:              appNamespace,
+		ConfigFile:             filepath.Join(archiveDir, "upstream", "userdata", "config.yaml"),
+		IdentityConfigFile:     identityConfigFile,
+		AirgapRoot:             airgapRoot,
+		AirgapBundle:           airgapBundlePath,
+		InstallationFile:       filepath.Join(archiveDir, "upstream", "userdata", "installation.yaml"),
+		UpdateCursor:           beforeKotsKinds.Installation.Spec.UpdateCursor,
+		RootDir:                archiveDir,
+		ExcludeKotsKinds:       true,
+		ExcludeAdminConsole:    true,
+		CreateAppDir:           false,
+		ReportWriter:           pipeWriter,
+		Silent:                 true,
+		RewriteImages:          true,
+		RewriteImageOptions:    registrySettings,
 		AppSlug:                a.Slug,
 		AppSequence:            appSequence,
 		SkipCompatibilityCheck: skipCompatibilityCheck,
 	}
 
 	if _, err := pull.Pull(fmt.Sprintf("replicated://%s", beforeKotsKinds.License.Spec.AppSlug), pullOptions); err != nil {
-		return errors.Wrap(err, "failed to pull")
+		if errors.Cause(err) != pull.ErrConfigNeeded {
+			return errors.Wrap(err, "failed to pull")
+		}
 	}
 
 	afterKotsKinds, err := kotsutil.LoadKotsKindsFromPath(archiveDir)
diff --git a/pkg/base/helm_v2.go b/pkg/base/helm_v2.go
index a898383fd..2a18b93c5 100644
--- a/pkg/base/helm_v2.go
+++ b/pkg/base/helm_v2.go
@@ -1,6 +1,7 @@
 package base
 
 import (
+	"fmt"
 	"io/ioutil"
 	golog "log"
 	"os"
@@ -8,6 +9,7 @@ import (
 
 	"github.com/ghodss/yaml"
 	"github.com/pkg/errors"
+	"github.com/replicatedhq/kots/pkg/util"
 	"k8s.io/helm/pkg/chartutil"
 	"k8s.io/helm/pkg/proto/hapi/chart"
 	"k8s.io/helm/pkg/renderutil"
@@ -58,7 +60,10 @@ func renderHelmV2(chartName string, chartPath string, vals map[string]interface{
 
 	rendered, err := renderutil.Render(c, config, renderOpts)
 	if err != nil {
-		return nil, nil, errors.Wrap(err, "failed to render chart")
+		return nil, nil, util.ActionableError{
+			NoRetry: true,
+			Message: fmt.Sprintf("helm v2 render failed with error: %v", err),
+		}
 	}
 
 	baseFiles := []BaseFile{}
diff --git a/pkg/base/helm_v3.go b/pkg/base/helm_v3.go
index b14df7a66..6bc6eeace 100644
--- a/pkg/base/helm_v3.go
+++ b/pkg/base/helm_v3.go
@@ -54,7 +54,10 @@ func renderHelmV3(chartName string, chartPath string, vals map[string]interface{
 
 	rel, err := client.Run(chartRequested, vals)
 	if err != nil {
-		return nil, nil, errors.Wrap(err, "failed to render chart")
+		return nil, nil, util.ActionableError{
+			NoRetry: true,
+			Message: fmt.Sprintf("helm v3 render failed with error: %v", err),
+		}
 	}
 
 	coalescedValues, err := chartutil.CoalesceValues(rel.Chart, rel.Config)
diff --git a/pkg/base/replicated.go b/pkg/base/replicated.go
index 77cf08580..3ea0a235f 100644
--- a/pkg/base/replicated.go
+++ b/pkg/base/replicated.go
@@ -18,6 +18,7 @@ import (
 	kotsconfig "github.com/replicatedhq/kots/pkg/config"
 	"github.com/replicatedhq/kots/pkg/kotsutil"
 	"github.com/replicatedhq/kots/pkg/logger"
+	registrytypes "github.com/replicatedhq/kots/pkg/registry/types"
 	"github.com/replicatedhq/kots/pkg/template"
 	upstreamtypes "github.com/replicatedhq/kots/pkg/upstream/types"
 	"github.com/replicatedhq/kots/pkg/util"
@@ -59,12 +60,12 @@ func renderReplicated(u *upstreamtypes.Upstream, renderOptions *RenderOptions) (
 		return nil, nil, errors.Wrap(err, "failed to find config file")
 	}
 
-	registry := template.LocalRegistry{
-		Host:      renderOptions.LocalRegistryHost,
-		Namespace: renderOptions.LocalRegistryNamespace,
-		Username:  renderOptions.LocalRegistryUsername,
-		Password:  renderOptions.LocalRegistryPassword,
-		ReadOnly:  renderOptions.LocalRegistryIsReadOnly,
+	registry := registrytypes.RegistrySettings{
+		Hostname:   renderOptions.LocalRegistryHost,
+		Namespace:  renderOptions.LocalRegistryNamespace,
+		Username:   renderOptions.LocalRegistryUsername,
+		Password:   renderOptions.LocalRegistryPassword,
+		IsReadOnly: renderOptions.LocalRegistryIsReadOnly,
 	}
 
 	versionInfo := template.VersionInfoFromInstallation(renderOptions.Sequence, renderOptions.IsAirgap, kotsKinds.Installation.Spec)
diff --git a/pkg/base/templates.go b/pkg/base/templates.go
index 8c1881844..ecd50870a 100644
--- a/pkg/base/templates.go
+++ b/pkg/base/templates.go
@@ -3,6 +3,7 @@ package base
 import (
 	"github.com/pkg/errors"
 	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
+	registrytypes "github.com/replicatedhq/kots/pkg/registry/types"
 	"github.com/replicatedhq/kots/pkg/template"
 	upstreamtypes "github.com/replicatedhq/kots/pkg/upstream/types"
 )
@@ -34,12 +35,12 @@ func NewConfigContextTemplateBuilder(u *upstreamtypes.Upstream, renderOptions *R
 		configGroups = kotsKinds.Config.Spec.Groups
 	}
 
-	localRegistry := template.LocalRegistry{
-		Host:      renderOptions.LocalRegistryHost,
-		Namespace: renderOptions.LocalRegistryNamespace,
-		Username:  renderOptions.LocalRegistryUsername,
-		Password:  renderOptions.LocalRegistryPassword,
-		ReadOnly:  renderOptions.LocalRegistryIsReadOnly,
+	localRegistry := registrytypes.RegistrySettings{
+		Hostname:   renderOptions.LocalRegistryHost,
+		Namespace:  renderOptions.LocalRegistryNamespace,
+		Username:   renderOptions.LocalRegistryUsername,
+		Password:   renderOptions.LocalRegistryPassword,
+		IsReadOnly: renderOptions.LocalRegistryIsReadOnly,
 	}
 
 	appInfo := template.ApplicationInfo{
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 9b97f2315..351e1488d 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -9,6 +9,7 @@ import (
 	"github.com/pkg/errors"
 	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
 	"github.com/replicatedhq/kots/kotskinds/multitype"
+	registrytypes "github.com/replicatedhq/kots/pkg/registry/types"
 	"github.com/replicatedhq/kots/pkg/template"
 	"github.com/replicatedhq/kots/pkg/util"
 	yaml "github.com/replicatedhq/yaml/v3"
@@ -16,7 +17,7 @@ import (
 	"k8s.io/client-go/kubernetes/scheme"
 )
 
-func TemplateConfigObjects(configSpec *kotsv1beta1.Config, configValues map[string]template.ItemValue, license *kotsv1beta1.License, app *kotsv1beta1.Application, localRegistry template.LocalRegistry, versionInfo *template.VersionInfo, appInfo *template.ApplicationInfo, identityconfig *kotsv1beta1.IdentityConfig, namespace string, decryptValues bool) (*kotsv1beta1.Config, error) {
+func TemplateConfigObjects(configSpec *kotsv1beta1.Config, configValues map[string]template.ItemValue, license *kotsv1beta1.License, app *kotsv1beta1.Application, localRegistry registrytypes.RegistrySettings, versionInfo *template.VersionInfo, appInfo *template.ApplicationInfo, identityconfig *kotsv1beta1.IdentityConfig, namespace string, decryptValues bool) (*kotsv1beta1.Config, error) {
 	templatedString, err := templateConfigObjects(configSpec, configValues, license, app, localRegistry, versionInfo, appInfo, identityconfig, namespace, decryptValues, MarshalConfig)
 	if err != nil {
 		return nil, errors.Wrap(err, "failed to template config")
@@ -38,7 +39,7 @@ func TemplateConfigObjects(configSpec *kotsv1beta1.Config, configValues map[stri
 	return config, nil
 }
 
-func templateConfigObjects(configSpec *kotsv1beta1.Config, configValues map[string]template.ItemValue, license *kotsv1beta1.License, app *kotsv1beta1.Application, localRegistry template.LocalRegistry, versionInfo *template.VersionInfo, appInfo *template.ApplicationInfo, identityconfig *kotsv1beta1.IdentityConfig, namespace string, decryptValues bool, marshalFunc func(config *kotsv1beta1.Config) (string, error)) (string, error) {
+func templateConfigObjects(configSpec *kotsv1beta1.Config, configValues map[string]template.ItemValue, license *kotsv1beta1.License, app *kotsv1beta1.Application, localRegistry registrytypes.RegistrySettings, versionInfo *template.VersionInfo, appInfo *template.ApplicationInfo, identityconfig *kotsv1beta1.IdentityConfig, namespace string, decryptValues bool, marshalFunc func(config *kotsv1beta1.Config) (string, error)) (string, error) {
 	if configSpec == nil {
 		return "", nil
 	}
diff --git a/pkg/handlers/config.go b/pkg/handlers/config.go
index 583424ce4..c4e4df7a1 100644
--- a/pkg/handlers/config.go
+++ b/pkg/handlers/config.go
@@ -190,7 +190,24 @@ func (h *Handler) UpdateAppConfig(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	createNewVersion, err := shouldCreateNewAppVersion(foundApp.ID, updateAppConfigRequest.Sequence)
+	archiveDir, err := ioutil.TempDir("", "kotsadm")
+	if err != nil {
+		updateAppConfigResponse.Error = "failed to create temp dir"
+		logger.Error(errors.Wrap(err, updateAppConfigResponse.Error))
+		JSON(w, http.StatusInternalServerError, updateAppConfigResponse)
+		return
+	}
+	defer os.RemoveAll(archiveDir)
+
+	err = store.GetStore().GetAppVersionArchive(foundApp.ID, updateAppConfigRequest.Sequence, archiveDir)
+	if err != nil {
+		updateAppConfigResponse.Error = "failed to get app version archive"
+		logger.Error(errors.Wrap(err, updateAppConfigResponse.Error))
+		JSON(w, http.StatusInternalServerError, updateAppConfigResponse)
+		return
+	}
+
+	createNewVersion, err := shouldCreateNewAppVersion(archiveDir, foundApp.ID, updateAppConfigRequest.Sequence)
 	if err != nil {
 		updateAppConfigResponse.Error = "failed to check if version should be created"
 		logger.Error(errors.Wrap(err, updateAppConfigResponse.Error))
@@ -234,7 +251,7 @@ func (h *Handler) LiveAppConfig(w http.ResponseWriter, r *http.Request) {
 	var kotsKinds *kotsutil.KotsKinds
 	var appLicense *kotsv1beta1.License
 	var app apptypes.AppType
-	var localRegistry template.LocalRegistry
+	var localRegistry registrytypes.RegistrySettings
 
 	createNewVersion := false
 	configValues := configValuesFromConfigGroups(liveAppConfigRequest.ConfigGroups)
@@ -335,15 +352,9 @@ func (h *Handler) LiveAppConfig(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		localRegistry = template.LocalRegistry{
-			Host:      registryInfo.Hostname,
-			Namespace: registryInfo.Namespace,
-			Username:  registryInfo.Username,
-			Password:  registryInfo.Password,
-			ReadOnly:  registryInfo.IsReadOnly,
-		}
+		localRegistry = registryInfo
 
-		createNewVersion, err = shouldCreateNewAppVersion(foundApp.GetID(), liveAppConfigRequest.Sequence)
+		createNewVersion, err = shouldCreateNewAppVersion(archiveDir, foundApp.GetID(), liveAppConfigRequest.Sequence)
 		if err != nil {
 			liveAppConfigResponse.Error = "failed to check new version"
 			logger.Error(errors.Wrap(err, liveAppConfigResponse.Error))
@@ -440,7 +451,7 @@ func (h *Handler) CurrentAppConfig(w http.ResponseWriter, r *http.Request) {
 
 	var kotsKinds *kotsutil.KotsKinds
 	var license *kotsv1beta1.License
-	var localRegistry template.LocalRegistry
+	var localRegistry registrytypes.RegistrySettings
 	var app apptypes.AppType
 	var downstreamVersion *downstreamtypes.DownstreamVersion
 
@@ -581,15 +592,9 @@ func (h *Handler) CurrentAppConfig(w http.ResponseWriter, r *http.Request) {
 			return
 		}
 
-		localRegistry = template.LocalRegistry{
-			Host:      registryInfo.Hostname,
-			Namespace: registryInfo.Namespace,
-			Username:  registryInfo.Username,
-			Password:  registryInfo.Password,
-			ReadOnly:  registryInfo.IsReadOnly,
-		}
+		localRegistry = registryInfo
 
-		createNewVersion, err = shouldCreateNewAppVersion(foundApp.GetID(), sequence)
+		createNewVersion, err = shouldCreateNewAppVersion(archiveDir, foundApp.GetID(), sequence)
 		if err != nil {
 			currentAppConfigResponse.Error = "failed to check new version"
 			logger.Error(errors.Wrap(err, currentAppConfigResponse.Error))
@@ -674,8 +679,17 @@ func isVersionConfigEditable(app *apptypes.App, sequence int64) (bool, error) {
 	return false, nil
 }
 
-func shouldCreateNewAppVersion(appID string, sequence int64) (bool, error) {
-	// Updates are allowed only for sequence 0 and only when it's pending config.
+func shouldCreateNewAppVersion(archiveDir string, appID string, sequence int64) (bool, error) {
+	// Updates are allowed for any version that does not have base rendered.
+	if _, err := os.Stat(filepath.Join(archiveDir, "base")); err != nil {
+		if os.IsNotExist(err) {
+			return false, nil
+		} else {
+			return false, errors.Wrap(err, "failed to stat base dir")
+		}
+	}
+
+	// If base is rendered, updates are allowed only for sequence 0 and only when it's pending config.
 	if sequence > 0 {
 		return true, nil
 	}
@@ -826,7 +840,12 @@ func updateAppConfig(updateApp *apptypes.App, sequence int64, configGroups []kot
 
 	err = render.RenderDir(archiveDir, app, downstreams, registrySettings, renderSequence)
 	if err != nil {
-		updateAppConfigResponse.Error = "failed to render archive directory"
+		cause := errors.Cause(err)
+		if _, ok := cause.(util.ActionableError); ok {
+			updateAppConfigResponse.Error = cause.Error()
+		} else {
+			updateAppConfigResponse.Error = "failed to render archive directory"
+		}
 		return updateAppConfigResponse, err
 	}
 
@@ -1103,14 +1122,6 @@ func (h *Handler) SetAppConfigValues(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	localRegistry := template.LocalRegistry{
-		Host:      registryInfo.Hostname,
-		Namespace: registryInfo.Namespace,
-		Username:  registryInfo.Username,
-		Password:  registryInfo.Password,
-		ReadOnly:  registryInfo.IsReadOnly,
-	}
-
 	nextAppSequence, err := store.GetStore().GetNextAppSequence(foundApp.ID)
 	if err != nil {
 		setAppConfigValuesResponse.Error = "failed to get next app sequence"
@@ -1121,7 +1132,7 @@ func (h *Handler) SetAppConfigValues(w http.ResponseWriter, r *http.Request) {
 
 	versionInfo := template.VersionInfoFromInstallation(nextAppSequence, foundApp.IsAirgap, 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: foundApp.Slug}
-	renderedConfig, err := kotsconfig.TemplateConfigObjects(newConfig, configValueMap, kotsKinds.License, &kotsKinds.KotsApplication, localRegistry, &versionInfo, &appInfo, kotsKinds.IdentityConfig, util.PodNamespace, true)
+	renderedConfig, err := kotsconfig.TemplateConfigObjects(newConfig, configValueMap, kotsKinds.License, &kotsKinds.KotsApplication, registryInfo, &versionInfo, &appInfo, kotsKinds.IdentityConfig, util.PodNamespace, true)
 	if err != nil {
 		setAppConfigValuesResponse.Error = "failed to render templates"
 		logger.Error(errors.Wrap(err, setAppConfigValuesResponse.Error))
diff --git a/pkg/kotsadmconfig/config.go b/pkg/kotsadmconfig/config.go
index a3fafedae..56e988472 100644
--- a/pkg/kotsadmconfig/config.go
+++ b/pkg/kotsadmconfig/config.go
@@ -68,19 +68,11 @@ func NeedsConfiguration(appSlug string, sequence int64, isAirgap bool, kotsKinds
 		configValues = map[string]template.ItemValue{}
 	}
 
-	localRegistry := template.LocalRegistry{
-		Host:      registrySettings.Hostname,
-		Namespace: registrySettings.Namespace,
-		Username:  registrySettings.Username,
-		Password:  registrySettings.Password,
-		ReadOnly:  registrySettings.IsReadOnly,
-	}
-
 	versionInfo := template.VersionInfoFromInstallation(sequence, isAirgap, kotsKinds.Installation.Spec)
 	appInfo := template.ApplicationInfo{Slug: appSlug}
 
 	// rendered, err := kotsconfig.TemplateConfig(logger.NewCLILogger(os.Stdout), configSpec, configValuesSpec, licenseSpec, appSpec, identityConfigSpec, localRegistry, util.PodNamespace)
-	config, err := kotsconfig.TemplateConfigObjects(kotsKinds.Config, configValues, kotsKinds.License, &kotsKinds.KotsApplication, localRegistry, &versionInfo, &appInfo, kotsKinds.IdentityConfig, util.PodNamespace, true)
+	config, err := kotsconfig.TemplateConfigObjects(kotsKinds.Config, configValues, kotsKinds.License, &kotsKinds.KotsApplication, registrySettings, &versionInfo, &appInfo, kotsKinds.IdentityConfig, util.PodNamespace, true)
 	if err != nil {
 		return false, errors.Wrap(err, "failed to template config")
 	}
diff --git a/pkg/kotsadmupstream/upstream.go b/pkg/kotsadmupstream/upstream.go
index 9c07d60e4..3d7fd2903 100644
--- a/pkg/kotsadmupstream/upstream.go
+++ b/pkg/kotsadmupstream/upstream.go
@@ -14,7 +14,7 @@ import (
 	"github.com/replicatedhq/kots/pkg/kotsutil"
 	"github.com/replicatedhq/kots/pkg/logger"
 	"github.com/replicatedhq/kots/pkg/preflight"
-	kotspull "github.com/replicatedhq/kots/pkg/pull"
+	"github.com/replicatedhq/kots/pkg/pull"
 	"github.com/replicatedhq/kots/pkg/render"
 	"github.com/replicatedhq/kots/pkg/reporting"
 	"github.com/replicatedhq/kots/pkg/store"
@@ -204,37 +204,39 @@ func DownloadUpdate(appID string, update types.Update, skipPreflights bool, skip
 		return
 	}
 
-	pullOptions := kotspull.PullOptions{
-		LicenseObj:          latestLicense,
-		Namespace:           appNamespace,
-		ConfigFile:          filepath.Join(archiveDir, "upstream", "userdata", "config.yaml"),
-		IdentityConfigFile:  identityConfigFile,
-		InstallationFile:    filepath.Join(archiveDir, "upstream", "userdata", "installation.yaml"),
-		UpdateCursor:        update.Cursor,
-		RootDir:             archiveDir,
-		Downstreams:         downstreamNames,
-		ExcludeKotsKinds:    true,
-		ExcludeAdminConsole: true,
-		CreateAppDir:        false,
-		ReportWriter:        pipeWriter,
-		AppSlug:             a.Slug,
-		AppSequence:         appSequence,
-		IsGitOps:            a.IsGitOps,
-		ReportingInfo:       reporting.GetReportingInfo(a.ID),
-		RewriteImages:       registrySettings.IsValid(),
-		RewriteImageOptions: kotspull.RewriteImageOptions{
-			Host:       registrySettings.Hostname,
-			Namespace:  registrySettings.Namespace,
-			Username:   registrySettings.Username,
-			Password:   registrySettings.Password,
-			IsReadOnly: registrySettings.IsReadOnly,
-		},
+	if err := pull.CleanBaseArchive(archiveDir); err != nil {
+		finalError = errors.Wrap(err, "failed to clean base archive")
+		return
+	}
+
+	pullOptions := pull.PullOptions{
+		LicenseObj:             latestLicense,
+		Namespace:              appNamespace,
+		ConfigFile:             filepath.Join(archiveDir, "upstream", "userdata", "config.yaml"),
+		IdentityConfigFile:     identityConfigFile,
+		InstallationFile:       filepath.Join(archiveDir, "upstream", "userdata", "installation.yaml"),
+		UpdateCursor:           update.Cursor,
+		RootDir:                archiveDir,
+		Downstreams:            downstreamNames,
+		ExcludeKotsKinds:       true,
+		ExcludeAdminConsole:    true,
+		CreateAppDir:           false,
+		ReportWriter:           pipeWriter,
+		AppSlug:                a.Slug,
+		AppSequence:            appSequence,
+		IsGitOps:               a.IsGitOps,
+		ReportingInfo:          reporting.GetReportingInfo(a.ID),
+		RewriteImages:          registrySettings.IsValid(),
+		RewriteImageOptions:    registrySettings,
 		SkipCompatibilityCheck: skipCompatibilityCheck,
 	}
 
-	if _, err := kotspull.Pull(fmt.Sprintf("replicated://%s", beforeKotsKinds.License.Spec.AppSlug), pullOptions); err != nil {
-		finalError = errors.Wrap(err, "failed to pull")
-		return
+	_, err = pull.Pull(fmt.Sprintf("replicated://%s", beforeKotsKinds.License.Spec.AppSlug), pullOptions)
+	if err != nil {
+		if errors.Cause(err) != pull.ErrConfigNeeded {
+			finalError = errors.Wrap(err, "failed to pull")
+			return
+		}
 	}
 
 	if update.AppSequence == nil {
diff --git a/pkg/midstream/write.go b/pkg/midstream/write.go
index 89db67ecd..1227dea7d 100644
--- a/pkg/midstream/write.go
+++ b/pkg/midstream/write.go
@@ -2,15 +2,27 @@ package midstream
 
 import (
 	"context"
+	"encoding/json"
+	"fmt"
+	"io"
 	"io/ioutil"
 	"os"
 	"path/filepath"
 
 	"github.com/pkg/errors"
 	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
+	"github.com/replicatedhq/kots/pkg/base"
 	"github.com/replicatedhq/kots/pkg/disasterrecovery"
+	"github.com/replicatedhq/kots/pkg/docker/registry"
+	dockerregistrytypes "github.com/replicatedhq/kots/pkg/docker/registry/types"
+	"github.com/replicatedhq/kots/pkg/k8sdoc"
 	"github.com/replicatedhq/kots/pkg/k8sutil"
+	"github.com/replicatedhq/kots/pkg/kotsutil"
+	"github.com/replicatedhq/kots/pkg/logger"
+	registrytypes "github.com/replicatedhq/kots/pkg/registry/types"
 	"github.com/replicatedhq/kots/pkg/template"
+	"github.com/replicatedhq/kots/pkg/upstream"
+	"github.com/replicatedhq/kots/pkg/util"
 	yaml "gopkg.in/yaml.v2"
 	corev1 "k8s.io/api/core/v1"
 	kustomizetypes "sigs.k8s.io/kustomize/api/types"
@@ -37,7 +49,264 @@ type WriteOptions struct {
 	NewHelmCharts      []*kotsv1beta1.HelmChart
 }
 
-func (m *Midstream) WriteMidstream(options WriteOptions) error {
+type ProcessImageOptions struct {
+	AppSlug          string
+	Namespace        string
+	RewriteImages    bool
+	RegistrySettings registrytypes.RegistrySettings
+	CopyImages       bool
+	RootDir          string
+	IsAirgap         bool
+	AirgapRoot       string
+	AirgapBundle     string
+	PushImages       bool
+	CreateAppDir     bool
+	ReportWriter     io.Writer
+}
+
+func WriteMidstream(writeMidstreamOptions WriteOptions, processImageOptions ProcessImageOptions, b *base.Base, license *kotsv1beta1.License, identityConfig *kotsv1beta1.IdentityConfig, upstreamDir string, log *logger.CLILogger) (*Midstream, error) {
+	var images []kustomizetypes.Image
+	var objects []k8sdoc.K8sDoc
+	var pullSecretRegistries []string
+	var pullSecretUsername string
+	var pullSecretPassword string
+
+	clientset, err := k8sutil.GetClientset()
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to get k8s clientset")
+	}
+
+	newKotsKinds, err := kotsutil.LoadKotsKindsFromPath(upstreamDir)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to load kotskinds from new upstream")
+	}
+
+	identitySpec, err := upstream.LoadIdentity(upstreamDir)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to load identity")
+	}
+
+	// do not fail on being unable to get dockerhub credentials, since they're just used to increase the rate limit
+	var dockerHubRegistryCreds registry.Credentials
+	dockerhubSecret, _ := registry.GetDockerHubPullSecret(clientset, util.PodNamespace, processImageOptions.Namespace, processImageOptions.AppSlug)
+	if dockerhubSecret != nil {
+		dockerHubRegistryCreds, _ = registry.GetCredentialsForRegistryFromConfigJSON(dockerhubSecret.Data[".dockerconfigjson"], registry.DockerHubRegistryName)
+	}
+
+	if processImageOptions.RewriteImages {
+		// A target registry is configured. Rewrite all images and copy them (if necessary) to the configured registry.
+		if processImageOptions.RegistrySettings.IsReadOnly {
+			log.ActionWithSpinner("Rewriting images")
+			io.WriteString(processImageOptions.ReportWriter, "Rewriting images\n")
+		} else {
+			log.ActionWithSpinner("Copying images")
+			io.WriteString(processImageOptions.ReportWriter, "Copying images\n")
+		}
+
+		if processImageOptions.AirgapRoot == "" {
+			// This is an online installation. Pull and rewrite images from online and copy them (if necessary) to the configured registry.
+			rewriteResult, err := rewriteBaseImages(processImageOptions, writeMidstreamOptions.BaseDir, newKotsKinds, license, dockerHubRegistryCreds, log)
+			if err != nil {
+				return nil, errors.Wrap(err, "failed to rewrite base images")
+			}
+			images = rewriteResult.Images
+			newKotsKinds.Installation.Spec.KnownImages = rewriteResult.CheckedImages
+		} else {
+			// This is an airgapped installation. Copy and rewrite images from the airgap bundle to the configured registry.
+			result, err := ProcessAirgapImages(processImageOptions, newKotsKinds, license, log)
+			if err != nil {
+				return nil, errors.Wrap(err, "failed to process airgap images")
+			}
+			images = result.KustomizeImages
+			newKotsKinds.Installation.Spec.KnownImages = result.KnownImages
+		}
+
+		objects = base.FindObjectsWithImages(b)
+
+		// Use target registry credentials to create image pull secrets for all objects that have images.
+		pullSecretRegistries = []string{processImageOptions.RegistrySettings.Hostname}
+		pullSecretUsername = processImageOptions.RegistrySettings.Username
+		pullSecretPassword = processImageOptions.RegistrySettings.Password
+		if pullSecretUsername == "" {
+			pullSecretUsername, pullSecretPassword, err = registry.LoadAuthForRegistry(processImageOptions.RegistrySettings.Hostname)
+			if err != nil {
+				return nil, errors.Wrapf(err, "failed to load registry auth for %q", processImageOptions.RegistrySettings.Hostname)
+			}
+		}
+	} else if license != nil {
+		// A target registry is NOT configured. Find and rewrite private images to be proxied through proxy.replicated.com
+		findResult, err := findPrivateImages(writeMidstreamOptions, b, newKotsKinds, license, dockerHubRegistryCreds)
+		if err != nil {
+			return nil, errors.Wrap(err, "failed to find private images")
+		}
+		images = findResult.Images
+		newKotsKinds.Installation.Spec.KnownImages = findResult.CheckedImages
+		objects = findResult.Docs
+
+		// Use license to create image pull secrets for all objects that have private images.
+		pullSecretRegistries = registry.GetRegistryProxyInfo(license, &newKotsKinds.KotsApplication).ToSlice()
+		pullSecretUsername = license.Spec.LicenseID
+		pullSecretPassword = license.Spec.LicenseID
+	}
+
+	// For the newer style charts, create a new secret per chart as helm adds chart specific
+	// details to annotations and labels to it.
+	namePrefix := processImageOptions.AppSlug
+	for _, v := range writeMidstreamOptions.NewHelmCharts {
+		if v.Spec.UseHelmInstall && filepath.Base(b.Path) != "." {
+			namePrefix = fmt.Sprintf("%s-%s", processImageOptions.AppSlug, filepath.Base(b.Path))
+			break
+		}
+	}
+	pullSecrets, err := registry.PullSecretForRegistries(
+		pullSecretRegistries,
+		pullSecretUsername,
+		pullSecretPassword,
+		processImageOptions.Namespace,
+		namePrefix,
+	)
+	if err != nil {
+		return nil, errors.Wrap(err, "create pull secret")
+	}
+	pullSecrets.DockerHubSecret = dockerhubSecret
+
+	if err := upstream.SaveInstallation(&newKotsKinds.Installation, upstreamDir); err != nil {
+		return nil, errors.Wrap(err, "failed to save installation")
+	}
+
+	m, err := CreateMidstream(b, images, objects, &pullSecrets, identitySpec, identityConfig)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to create midstream")
+	}
+
+	if err := m.Write(writeMidstreamOptions); err != nil {
+		return nil, errors.Wrap(err, "failed to write common midstream")
+	}
+
+	return m, nil
+}
+
+// rewriteBaseImages Will rewrite images found in base and copy them (if necessary) to the configured registry.
+func rewriteBaseImages(options ProcessImageOptions, baseDir string, kotsKinds *kotsutil.KotsKinds, license *kotsv1beta1.License, dockerHubRegistryCreds registry.Credentials, log *logger.CLILogger) (*base.RewriteImagesResult, error) {
+	replicatedRegistryInfo := registry.GetRegistryProxyInfo(license, &kotsKinds.KotsApplication)
+
+	rewriteImageOptions := base.RewriteImageOptions{
+		BaseDir: baseDir,
+		Log:     log,
+		SourceRegistry: dockerregistrytypes.RegistryOptions{
+			Endpoint:      replicatedRegistryInfo.Registry,
+			ProxyEndpoint: replicatedRegistryInfo.Proxy,
+		},
+		DockerHubRegistry: dockerregistrytypes.RegistryOptions{
+			Username: dockerHubRegistryCreds.Username,
+			Password: dockerHubRegistryCreds.Password,
+		},
+		DestRegistry: dockerregistrytypes.RegistryOptions{
+			Endpoint:  options.RegistrySettings.Hostname,
+			Namespace: options.RegistrySettings.Namespace,
+			Username:  options.RegistrySettings.Username,
+			Password:  options.RegistrySettings.Password,
+		},
+		ReportWriter: options.ReportWriter,
+		KotsKinds:    kotsKinds,
+		IsAirgap:     options.IsAirgap,
+		CopyImages:   options.CopyImages,
+	}
+	if license != nil {
+		rewriteImageOptions.AppSlug = license.Spec.AppSlug
+		rewriteImageOptions.SourceRegistry.Username = license.Spec.LicenseID
+		rewriteImageOptions.SourceRegistry.Password = license.Spec.LicenseID
+	}
+
+	rewriteResult, err := base.RewriteImages(rewriteImageOptions)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to rewrite images")
+	}
+
+	return rewriteResult, nil
+}
+
+// processAirgapImages Will rewrite images found in the airgap bundle/airgap root and copy them (if necessary) to the configured registry.
+func ProcessAirgapImages(options ProcessImageOptions, kotsKinds *kotsutil.KotsKinds, license *kotsv1beta1.License, log *logger.CLILogger) (*upstream.ProcessAirgapImagesResult, error) {
+	replicatedRegistryInfo := registry.GetRegistryProxyInfo(license, &kotsKinds.KotsApplication)
+
+	processAirgapImageOptions := upstream.ProcessAirgapImagesOptions{
+		RootDir:      options.RootDir,
+		AirgapRoot:   options.AirgapRoot,
+		AirgapBundle: options.AirgapBundle,
+		CreateAppDir: options.CreateAppDir,
+		PushImages:   !options.RegistrySettings.IsReadOnly && options.PushImages,
+		Log:          log,
+		ReplicatedRegistry: dockerregistrytypes.RegistryOptions{
+			Endpoint:      replicatedRegistryInfo.Registry,
+			ProxyEndpoint: replicatedRegistryInfo.Proxy,
+		},
+		ReportWriter: options.ReportWriter,
+		DestinationRegistry: dockerregistrytypes.RegistryOptions{
+			Endpoint:  options.RegistrySettings.Hostname,
+			Namespace: options.RegistrySettings.Namespace,
+			Username:  options.RegistrySettings.Username,
+			Password:  options.RegistrySettings.Password,
+		},
+	}
+	if license != nil {
+		processAirgapImageOptions.ReplicatedRegistry.Username = license.Spec.LicenseID
+		processAirgapImageOptions.ReplicatedRegistry.Password = license.Spec.LicenseID
+	}
+
+	imagesData, err := ioutil.ReadFile(filepath.Join(options.AirgapRoot, "images.json"))
+	if err != nil && !os.IsNotExist(err) {
+		return nil, errors.Wrap(err, "failed to load images file")
+	}
+	if err == nil {
+		var images []kustomizetypes.Image
+		err := json.Unmarshal(imagesData, &images)
+		if err != nil && !os.IsNotExist(err) {
+			return nil, errors.Wrap(err, "failed to unmarshal images data")
+		}
+		processAirgapImageOptions.UseKnownImages = true
+		processAirgapImageOptions.KnownImages = images
+	}
+
+	result, err := upstream.ProcessAirgapImages(processAirgapImageOptions)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to process airgap images")
+	}
+
+	return result, nil
+}
+
+// findPrivateImages Finds and rewrites private images to be proxied through proxy.replicated.com
+func findPrivateImages(writeMidstreamOptions WriteOptions, b *base.Base, kotsKinds *kotsutil.KotsKinds, license *kotsv1beta1.License, dockerHubRegistryCreds registry.Credentials) (*base.FindPrivateImagesResult, error) {
+	replicatedRegistryInfo := registry.GetRegistryProxyInfo(license, &kotsKinds.KotsApplication)
+	allPrivate := kotsKinds.KotsApplication.Spec.ProxyPublicImages
+
+	findPrivateImagesOptions := base.FindPrivateImagesOptions{
+		BaseDir: writeMidstreamOptions.BaseDir,
+		AppSlug: license.Spec.AppSlug,
+		ReplicatedRegistry: dockerregistrytypes.RegistryOptions{
+			Endpoint:      replicatedRegistryInfo.Registry,
+			ProxyEndpoint: replicatedRegistryInfo.Proxy,
+		},
+		DockerHubRegistry: dockerregistrytypes.RegistryOptions{
+			Username: dockerHubRegistryCreds.Username,
+			Password: dockerHubRegistryCreds.Password,
+		},
+		Installation:     &kotsKinds.Installation,
+		AllImagesPrivate: allPrivate,
+		HelmChartPath:    b.Path,
+		UseHelmInstall:   writeMidstreamOptions.UseHelmInstall,
+		KotsKindsImages:  kotsutil.GetImagesFromKotsKinds(kotsKinds),
+	}
+	findResult, err := base.FindPrivateImages(findPrivateImagesOptions)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to find private images")
+	}
+
+	return findResult, nil
+}
+
+func (m *Midstream) Write(options WriteOptions) error {
 	if err := os.MkdirAll(options.MidstreamDir, 0744); err != nil {
 		return errors.Wrap(err, "failed to mkdir")
 	}
diff --git a/pkg/online/online.go b/pkg/online/online.go
index ca165a8cd..f835fdad3 100644
--- a/pkg/online/online.go
+++ b/pkg/online/online.go
@@ -158,7 +158,9 @@ func CreateAppFromOnline(opts CreateOnlineAppOpts) (_ *kotsutil.KotsKinds, final
 	}
 
 	if _, err := pull.Pull(opts.UpstreamURI, pullOptions); err != nil {
-		return nil, errors.Wrap(err, "failed to pull")
+		if errors.Cause(err) != pull.ErrConfigNeeded {
+			return nil, errors.Wrap(err, "failed to pull")
+		}
 	}
 
 	// Create the downstream
diff --git a/pkg/pull/archive.go b/pkg/pull/archive.go
index d3c8e5062..e97bb0779 100644
--- a/pkg/pull/archive.go
+++ b/pkg/pull/archive.go
@@ -7,6 +7,7 @@ import (
 	"io/ioutil"
 	"os"
 	"path"
+	"path/filepath"
 	"strconv"
 
 	"github.com/mholt/archiver/v3"
@@ -102,3 +103,27 @@ func writeArchiveAsConfigMap(pullOptions PullOptions, u *upstreamtypes.Upstream,
 
 	return nil
 }
+
+func CleanBaseArchive(path string) error {
+	files, err := ioutil.ReadDir(path)
+	if err != nil {
+		return errors.Wrap(err, "failed to read dir")
+	}
+
+	// "overlays" contains manual kustomizations.
+	// "upstream" contains config values, known images, and other important installation info
+	// everything else should be deleted and generated again
+	for _, file := range files {
+		switch file.Name() {
+		case "overlays", "upstream":
+			continue
+		default:
+			err := os.RemoveAll(filepath.Join(path, file.Name()))
+			if err != nil {
+				return errors.Wrapf(err, "failed to delete %s", file.Name())
+			}
+		}
+	}
+
+	return nil
+}
diff --git a/pkg/pull/pull.go b/pkg/pull/pull.go
index 46117efa3..cb5cba753 100644
--- a/pkg/pull/pull.go
+++ b/pkg/pull/pull.go
@@ -2,7 +2,6 @@ package pull
 
 import (
 	"bytes"
-	"encoding/json"
 	"fmt"
 	"io"
 	"io/ioutil"
@@ -16,15 +15,14 @@ import (
 	"github.com/replicatedhq/kots/pkg/archives"
 	"github.com/replicatedhq/kots/pkg/base"
 	"github.com/replicatedhq/kots/pkg/crypto"
-	"github.com/replicatedhq/kots/pkg/docker/registry"
-	registrytypes "github.com/replicatedhq/kots/pkg/docker/registry/types"
 	"github.com/replicatedhq/kots/pkg/downstream"
-	"github.com/replicatedhq/kots/pkg/k8sdoc"
 	"github.com/replicatedhq/kots/pkg/k8sutil"
+	"github.com/replicatedhq/kots/pkg/kotsadmconfig"
 	"github.com/replicatedhq/kots/pkg/kotsutil"
 	kotslicense "github.com/replicatedhq/kots/pkg/license"
 	"github.com/replicatedhq/kots/pkg/logger"
 	"github.com/replicatedhq/kots/pkg/midstream"
+	registrytypes "github.com/replicatedhq/kots/pkg/registry/types"
 	"github.com/replicatedhq/kots/pkg/replicatedapp"
 	"github.com/replicatedhq/kots/pkg/upstream"
 	upstreamtypes "github.com/replicatedhq/kots/pkg/upstream/types"
@@ -55,7 +53,7 @@ type PullOptions struct {
 	CreateAppDir            bool
 	Silent                  bool
 	RewriteImages           bool
-	RewriteImageOptions     RewriteImageOptions
+	RewriteImageOptions     registrytypes.RegistrySettings
 	SkipHelmChartCheck      bool
 	ReportWriter            io.Writer
 	AppSlug                 string
@@ -69,13 +67,9 @@ type PullOptions struct {
 	SkipCompatibilityCheck  bool
 }
 
-type RewriteImageOptions struct {
-	Host       string
-	Namespace  string
-	Username   string
-	Password   string
-	IsReadOnly bool
-}
+var (
+	ErrConfigNeeded = errors.New("version needs config")
+)
 
 // PullApplicationMetadata will return the application metadata yaml, if one is
 // available for the upstream
@@ -119,20 +113,14 @@ func Pull(upstreamURI string, pullOptions PullOptions) (string, error) {
 	}
 
 	fetchOptions := upstreamtypes.FetchOptions{
-		RootDir:         pullOptions.RootDir,
-		UseAppDir:       pullOptions.CreateAppDir,
-		LocalPath:       pullOptions.LocalPath,
-		CurrentCursor:   pullOptions.UpdateCursor,
-		AppSlug:         pullOptions.AppSlug,
-		AppSequence:     pullOptions.AppSequence,
-		AppVersionLabel: pullOptions.AppVersionLabel,
-		LocalRegistry: upstreamtypes.LocalRegistry{
-			Host:      pullOptions.RewriteImageOptions.Host,
-			Namespace: pullOptions.RewriteImageOptions.Namespace,
-			Username:  pullOptions.RewriteImageOptions.Username,
-			Password:  pullOptions.RewriteImageOptions.Password,
-			ReadOnly:  pullOptions.RewriteImageOptions.IsReadOnly,
-		},
+		RootDir:                pullOptions.RootDir,
+		UseAppDir:              pullOptions.CreateAppDir,
+		LocalPath:              pullOptions.LocalPath,
+		CurrentCursor:          pullOptions.UpdateCursor,
+		AppSlug:                pullOptions.AppSlug,
+		AppSequence:            pullOptions.AppSequence,
+		AppVersionLabel:        pullOptions.AppVersionLabel,
+		LocalRegistry:          pullOptions.RewriteImageOptions,
 		ReportingInfo:          pullOptions.ReportingInfo,
 		SkipCompatibilityCheck: pullOptions.SkipCompatibilityCheck,
 	}
@@ -293,6 +281,50 @@ func Pull(upstreamURI string, pullOptions PullOptions) (string, error) {
 	}
 	log.FinishSpinner()
 
+	kotsKinds, err := kotsutil.LoadKotsKindsFromPath(u.GetUpstreamDir(writeUpstreamOptions))
+	if err != nil {
+		return "", errors.Wrap(err, "failed to load kotskinds")
+	}
+
+	registrySettings := registrytypes.RegistrySettings{
+		Hostname:   pullOptions.RewriteImageOptions.Hostname,
+		Namespace:  pullOptions.RewriteImageOptions.Namespace,
+		Username:   pullOptions.RewriteImageOptions.Username,
+		Password:   pullOptions.RewriteImageOptions.Password,
+		IsReadOnly: pullOptions.RewriteImageOptions.IsReadOnly,
+	}
+
+	needsConfig, err := kotsadmconfig.NeedsConfiguration(pullOptions.AppSlug, pullOptions.AppSequence, pullOptions.AirgapRoot != "", kotsKinds, registrySettings)
+	if err != nil {
+		return "", errors.Wrap(err, "failed to check if version needs configuration")
+	}
+
+	processImageOptions := midstream.ProcessImageOptions{
+		AppSlug:          pullOptions.AppSlug,
+		Namespace:        pullOptions.Namespace,
+		RewriteImages:    pullOptions.RewriteImages,
+		RegistrySettings: pullOptions.RewriteImageOptions,
+		CopyImages:       !pullOptions.RewriteImageOptions.IsReadOnly,
+		RootDir:          pullOptions.RootDir,
+		IsAirgap:         pullOptions.AirgapRoot != "",
+		AirgapRoot:       pullOptions.AirgapRoot,
+		AirgapBundle:     pullOptions.AirgapBundle,
+		PushImages:       pullOptions.RewriteImageOptions.Hostname != "",
+		CreateAppDir:     pullOptions.CreateAppDir,
+		ReportWriter:     pullOptions.ReportWriter,
+	}
+
+	if needsConfig {
+		if processImageOptions.RewriteImages && processImageOptions.AirgapRoot != "" {
+			// if this is an airgap install, we still need to process the images
+			if _, err = midstream.ProcessAirgapImages(processImageOptions, kotsKinds, fetchOptions.License, log); err != nil {
+				return "", errors.Wrap(err, "failed to process airgap images")
+			}
+		}
+
+		return "", ErrConfigNeeded
+	}
+
 	renderDir := pullOptions.RootDir
 	if pullOptions.CreateAppDir {
 		renderDir = filepath.Join(pullOptions.RootDir, u.Name)
@@ -331,7 +363,7 @@ func Pull(upstreamURI string, pullOptions PullOptions) (string, error) {
 	renderOptions := base.RenderOptions{
 		SplitMultiDocYAML:       true,
 		Namespace:               pullOptions.Namespace,
-		LocalRegistryHost:       pullOptions.RewriteImageOptions.Host,
+		LocalRegistryHost:       pullOptions.RewriteImageOptions.Hostname,
 		LocalRegistryNamespace:  pullOptions.RewriteImageOptions.Namespace,
 		LocalRegistryUsername:   pullOptions.RewriteImageOptions.Username,
 		LocalRegistryPassword:   pullOptions.RewriteImageOptions.Password,
@@ -444,7 +476,6 @@ func Pull(upstreamURI string, pullOptions PullOptions) (string, error) {
 		NoProxyEnvValue:    pullOptions.NoProxyEnvValue,
 		NewHelmCharts:      newHelmCharts,
 	}
-	pushImages := pullOptions.RewriteImageOptions.Host != ""
 
 	// the UseHelmInstall map blocks visibility into charts and subcharts when searching for private images
 	// any chart name listed here will be skipped when writing midstream kustomization.yaml and pullsecret.yaml
@@ -470,7 +501,7 @@ func Pull(upstreamURI string, pullOptions PullOptions) (string, error) {
 	writeMidstreamOptions.MidstreamDir = filepath.Join(commonBase.GetOverlaysDir(writeBaseOptions), "midstream")
 	writeMidstreamOptions.BaseDir = filepath.Join(u.GetBaseDir(writeUpstreamOptions), commonBase.Path)
 
-	m, err := writeMidstream(writeMidstreamOptions, pullOptions, u, commonBase, fetchOptions.License, identityConfig, u.GetUpstreamDir(writeUpstreamOptions), pushImages, log)
+	m, err := midstream.WriteMidstream(writeMidstreamOptions, processImageOptions, commonBase, fetchOptions.License, identityConfig, u.GetUpstreamDir(writeUpstreamOptions), log)
 	if err != nil {
 		log.FinishSpinnerWithError()
 		return "", errors.Wrap(err, "failed to write common midstream")
@@ -487,14 +518,14 @@ func Pull(upstreamURI string, pullOptions PullOptions) (string, error) {
 
 		writeMidstreamOptions.MidstreamDir = filepath.Join(helmBase.GetOverlaysDir(writeBaseOptions), "midstream", helmBase.Path)
 		writeMidstreamOptions.BaseDir = filepath.Join(u.GetBaseDir(writeUpstreamOptions), helmBase.Path)
-		pushImages = false // never push images more than once
 
 		helmBaseCopy := helmBase.DeepCopy()
 
-		pullOptionsCopy := pullOptions
-		pullOptionsCopy.Namespace = helmBaseCopy.Namespace
+		processImageOptionsCopy := processImageOptions
+		processImageOptionsCopy.Namespace = helmBaseCopy.Namespace
+		processImageOptionsCopy.PushImages = false // never push images more than once
 
-		helmMidstream, err := writeMidstream(writeMidstreamOptions, pullOptionsCopy, u, helmBaseCopy, fetchOptions.License, identityConfig, u.GetUpstreamDir(writeUpstreamOptions), pushImages, log)
+		helmMidstream, err := midstream.WriteMidstream(writeMidstreamOptions, processImageOptionsCopy, helmBaseCopy, fetchOptions.License, identityConfig, u.GetUpstreamDir(writeUpstreamOptions), log)
 		if err != nil {
 			log.FinishSpinnerWithError()
 			return "", errors.Wrapf(err, "failed to write helm midstream %s", helmBase.Path)
@@ -527,248 +558,6 @@ func Pull(upstreamURI string, pullOptions PullOptions) (string, error) {
 	return filepath.Join(pullOptions.RootDir, u.Name), nil
 }
 
-func writeMidstream(writeMidstreamOptions midstream.WriteOptions, options PullOptions, u *upstreamtypes.Upstream, b *base.Base, license *kotsv1beta1.License, identityConfig *kotsv1beta1.IdentityConfig, upstreamDir string, pushImages bool, log *logger.CLILogger) (*midstream.Midstream, error) {
-	var images []kustomizetypes.Image
-	var objects []k8sdoc.K8sDoc
-	var pullSecretRegistries []string
-	var pullSecretUsername string
-	var pullSecretPassword string
-
-	clientset, err := k8sutil.GetClientset()
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to get k8s clientset")
-	}
-
-	newKotsKinds, err := kotsutil.LoadKotsKindsFromPath(upstreamDir)
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to load kotskinds from new upstream")
-	}
-
-	identitySpec, err := upstream.LoadIdentity(upstreamDir)
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to load identity")
-	}
-
-	// do not fail on being unable to get dockerhub credentials, since they're just used to increase the rate limit
-	var dockerHubRegistryCreds registry.Credentials
-	dockerhubSecret, _ := registry.GetDockerHubPullSecret(clientset, util.PodNamespace, options.Namespace, options.AppSlug)
-	if dockerhubSecret != nil {
-		dockerHubRegistryCreds, _ = registry.GetCredentialsForRegistryFromConfigJSON(dockerhubSecret.Data[".dockerconfigjson"], registry.DockerHubRegistryName)
-	}
-
-	if options.RewriteImages {
-		// A target registry is configured. Rewrite all images and copy them (if necessary) to the configured registry.
-		if options.RewriteImageOptions.IsReadOnly {
-			log.ActionWithSpinner("Rewriting images")
-			io.WriteString(options.ReportWriter, "Rewriting images\n")
-		} else {
-			log.ActionWithSpinner("Copying images")
-			io.WriteString(options.ReportWriter, "Copying images\n")
-		}
-
-		if options.AirgapRoot == "" {
-			// This is an online installation. Pull and rewrite images from online and copy them (if necessary) to the configured registry.
-			rewriteResult, err := rewriteBaseImages(options, writeMidstreamOptions.BaseDir, newKotsKinds, license, dockerHubRegistryCreds, log)
-			if err != nil {
-				return nil, errors.Wrap(err, "failed to rewrite base images")
-			}
-			images = rewriteResult.Images
-			newKotsKinds.Installation.Spec.KnownImages = rewriteResult.CheckedImages
-		} else {
-			// This is an airgapped installation. Copy and rewrite images from the airgap bundle to the configured registry.
-			result, err := processAirgapImages(options, pushImages, newKotsKinds, license, log)
-			if err != nil {
-				return nil, errors.Wrap(err, "failed to process airgap images")
-			}
-			images = result.KustomizeImages
-			newKotsKinds.Installation.Spec.KnownImages = result.KnownImages
-		}
-
-		objects = base.FindObjectsWithImages(b)
-
-		// Use target registry credentials to create image pull secrets for all objects that have images.
-		pullSecretRegistries = []string{options.RewriteImageOptions.Host}
-		pullSecretUsername = options.RewriteImageOptions.Username
-		pullSecretPassword = options.RewriteImageOptions.Password
-		if pullSecretUsername == "" {
-			pullSecretUsername, pullSecretPassword, err = registry.LoadAuthForRegistry(options.RewriteImageOptions.Host)
-			if err != nil {
-				return nil, errors.Wrapf(err, "failed to load registry auth for %q", options.RewriteImageOptions.Host)
-			}
-		}
-	} else if license != nil {
-		// A target registry is NOT configured. Find and rewrite private images to be proxied through proxy.replicated.com
-		findResult, err := findPrivateImages(writeMidstreamOptions, b, newKotsKinds, license, dockerHubRegistryCreds)
-		if err != nil {
-			return nil, errors.Wrap(err, "failed to find private images")
-		}
-		images = findResult.Images
-		newKotsKinds.Installation.Spec.KnownImages = findResult.CheckedImages
-		objects = findResult.Docs
-
-		// Use license to create image pull secrets for all objects that have private images.
-		pullSecretRegistries = registry.GetRegistryProxyInfo(license, &newKotsKinds.KotsApplication).ToSlice()
-		pullSecretUsername = license.Spec.LicenseID
-		pullSecretPassword = license.Spec.LicenseID
-	}
-
-	// For the newer style charts, create a new secret per chart as helm adds chart specific
-	// details to annotations and labels to it.
-	namePrefix := options.AppSlug
-	for _, v := range writeMidstreamOptions.NewHelmCharts {
-		if v.Spec.UseHelmInstall && filepath.Base(b.Path) != "." {
-			namePrefix = fmt.Sprintf("%s-%s", options.AppSlug, filepath.Base(b.Path))
-			break
-		}
-	}
-	pullSecrets, err := registry.PullSecretForRegistries(
-		pullSecretRegistries,
-		pullSecretUsername,
-		pullSecretPassword,
-		options.Namespace,
-		namePrefix,
-	)
-	if err != nil {
-		return nil, errors.Wrap(err, "create pull secret")
-	}
-	pullSecrets.DockerHubSecret = dockerhubSecret
-
-	if err := upstream.SaveInstallation(&newKotsKinds.Installation, upstreamDir); err != nil {
-		return nil, errors.Wrap(err, "failed to save installation")
-	}
-
-	m, err := midstream.CreateMidstream(b, images, objects, &pullSecrets, identitySpec, identityConfig)
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to create midstream")
-	}
-
-	if err := m.WriteMidstream(writeMidstreamOptions); err != nil {
-		return nil, errors.Wrap(err, "failed to write common midstream")
-	}
-
-	return m, nil
-}
-
-// rewriteBaseImages Will rewrite images found in base and copy them (if necessary) to the configured registry.
-func rewriteBaseImages(pullOptions PullOptions, baseDir string, kotsKinds *kotsutil.KotsKinds, license *kotsv1beta1.License, dockerHubRegistryCreds registry.Credentials, log *logger.CLILogger) (*base.RewriteImagesResult, error) {
-	replicatedRegistryInfo := registry.GetRegistryProxyInfo(license, &kotsKinds.KotsApplication)
-
-	rewriteImageOptions := base.RewriteImageOptions{
-		BaseDir: baseDir,
-		Log:     log,
-		SourceRegistry: registrytypes.RegistryOptions{
-			Endpoint:      replicatedRegistryInfo.Registry,
-			ProxyEndpoint: replicatedRegistryInfo.Proxy,
-		},
-		DockerHubRegistry: registrytypes.RegistryOptions{
-			Username: dockerHubRegistryCreds.Username,
-			Password: dockerHubRegistryCreds.Password,
-		},
-		DestRegistry: registrytypes.RegistryOptions{
-			Endpoint:  pullOptions.RewriteImageOptions.Host,
-			Namespace: pullOptions.RewriteImageOptions.Namespace,
-			Username:  pullOptions.RewriteImageOptions.Username,
-			Password:  pullOptions.RewriteImageOptions.Password,
-		},
-		ReportWriter: pullOptions.ReportWriter,
-		KotsKinds:    kotsKinds,
-		CopyImages:   !pullOptions.RewriteImageOptions.IsReadOnly,
-	}
-	if license != nil {
-		rewriteImageOptions.AppSlug = license.Spec.AppSlug
-		rewriteImageOptions.SourceRegistry.Username = license.Spec.LicenseID
-		rewriteImageOptions.SourceRegistry.Password = license.Spec.LicenseID
-	}
-
-	rewriteResult, err := base.RewriteImages(rewriteImageOptions)
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to rewrite images")
-	}
-
-	return rewriteResult, nil
-}
-
-// processAirgapImages Will rewrite images found in the airgap bundle/airgap root and copy them (if necessary) to the configured registry.
-func processAirgapImages(pullOptions PullOptions, pushImages bool, kotsKinds *kotsutil.KotsKinds, license *kotsv1beta1.License, log *logger.CLILogger) (*upstream.ProcessAirgapImagesResult, error) {
-	replicatedRegistryInfo := registry.GetRegistryProxyInfo(license, &kotsKinds.KotsApplication)
-
-	processAirgapImageOptions := upstream.ProcessAirgapImagesOptions{
-		RootDir:      pullOptions.RootDir,
-		AirgapRoot:   pullOptions.AirgapRoot,
-		AirgapBundle: pullOptions.AirgapBundle,
-		CreateAppDir: pullOptions.CreateAppDir,
-		PushImages:   !pullOptions.RewriteImageOptions.IsReadOnly && pushImages,
-		Log:          log,
-		ReplicatedRegistry: registrytypes.RegistryOptions{
-			Endpoint:      replicatedRegistryInfo.Registry,
-			ProxyEndpoint: replicatedRegistryInfo.Proxy,
-		},
-		ReportWriter: pullOptions.ReportWriter,
-		DestinationRegistry: registrytypes.RegistryOptions{
-			Endpoint:  pullOptions.RewriteImageOptions.Host,
-			Namespace: pullOptions.RewriteImageOptions.Namespace,
-			Username:  pullOptions.RewriteImageOptions.Username,
-			Password:  pullOptions.RewriteImageOptions.Password,
-		},
-	}
-	if license != nil {
-		processAirgapImageOptions.ReplicatedRegistry.Username = license.Spec.LicenseID
-		processAirgapImageOptions.ReplicatedRegistry.Password = license.Spec.LicenseID
-	}
-
-	imagesData, err := ioutil.ReadFile(filepath.Join(pullOptions.AirgapRoot, "images.json"))
-	if err != nil && !os.IsNotExist(err) {
-		return nil, errors.Wrap(err, "failed to load images file")
-	}
-	if err == nil {
-		var images []kustomizetypes.Image
-		err := json.Unmarshal(imagesData, &images)
-		if err != nil && !os.IsNotExist(err) {
-			return nil, errors.Wrap(err, "failed to unmarshal images data")
-		}
-		processAirgapImageOptions.UseKnownImages = true
-		processAirgapImageOptions.KnownImages = images
-	}
-
-	result, err := upstream.ProcessAirgapImages(processAirgapImageOptions)
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to process airgap images")
-	}
-
-	return result, nil
-}
-
-// findPrivateImages Finds and rewrites private images to be proxied through proxy.replicated.com
-func findPrivateImages(writeMidstreamOptions midstream.WriteOptions, b *base.Base, kotsKinds *kotsutil.KotsKinds, license *kotsv1beta1.License, dockerHubRegistryCreds registry.Credentials) (*base.FindPrivateImagesResult, error) {
-	replicatedRegistryInfo := registry.GetRegistryProxyInfo(license, &kotsKinds.KotsApplication)
-	allPrivate := kotsKinds.KotsApplication.Spec.ProxyPublicImages
-
-	findPrivateImagesOptions := base.FindPrivateImagesOptions{
-		BaseDir: writeMidstreamOptions.BaseDir,
-		AppSlug: license.Spec.AppSlug,
-		ReplicatedRegistry: registrytypes.RegistryOptions{
-			Endpoint:         replicatedRegistryInfo.Registry,
-			ProxyEndpoint:    replicatedRegistryInfo.Proxy,
-			UpstreamEndpoint: replicatedRegistryInfo.Upstream,
-		},
-		DockerHubRegistry: registrytypes.RegistryOptions{
-			Username: dockerHubRegistryCreds.Username,
-			Password: dockerHubRegistryCreds.Password,
-		},
-		Installation:     &kotsKinds.Installation,
-		AllImagesPrivate: allPrivate,
-		HelmChartPath:    b.Path,
-		UseHelmInstall:   writeMidstreamOptions.UseHelmInstall,
-		KotsKindsImages:  kotsutil.GetImagesFromKotsKinds(kotsKinds),
-	}
-	findResult, err := base.FindPrivateImages(findPrivateImagesOptions)
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to find private images")
-	}
-
-	return findResult, nil
-}
-
 func removeUnusedHelmOverlays(overlayRoot string, baseRoot string) error {
 	// Only cleanup "charts" subdirectory. This can be isolated from customer overlays, so we don't destroy them.
 	return removeUnusedHelmOverlaysRec(overlayRoot, baseRoot, "charts")
diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go
index ff65ffe6b..e5a852989 100644
--- a/pkg/registry/registry.go
+++ b/pkg/registry/registry.go
@@ -125,29 +125,31 @@ func RewriteImages(appID string, sequence int64, hostname string, username strin
 	}
 
 	options := rewrite.RewriteOptions{
-		RootDir:            appDir,
-		UpstreamURI:        fmt.Sprintf("replicated://%s", kotsKinds.License.Spec.AppSlug),
-		UpstreamPath:       filepath.Join(appDir, "upstream"),
-		Installation:       &kotsKinds.Installation,
-		Downstreams:        downstreamNames,
-		CreateAppDir:       false,
-		ExcludeKotsKinds:   true,
-		License:            kotsKinds.License,
-		ConfigValues:       configValues,
-		KotsApplication:    &kotsKinds.KotsApplication,
-		K8sNamespace:       appNamespace,
-		ReportWriter:       pipeWriter,
-		IsAirgap:           a.IsAirgap,
-		RegistryEndpoint:   hostname,
-		RegistryUsername:   username,
-		RegistryPassword:   password,
-		RegistryNamespace:  namespace,
-		RegistryIsReadOnly: isReadOnly,
-		AppID:              a.ID,
-		AppSlug:            a.Slug,
-		IsGitOps:           a.IsGitOps,
-		AppSequence:        nextAppSequence,
-		ReportingInfo:      reporting.GetReportingInfo(a.ID),
+		RootDir:          appDir,
+		UpstreamURI:      fmt.Sprintf("replicated://%s", kotsKinds.License.Spec.AppSlug),
+		UpstreamPath:     filepath.Join(appDir, "upstream"),
+		Installation:     &kotsKinds.Installation,
+		Downstreams:      downstreamNames,
+		CreateAppDir:     false,
+		ExcludeKotsKinds: true,
+		License:          kotsKinds.License,
+		ConfigValues:     configValues,
+		KotsApplication:  &kotsKinds.KotsApplication,
+		K8sNamespace:     appNamespace,
+		ReportWriter:     pipeWriter,
+		IsAirgap:         a.IsAirgap,
+		RegistrySettings: types.RegistrySettings{
+			Hostname:   hostname,
+			Namespace:  namespace,
+			Username:   username,
+			Password:   password,
+			IsReadOnly: isReadOnly,
+		},
+		AppID:         a.ID,
+		AppSlug:       a.Slug,
+		IsGitOps:      a.IsGitOps,
+		AppSequence:   nextAppSequence,
+		ReportingInfo: reporting.GetReportingInfo(a.ID),
 
 		// TODO: pass in as arguments if this is ever called from CLI
 		HTTPProxyEnvValue:  os.Getenv("HTTP_PROXY"),
diff --git a/pkg/registry/types/types.go b/pkg/registry/types/types.go
index e362d5add..6dd628386 100644
--- a/pkg/registry/types/types.go
+++ b/pkg/registry/types/types.go
@@ -1,12 +1,11 @@
 package types
 
 type RegistrySettings struct {
-	Hostname    string
-	Username    string
-	PasswordEnc string
-	Password    string
-	Namespace   string
-	IsReadOnly  bool
+	Hostname   string
+	Username   string
+	Password   string
+	Namespace  string
+	IsReadOnly bool
 }
 
 const (
diff --git a/pkg/render/render.go b/pkg/render/render.go
index d1e224184..c57ecc77b 100644
--- a/pkg/render/render.go
+++ b/pkg/render/render.go
@@ -53,14 +53,6 @@ func RenderContent(kotsKinds *kotsutil.KotsKinds, registrySettings registrytypes
 }
 
 func NewBuilder(kotsKinds *kotsutil.KotsKinds, registrySettings registrytypes.RegistrySettings, appSlug string, sequence int64, isAirgap bool, namespace string) (*template.Builder, error) {
-	localRegistry := template.LocalRegistry{
-		Host:      registrySettings.Hostname,
-		Namespace: registrySettings.Namespace,
-		Username:  registrySettings.Username,
-		Password:  registrySettings.Password,
-		ReadOnly:  registrySettings.IsReadOnly,
-	}
-
 	templateContextValues := make(map[string]template.ItemValue)
 	if kotsKinds.ConfigValues != nil {
 		for k, v := range kotsKinds.ConfigValues.Spec.Values {
@@ -90,7 +82,7 @@ func NewBuilder(kotsKinds *kotsutil.KotsKinds, registrySettings registrytypes.Re
 	builderOptions := template.BuilderOptions{
 		ConfigGroups:    configGroups,
 		ExistingValues:  templateContextValues,
-		LocalRegistry:   localRegistry,
+		LocalRegistry:   registrySettings,
 		License:         kotsKinds.License,
 		Application:     &kotsKinds.KotsApplication,
 		ApplicationInfo: &appInfo,
@@ -126,30 +118,26 @@ func RenderDir(archiveDir string, a *apptypes.App, downstreams []downstreamtypes
 	}
 
 	reOptions := rewrite.RewriteOptions{
-		RootDir:            archiveDir,
-		UpstreamURI:        fmt.Sprintf("replicated://%s", kotsKinds.License.Spec.AppSlug),
-		UpstreamPath:       filepath.Join(archiveDir, "upstream"),
-		Installation:       &kotsKinds.Installation,
-		Downstreams:        downstreamNames,
-		Silent:             true,
-		CreateAppDir:       false,
-		ExcludeKotsKinds:   true,
-		License:            kotsKinds.License,
-		ConfigValues:       kotsKinds.ConfigValues,
-		KotsApplication:    &kotsKinds.KotsApplication,
-		K8sNamespace:       appNamespace,
-		CopyImages:         false,
-		IsAirgap:           a.IsAirgap,
-		AppID:              a.ID,
-		AppSlug:            a.Slug,
-		IsGitOps:           a.IsGitOps,
-		AppSequence:        sequence,
-		ReportingInfo:      reporting.GetReportingInfo(a.ID),
-		RegistryEndpoint:   registrySettings.Hostname,
-		RegistryNamespace:  registrySettings.Namespace,
-		RegistryUsername:   registrySettings.Username,
-		RegistryPassword:   registrySettings.Password,
-		RegistryIsReadOnly: registrySettings.IsReadOnly,
+		RootDir:          archiveDir,
+		UpstreamURI:      fmt.Sprintf("replicated://%s", kotsKinds.License.Spec.AppSlug),
+		UpstreamPath:     filepath.Join(archiveDir, "upstream"),
+		Installation:     &kotsKinds.Installation,
+		Downstreams:      downstreamNames,
+		Silent:           true,
+		CreateAppDir:     false,
+		ExcludeKotsKinds: true,
+		License:          kotsKinds.License,
+		ConfigValues:     kotsKinds.ConfigValues,
+		KotsApplication:  &kotsKinds.KotsApplication,
+		K8sNamespace:     appNamespace,
+		CopyImages:       false,
+		IsAirgap:         a.IsAirgap,
+		AppID:            a.ID,
+		AppSlug:          a.Slug,
+		IsGitOps:         a.IsGitOps,
+		AppSequence:      sequence,
+		ReportingInfo:    reporting.GetReportingInfo(a.ID),
+		RegistrySettings: registrySettings,
 
 		// TODO: pass in as arguments if this is ever called from CLI
 		HTTPProxyEnvValue:  os.Getenv("HTTP_PROXY"),
diff --git a/pkg/rewrite/rewrite.go b/pkg/rewrite/rewrite.go
index c00db71b8..27269b8fc 100644
--- a/pkg/rewrite/rewrite.go
+++ b/pkg/rewrite/rewrite.go
@@ -12,18 +12,15 @@ import (
 	reportingtypes "github.com/replicatedhq/kots/pkg/api/reporting/types"
 	"github.com/replicatedhq/kots/pkg/base"
 	"github.com/replicatedhq/kots/pkg/crypto"
-	"github.com/replicatedhq/kots/pkg/docker/registry"
-	registrytypes "github.com/replicatedhq/kots/pkg/docker/registry/types"
 	"github.com/replicatedhq/kots/pkg/downstream"
-	"github.com/replicatedhq/kots/pkg/k8sdoc"
 	"github.com/replicatedhq/kots/pkg/k8sutil"
 	"github.com/replicatedhq/kots/pkg/kotsutil"
 	"github.com/replicatedhq/kots/pkg/logger"
 	"github.com/replicatedhq/kots/pkg/midstream"
+	registrytypes "github.com/replicatedhq/kots/pkg/registry/types"
 	"github.com/replicatedhq/kots/pkg/store"
 	"github.com/replicatedhq/kots/pkg/upstream"
 	upstreamtypes "github.com/replicatedhq/kots/pkg/upstream/types"
-	"github.com/replicatedhq/kots/pkg/util"
 	kustomizetypes "sigs.k8s.io/kustomize/api/types"
 )
 
@@ -43,11 +40,7 @@ type RewriteOptions struct {
 	ReportWriter       io.Writer
 	CopyImages         bool // can be false even if registry is not read-only
 	IsAirgap           bool
-	RegistryEndpoint   string
-	RegistryUsername   string
-	RegistryPassword   string
-	RegistryNamespace  string
-	RegistryIsReadOnly bool
+	RegistrySettings   registrytypes.RegistrySettings
 	AppID              string
 	AppSlug            string
 	IsGitOps           bool
@@ -81,15 +74,9 @@ func Rewrite(rewriteOptions RewriteOptions) error {
 		License:                  rewriteOptions.License,
 		AppSequence:              rewriteOptions.AppSequence,
 		AppSlug:                  rewriteOptions.AppSlug,
-		LocalRegistry: upstreamtypes.LocalRegistry{
-			Host:      rewriteOptions.RegistryEndpoint,
-			Namespace: rewriteOptions.RegistryNamespace,
-			Username:  rewriteOptions.RegistryUsername,
-			Password:  rewriteOptions.RegistryPassword,
-			ReadOnly:  rewriteOptions.RegistryIsReadOnly,
-		},
-		ReportingInfo:          rewriteOptions.ReportingInfo,
-		SkipCompatibilityCheck: true, // we're rewriting an existing version, no need to check for compatibility
+		LocalRegistry:            rewriteOptions.RegistrySettings,
+		ReportingInfo:            rewriteOptions.ReportingInfo,
+		SkipCompatibilityCheck:   true, // we're rewriting an existing version, no need to check for compatibility
 	}
 
 	log.ActionWithSpinner("Pulling upstream")
@@ -121,11 +108,11 @@ func Rewrite(rewriteOptions RewriteOptions) error {
 	renderOptions := base.RenderOptions{
 		SplitMultiDocYAML:       true,
 		Namespace:               rewriteOptions.K8sNamespace,
-		LocalRegistryHost:       rewriteOptions.RegistryEndpoint,
-		LocalRegistryNamespace:  rewriteOptions.RegistryNamespace,
-		LocalRegistryUsername:   rewriteOptions.RegistryUsername,
-		LocalRegistryPassword:   rewriteOptions.RegistryPassword,
-		LocalRegistryIsReadOnly: rewriteOptions.RegistryIsReadOnly,
+		LocalRegistryHost:       rewriteOptions.RegistrySettings.Hostname,
+		LocalRegistryNamespace:  rewriteOptions.RegistrySettings.Namespace,
+		LocalRegistryUsername:   rewriteOptions.RegistrySettings.Username,
+		LocalRegistryPassword:   rewriteOptions.RegistrySettings.Password,
+		LocalRegistryIsReadOnly: rewriteOptions.RegistrySettings.IsReadOnly,
 		ExcludeKotsKinds:        rewriteOptions.ExcludeKotsKinds,
 		Log:                     log,
 		AppSlug:                 rewriteOptions.AppSlug,
@@ -251,7 +238,28 @@ func Rewrite(rewriteOptions RewriteOptions) error {
 	writeMidstreamOptions.MidstreamDir = filepath.Join(commonBase.GetOverlaysDir(writeBaseOptions), "midstream")
 	writeMidstreamOptions.BaseDir = filepath.Join(u.GetBaseDir(writeUpstreamOptions), commonBase.Path)
 
-	m, err := writeMidstream(writeMidstreamOptions, rewriteOptions, commonBase, u.GetUpstreamDir(writeUpstreamOptions), log)
+	processImageOptions := midstream.ProcessImageOptions{
+		AppSlug:          rewriteOptions.AppSlug,
+		Namespace:        rewriteOptions.K8sNamespace,
+		RewriteImages:    rewriteOptions.RegistrySettings.Hostname != "",
+		RegistrySettings: rewriteOptions.RegistrySettings,
+		CopyImages:       rewriteOptions.CopyImages,
+		RootDir:          rewriteOptions.RootDir,
+		IsAirgap:         rewriteOptions.IsAirgap,
+		AirgapRoot:       "",
+		AirgapBundle:     "",
+		PushImages:       rewriteOptions.RegistrySettings.Hostname != "",
+		CreateAppDir:     false,
+		ReportWriter:     rewriteOptions.ReportWriter,
+	}
+
+	upstreamDir := u.GetUpstreamDir(writeUpstreamOptions)
+	identityConfig, err := upstream.LoadIdentityConfig(upstreamDir)
+	if err != nil {
+		return errors.Wrap(err, "failed to load identity config")
+	}
+
+	m, err := midstream.WriteMidstream(writeMidstreamOptions, processImageOptions, commonBase, rewriteOptions.License, identityConfig, upstreamDir, log)
 	if err != nil {
 		return errors.Wrap(err, "failed to write common midstream")
 	}
@@ -271,11 +279,11 @@ func Rewrite(rewriteOptions RewriteOptions) error {
 
 		helmBaseCopy := helmBase.DeepCopy()
 
-		rewriteOptionsCopy := rewriteOptions
-		rewriteOptionsCopy.K8sNamespace = helmBaseCopy.Namespace
-		rewriteOptionsCopy.CopyImages = false // don't copy images more than once
+		processImageOptionsCopy := processImageOptions
+		processImageOptionsCopy.Namespace = helmBaseCopy.Namespace
+		processImageOptionsCopy.CopyImages = false // don't copy images more than once
 
-		helmMidstream, err := writeMidstream(writeMidstreamOptions, rewriteOptionsCopy, helmBaseCopy, u.GetUpstreamDir(writeUpstreamOptions), log)
+		helmMidstream, err := midstream.WriteMidstream(writeMidstreamOptions, processImageOptionsCopy, helmBaseCopy, rewriteOptions.License, identityConfig, upstreamDir, log)
 		if err != nil {
 			return errors.Wrapf(err, "failed to write helm midstream %s", helmBase.Path)
 		}
@@ -305,186 +313,6 @@ func Rewrite(rewriteOptions RewriteOptions) error {
 	return nil
 }
 
-func writeMidstream(writeMidstreamOptions midstream.WriteOptions, options RewriteOptions, b *base.Base, upstreamDir string, log *logger.CLILogger) (*midstream.Midstream, error) {
-	var images []kustomizetypes.Image
-	var objects []k8sdoc.K8sDoc
-	var pullSecretRegistries []string
-	var pullSecretUsername string
-	var pullSecretPassword string
-
-	clientset, err := k8sutil.GetClientset()
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to get k8s clientset")
-	}
-
-	newKotsKinds, err := kotsutil.LoadKotsKindsFromPath(upstreamDir)
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to load kotskinds from new upstream")
-	}
-
-	identitySpec, err := upstream.LoadIdentity(upstreamDir)
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to load identity")
-	}
-
-	identityConfig, err := upstream.LoadIdentityConfig(upstreamDir)
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to load identity config")
-	}
-
-	// do not fail on being unable to get dockerhub credentials, since they're just used to increase the rate limit
-	var dockerHubRegistryCreds registry.Credentials
-	dockerhubSecret, _ := registry.GetDockerHubPullSecret(clientset, util.PodNamespace, options.K8sNamespace, options.AppSlug)
-	if dockerhubSecret != nil {
-		dockerHubRegistryCreds, _ = registry.GetCredentialsForRegistryFromConfigJSON(dockerhubSecret.Data[".dockerconfigjson"], registry.DockerHubRegistryName)
-	}
-
-	if options.RegistryEndpoint != "" {
-		// A target registry is configured. Rewrite all images and copy them (if necessary) to the configured registry.
-		rewriteResult, err := rewriteBaseImages(options, writeMidstreamOptions.BaseDir, newKotsKinds, dockerHubRegistryCreds, log)
-		if err != nil {
-			return nil, errors.Wrap(err, "failed to rewrite base images")
-		}
-		images = rewriteResult.Images
-		newKotsKinds.Installation.Spec.KnownImages = rewriteResult.CheckedImages
-		objects = base.FindObjectsWithImages(b)
-
-		// Use target registry credentials to create image pull secrets for all objects that have images.
-		pullSecretRegistries = []string{options.RegistryEndpoint}
-		pullSecretUsername = options.RegistryUsername
-		pullSecretPassword = options.RegistryPassword
-		if pullSecretUsername == "" {
-			// this will only work when envoked from CLI where `docker login` command has been executed
-			pullSecretUsername, pullSecretPassword, err = registry.LoadAuthForRegistry(options.RegistryEndpoint)
-			if err != nil {
-				return nil, errors.Wrapf(err, "failed to load registry auth for %q", options.RegistryEndpoint)
-			}
-		}
-	} else if options.License != nil {
-		// A target registry is NOT configured. Find and rewrite private images to be proxied through proxy.replicated.com
-		findResult, err := findPrivateImages(writeMidstreamOptions, options, b, newKotsKinds, dockerHubRegistryCreds)
-		if err != nil {
-			return nil, errors.Wrap(err, "failed to find private images")
-		}
-		images = findResult.Images
-		newKotsKinds.Installation.Spec.KnownImages = findResult.CheckedImages
-		objects = findResult.Docs
-
-		// Use license to create image pull secrets for all objects that have private images.
-		pullSecretRegistries = registry.GetRegistryProxyInfo(options.License, options.KotsApplication).ToSlice()
-		pullSecretUsername = options.License.Spec.LicenseID
-		pullSecretPassword = options.License.Spec.LicenseID
-	}
-
-	// For the newer style charts, create a new secret per chart as helm adds chart specific
-	// details to annotations and labels to it.
-	namePrefix := options.AppSlug
-	for _, v := range writeMidstreamOptions.NewHelmCharts {
-		if v.Spec.UseHelmInstall && filepath.Base(b.Path) != "." {
-			namePrefix = fmt.Sprintf("%s-%s", options.AppSlug, filepath.Base(b.Path))
-			break
-		}
-	}
-	pullSecrets, err := registry.PullSecretForRegistries(
-		pullSecretRegistries,
-		pullSecretUsername,
-		pullSecretPassword,
-		options.K8sNamespace,
-		namePrefix,
-	)
-	if err != nil {
-		return nil, errors.Wrap(err, "create pull secret")
-	}
-	pullSecrets.DockerHubSecret = dockerhubSecret
-
-	if err := upstream.SaveInstallation(&newKotsKinds.Installation, upstreamDir); err != nil {
-		return nil, errors.Wrap(err, "failed to save installation")
-	}
-
-	m, err := midstream.CreateMidstream(b, images, objects, &pullSecrets, identitySpec, identityConfig)
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to create midstream")
-	}
-
-	if err := m.WriteMidstream(writeMidstreamOptions); err != nil {
-		return nil, errors.Wrap(err, "failed to write common midstream")
-	}
-
-	return m, nil
-}
-
-// rewriteBaseImages Will rewrite images found in base and copy them (if necessary) to the configured registry.
-func rewriteBaseImages(rewriteOptions RewriteOptions, baseDir string, kotsKinds *kotsutil.KotsKinds, dockerHubRegistryCreds registry.Credentials, log *logger.CLILogger) (*base.RewriteImagesResult, error) {
-	replicatedRegistryInfo := registry.GetRegistryProxyInfo(rewriteOptions.License, rewriteOptions.KotsApplication)
-
-	rewriteImageOptions := base.RewriteImageOptions{
-		BaseDir:      baseDir,
-		ReportWriter: rewriteOptions.ReportWriter,
-		Log:          log,
-		SourceRegistry: registrytypes.RegistryOptions{
-			Endpoint:      replicatedRegistryInfo.Registry,
-			ProxyEndpoint: replicatedRegistryInfo.Proxy,
-		},
-		DestRegistry: registrytypes.RegistryOptions{
-			Endpoint:  rewriteOptions.RegistryEndpoint,
-			Namespace: rewriteOptions.RegistryNamespace,
-			Username:  rewriteOptions.RegistryUsername,
-			Password:  rewriteOptions.RegistryPassword,
-		},
-		DockerHubRegistry: registrytypes.RegistryOptions{
-			Username: dockerHubRegistryCreds.Username,
-			Password: dockerHubRegistryCreds.Password,
-		},
-		KotsKinds:  kotsKinds,
-		IsAirgap:   rewriteOptions.IsAirgap,
-		CopyImages: rewriteOptions.CopyImages,
-	}
-
-	if rewriteOptions.License != nil {
-		rewriteImageOptions.AppSlug = rewriteOptions.License.Spec.AppSlug
-		rewriteImageOptions.SourceRegistry.Username = rewriteOptions.License.Spec.LicenseID
-		rewriteImageOptions.SourceRegistry.Password = rewriteOptions.License.Spec.LicenseID
-	}
-
-	rewriteResult, err := base.RewriteImages(rewriteImageOptions)
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to rewrite images")
-	}
-
-	return rewriteResult, nil
-}
-
-// findPrivateImages Finds and rewrites private images to be proxied through proxy.replicated.com
-func findPrivateImages(writeMidstreamOptions midstream.WriteOptions, rewriteOptions RewriteOptions, b *base.Base, kotsKinds *kotsutil.KotsKinds, dockerHubRegistryCreds registry.Credentials) (*base.FindPrivateImagesResult, error) {
-	replicatedRegistryInfo := registry.GetRegistryProxyInfo(rewriteOptions.License, rewriteOptions.KotsApplication)
-	allPrivate := kotsKinds.KotsApplication.Spec.ProxyPublicImages
-
-	findPrivateImagesOptions := base.FindPrivateImagesOptions{
-		BaseDir: writeMidstreamOptions.BaseDir,
-		AppSlug: rewriteOptions.License.Spec.AppSlug,
-		ReplicatedRegistry: registrytypes.RegistryOptions{
-			Endpoint:         replicatedRegistryInfo.Registry,
-			ProxyEndpoint:    replicatedRegistryInfo.Proxy,
-			UpstreamEndpoint: replicatedRegistryInfo.Upstream,
-		},
-		DockerHubRegistry: registrytypes.RegistryOptions{
-			Username: dockerHubRegistryCreds.Username,
-			Password: dockerHubRegistryCreds.Password,
-		},
-		Installation:     rewriteOptions.Installation,
-		AllImagesPrivate: allPrivate,
-		HelmChartPath:    b.Path,
-		UseHelmInstall:   writeMidstreamOptions.UseHelmInstall,
-		KotsKindsImages:  kotsutil.GetImagesFromKotsKinds(kotsKinds),
-	}
-	findResult, err := base.FindPrivateImages(findPrivateImagesOptions)
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to find private images")
-	}
-
-	return findResult, nil
-}
-
 func writeDownstreams(options RewriteOptions, overlaysDir string, m *midstream.Midstream, helmMidstreams []midstream.Midstream, log *logger.CLILogger) error {
 	for _, downstreamName := range options.Downstreams {
 		log.ActionWithSpinner("Creating downstream %q", downstreamName)
diff --git a/pkg/store/kotsstore/registry_store.go b/pkg/store/kotsstore/registry_store.go
index ac5ae5ea9..287befb1b 100644
--- a/pkg/store/kotsstore/registry_store.go
+++ b/pkg/store/kotsstore/registry_store.go
@@ -38,18 +38,17 @@ func (s *KOTSStore) GetRegistryDetailsForApp(appID string) (registrytypes.Regist
 	}
 
 	registrySettings := registrytypes.RegistrySettings{
-		Hostname:    registryHostname.String,
-		Username:    registryUsername.String,
-		PasswordEnc: registryPasswordEnc.String,
-		Namespace:   registryNamespace.String,
-		IsReadOnly:  isReadOnly.Bool,
+		Hostname:   registryHostname.String,
+		Username:   registryUsername.String,
+		Namespace:  registryNamespace.String,
+		IsReadOnly: isReadOnly.Bool,
 	}
 
 	if !registryPasswordEnc.Valid {
 		return registrySettings, nil
 	}
 
-	decodedPassword, err := base64.StdEncoding.DecodeString(registrySettings.PasswordEnc)
+	decodedPassword, err := base64.StdEncoding.DecodeString(registryPasswordEnc.String)
 	if err != nil {
 		return registrytypes.RegistrySettings{}, errors.Wrap(err, "failed to decode")
 	}
diff --git a/pkg/store/kotsstore/version_store.go b/pkg/store/kotsstore/version_store.go
index 7189e68bb..90e5ba565 100644
--- a/pkg/store/kotsstore/version_store.go
+++ b/pkg/store/kotsstore/version_store.go
@@ -242,8 +242,16 @@ func (s *KOTSStore) GetTargetKotsVersionForVersion(appID string, sequence int64)
 func (s *KOTSStore) CreateAppVersionArchive(appID string, sequence int64, archivePath string) error {
 	paths := []string{
 		filepath.Join(archivePath, "upstream"),
-		filepath.Join(archivePath, "base"),
-		filepath.Join(archivePath, "overlays"),
+	}
+
+	basePath := filepath.Join(archivePath, "base")
+	if _, err := os.Stat(basePath); err == nil {
+		paths = append(paths, basePath)
+	}
+
+	overlaysPath := filepath.Join(archivePath, "overlays")
+	if _, err := os.Stat(overlaysPath); err == nil {
+		paths = append(paths, overlaysPath)
 	}
 
 	skippedFilesPath := filepath.Join(archivePath, "skippedFiles")
diff --git a/pkg/template/builder.go b/pkg/template/builder.go
index f2c1d8084..20e17f5b2 100644
--- a/pkg/template/builder.go
+++ b/pkg/template/builder.go
@@ -9,8 +9,9 @@ import (
 	"github.com/pkg/errors"
 	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
 	"github.com/replicatedhq/kots/pkg/docker/registry"
-	registrytypes "github.com/replicatedhq/kots/pkg/docker/registry/types"
+	dockerregistrytypes "github.com/replicatedhq/kots/pkg/docker/registry/types"
 	"github.com/replicatedhq/kots/pkg/k8sutil"
+	registrytypes "github.com/replicatedhq/kots/pkg/registry/types"
 )
 
 var (
@@ -25,7 +26,7 @@ type Builder struct {
 type BuilderOptions struct {
 	ConfigGroups    []kotsv1beta1.ConfigGroup
 	ExistingValues  map[string]ItemValue
-	LocalRegistry   LocalRegistry
+	LocalRegistry   registrytypes.RegistrySettings
 	License         *kotsv1beta1.License
 	Application     *kotsv1beta1.Application
 	ApplicationInfo *ApplicationInfo
@@ -40,12 +41,12 @@ func NewBuilder(opts BuilderOptions) (Builder, map[string]ItemValue, error) {
 	b := Builder{}
 
 	// do not fail on being unable to get dockerhub credentials, since they're just used to increase the rate limit
-	dockerHubRegistry := registrytypes.RegistryOptions{}
+	dockerHubRegistry := dockerregistrytypes.RegistryOptions{}
 	if opts.Namespace != "" {
 		clientset, err := k8sutil.GetClientset()
 		if err == nil {
 			dockerHubRegistryCreds, _ := registry.GetDockerHubCredentials(clientset, opts.Namespace)
-			dockerHubRegistry = registrytypes.RegistryOptions{
+			dockerHubRegistry = dockerregistrytypes.RegistryOptions{
 				Username: dockerHubRegistryCreds.Username,
 				Password: dockerHubRegistryCreds.Password,
 			}
diff --git a/pkg/template/config_context.go b/pkg/template/config_context.go
index 418384fd7..be28ee9f4 100644
--- a/pkg/template/config_context.go
+++ b/pkg/template/config_context.go
@@ -10,8 +10,9 @@ import (
 	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
 	"github.com/replicatedhq/kots/pkg/crypto"
 	"github.com/replicatedhq/kots/pkg/docker/registry"
-	registrytypes "github.com/replicatedhq/kots/pkg/docker/registry/types"
+	dockerregistrytypes "github.com/replicatedhq/kots/pkg/docker/registry/types"
 	"github.com/replicatedhq/kots/pkg/image"
+	registrytypes "github.com/replicatedhq/kots/pkg/registry/types"
 	corev1 "k8s.io/api/core/v1"
 )
 
@@ -19,14 +20,6 @@ var (
 	dockerImageNameRegex = regexp.MustCompile("(?:([^\\/]+)\\/)?(?:([^\\/]+)\\/)?([^@:\\/]+)(?:[@:](.+))")
 )
 
-type LocalRegistry struct {
-	Host      string
-	Namespace string
-	Username  string
-	Password  string
-	ReadOnly  bool
-}
-
 type ItemValue struct {
 	Value          interface{}
 	Default        interface{}
@@ -65,8 +58,8 @@ func (i ItemValue) DefaultStr() string {
 // ConfigCtx is the context for builder functions before the application has started.
 type ConfigCtx struct {
 	ItemValues        map[string]ItemValue
-	LocalRegistry     LocalRegistry
-	DockerHubRegistry registrytypes.RegistryOptions
+	LocalRegistry     registrytypes.RegistrySettings
+	DockerHubRegistry dockerregistrytypes.RegistryOptions
 	AppSlug           string
 	DecryptValues     bool
 
@@ -75,7 +68,7 @@ type ConfigCtx struct {
 }
 
 // newConfigContext creates and returns a context for template rendering
-func (b *Builder) newConfigContext(configGroups []kotsv1beta1.ConfigGroup, existingValues map[string]ItemValue, localRegistry LocalRegistry, license *kotsv1beta1.License, app *kotsv1beta1.Application, info *VersionInfo, dockerHubRegistry registrytypes.RegistryOptions, appSlug string, decryptValues bool) (*ConfigCtx, error) {
+func (b *Builder) newConfigContext(configGroups []kotsv1beta1.ConfigGroup, existingValues map[string]ItemValue, localRegistry registrytypes.RegistrySettings, license *kotsv1beta1.License, app *kotsv1beta1.Application, info *VersionInfo, dockerHubRegistry dockerregistrytypes.RegistryOptions, appSlug string, decryptValues bool) (*ConfigCtx, error) {
 	configCtx := &ConfigCtx{
 		ItemValues:        existingValues,
 		LocalRegistry:     localRegistry,
@@ -276,14 +269,14 @@ func (ctx ConfigCtx) configOptionNotEquals(name string, value string) bool {
 
 func (ctx ConfigCtx) localRegistryAddress() string {
 	if ctx.LocalRegistry.Namespace == "" {
-		return ctx.LocalRegistry.Host
+		return ctx.LocalRegistry.Hostname
 	}
 
-	return fmt.Sprintf("%s/%s", ctx.LocalRegistry.Host, ctx.LocalRegistry.Namespace)
+	return fmt.Sprintf("%s/%s", ctx.LocalRegistry.Hostname, ctx.LocalRegistry.Namespace)
 }
 
 func (ctx ConfigCtx) localRegistryHost() string {
-	return ctx.LocalRegistry.Host
+	return ctx.LocalRegistry.Hostname
 }
 
 func (ctx ConfigCtx) localRegistryNamespace() string {
@@ -292,8 +285,8 @@ func (ctx ConfigCtx) localRegistryNamespace() string {
 
 func (ctx ConfigCtx) localImageName(imageRef string) string {
 	// If there's a private registry. Always rewrite everything. This covers airgap installs too.
-	if ctx.LocalRegistry.Host != "" {
-		destRegistry := registrytypes.RegistryOptions{
+	if ctx.LocalRegistry.Hostname != "" {
+		destRegistry := dockerregistrytypes.RegistryOptions{
 			Endpoint:  ctx.localRegistryHost(),
 			Namespace: ctx.localRegistryNamespace(),
 		}
@@ -320,7 +313,7 @@ func (ctx ConfigCtx) localImageName(imageRef string) string {
 	}
 
 	registryProxyInfo := registry.GetRegistryProxyInfo(ctx.license, ctx.app)
-	registryOptions := registrytypes.RegistryOptions{
+	registryOptions := dockerregistrytypes.RegistryOptions{
 		Endpoint:      registryProxyInfo.Registry,
 		ProxyEndpoint: registryProxyInfo.Proxy,
 	}
@@ -340,14 +333,14 @@ func (ctx ConfigCtx) localImageName(imageRef string) string {
 }
 
 func (ctx ConfigCtx) hasLocalRegistry() bool {
-	return ctx.LocalRegistry.Host != ""
+	return ctx.LocalRegistry.Hostname != ""
 }
 
 func (ctx ConfigCtx) localRegistryImagePullSecret() string {
 	var secret *corev1.Secret
-	if ctx.LocalRegistry.Host != "" {
+	if ctx.LocalRegistry.Hostname != "" {
 		secrets, err := registry.PullSecretForRegistries(
-			[]string{ctx.LocalRegistry.Host},
+			[]string{ctx.LocalRegistry.Hostname},
 			ctx.LocalRegistry.Username,
 			ctx.LocalRegistry.Password,
 			"default", // this value doesn't matter
diff --git a/pkg/upstream/replicated.go b/pkg/upstream/replicated.go
index 3c094297d..004029009 100644
--- a/pkg/upstream/replicated.go
+++ b/pkg/upstream/replicated.go
@@ -22,6 +22,7 @@ import (
 	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
 	reportingtypes "github.com/replicatedhq/kots/pkg/api/reporting/types"
 	"github.com/replicatedhq/kots/pkg/kotsutil"
+	registrytypes "github.com/replicatedhq/kots/pkg/registry/types"
 	"github.com/replicatedhq/kots/pkg/replicatedapp"
 	reporting "github.com/replicatedhq/kots/pkg/reporting"
 	"github.com/replicatedhq/kots/pkg/template"
@@ -134,7 +135,7 @@ func downloadReplicated(
 	appSequence int64,
 	isAirgap bool,
 	airgapMetadata *kotsv1beta1.Airgap,
-	registry types.LocalRegistry,
+	registry registrytypes.RegistrySettings,
 	reportingInfo *reportingtypes.ReportingInfo,
 	skipCompatibilityCheck bool,
 ) (*types.Upstream, error) {
@@ -266,16 +267,9 @@ func downloadReplicated(
 			IsAirgap:     isAirgap,
 		}
 
-		localRegistry := template.LocalRegistry{
-			Host:      registry.Host,
-			Namespace: registry.Namespace,
-			Username:  registry.Username,
-			Password:  registry.Password,
-		}
-
 		// If config existed and was removed from the app,
 		// values will be carried over to the new version anyway.
-		configValues, err := createConfigValues(application.Name, config, existingConfigValues, license, application, &appInfo, &versionInfo, localRegistry, existingIdentityConfig)
+		configValues, err := createConfigValues(application.Name, config, existingConfigValues, license, application, &appInfo, &versionInfo, registry, existingIdentityConfig)
 		if err != nil {
 			return nil, errors.Wrap(err, "failed to create empty config values")
 		}
@@ -525,7 +519,7 @@ func mustMarshalConfigValues(configValues *kotsv1beta1.ConfigValues) []byte {
 	return b.Bytes()
 }
 
-func createConfigValues(applicationName string, config *kotsv1beta1.Config, existingConfigValues *kotsv1beta1.ConfigValues, license *kotsv1beta1.License, app *kotsv1beta1.Application, appInfo *template.ApplicationInfo, versionInfo *template.VersionInfo, localRegistry template.LocalRegistry, identityConfig *kotsv1beta1.IdentityConfig) (*kotsv1beta1.ConfigValues, error) {
+func createConfigValues(applicationName string, config *kotsv1beta1.Config, existingConfigValues *kotsv1beta1.ConfigValues, license *kotsv1beta1.License, app *kotsv1beta1.Application, appInfo *template.ApplicationInfo, versionInfo *template.VersionInfo, localRegistry registrytypes.RegistrySettings, identityConfig *kotsv1beta1.IdentityConfig) (*kotsv1beta1.ConfigValues, error) {
 	templateContextValues := make(map[string]template.ItemValue)
 
 	var newValues kotsv1beta1.ConfigValuesSpec
diff --git a/pkg/upstream/types/types.go b/pkg/upstream/types/types.go
index 7876bd6f5..575e88995 100644
--- a/pkg/upstream/types/types.go
+++ b/pkg/upstream/types/types.go
@@ -7,6 +7,7 @@ import (
 	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
 	kotsscheme "github.com/replicatedhq/kots/kotskinds/client/kotsclientset/scheme"
 	reportingtypes "github.com/replicatedhq/kots/pkg/api/reporting/types"
+	registrytypes "github.com/replicatedhq/kots/pkg/registry/types"
 	"k8s.io/client-go/kubernetes/scheme"
 )
 
@@ -91,19 +92,11 @@ type FetchOptions struct {
 	AppSlug                  string
 	AppSequence              int64
 	AppVersionLabel          string
-	LocalRegistry            LocalRegistry
+	LocalRegistry            registrytypes.RegistrySettings
 	ReportingInfo            *reportingtypes.ReportingInfo
 	SkipCompatibilityCheck   bool
 }
 
-type LocalRegistry struct {
-	Host      string
-	Namespace string
-	Username  string
-	Password  string
-	ReadOnly  bool
-}
-
 func (u *Upstream) GetUpstreamDir(options WriteOptions) string {
 	renderDir := options.RootDir
 	if options.CreateAppDir {


Test patch:
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index a2a145b97..606d67818 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -10,6 +10,7 @@ import (
 	kotsscheme "github.com/replicatedhq/kots/kotskinds/client/kotsclientset/scheme"
 	"github.com/replicatedhq/kots/kotskinds/multitype"
 	"github.com/replicatedhq/kots/pkg/logger"
+	registrytypes "github.com/replicatedhq/kots/pkg/registry/types"
 	"github.com/replicatedhq/kots/pkg/template"
 	"github.com/stretchr/testify/require"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -364,7 +365,7 @@ spec:
 
 			configObj, _, _ := decode([]byte(tt.configSpecData), nil, nil)
 
-			localRegistry := template.LocalRegistry{}
+			localRegistry := registrytypes.RegistrySettings{}
 			got, err := templateConfigObjects(configObj.(*kotsv1beta1.Config), tt.configValuesData, license, app, localRegistry, versionInfo, appInfo, nil, "app-namespace", false, MarshalConfig)
 			req.NoError(err)
 
diff --git a/pkg/template/config_context_test.go b/pkg/template/config_context_test.go
index 10bc20522..6fed1f893 100644
--- a/pkg/template/config_context_test.go
+++ b/pkg/template/config_context_test.go
@@ -7,7 +7,8 @@ import (
 	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
 	"github.com/replicatedhq/kots/kotskinds/multitype"
 	"github.com/replicatedhq/kots/pkg/crypto"
-	registrytypes "github.com/replicatedhq/kots/pkg/docker/registry/types"
+	dockerregistrytypes "github.com/replicatedhq/kots/pkg/docker/registry/types"
+	registrytypes "github.com/replicatedhq/kots/pkg/registry/types"
 	"github.com/stretchr/testify/require"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
@@ -484,8 +485,8 @@ func TestBuilder_NewConfigContext(t *testing.T) {
 			builder := Builder{}
 			builder.AddCtx(StaticCtx{})
 
-			localRegistry := LocalRegistry{}
-			got, err := builder.newConfigContext(tt.args.configGroups, tt.args.templateContext, localRegistry, tt.args.license, nil, nil, registrytypes.RegistryOptions{}, "app-slug", tt.args.decryptValues)
+			localRegistry := registrytypes.RegistrySettings{}
+			got, err := builder.newConfigContext(tt.args.configGroups, tt.args.templateContext, localRegistry, tt.args.license, nil, nil, dockerregistrytypes.RegistryOptions{}, "app-slug", tt.args.decryptValues)
 			req.NoError(err)
 			req.Equal(tt.want, got)
 		})
@@ -494,8 +495,8 @@ func TestBuilder_NewConfigContext(t *testing.T) {
 
 func Test_localImageName(t *testing.T) {
 	ctxWithRegistry := ConfigCtx{
-		LocalRegistry: LocalRegistry{
-			Host:      "my.registry.com",
+		LocalRegistry: registrytypes.RegistrySettings{
+			Hostname:  "my.registry.com",
 			Namespace: "my_namespace",
 			Username:  "my_user",
 			Password:  "my_password",
@@ -514,7 +515,7 @@ func Test_localImageName(t *testing.T) {
 	}
 
 	ctxWithoutRegistry := ConfigCtx{
-		LocalRegistry: LocalRegistry{},
+		LocalRegistry: registrytypes.RegistrySettings{},
 
 		license: &kotsv1beta1.License{
 			Spec: kotsv1beta1.LicenseSpec{
@@ -530,7 +531,7 @@ func Test_localImageName(t *testing.T) {
 	}
 
 	ctxWithoutRegistryProxyAll := ConfigCtx{
-		LocalRegistry: LocalRegistry{},
+		LocalRegistry: registrytypes.RegistrySettings{},
 
 		license: &kotsv1beta1.License{
 			Spec: kotsv1beta1.LicenseSpec{
@@ -546,7 +547,7 @@ func Test_localImageName(t *testing.T) {
 	}
 
 	ctxWithNothing := ConfigCtx{
-		LocalRegistry: LocalRegistry{},
+		LocalRegistry: registrytypes.RegistrySettings{},
 	}
 
 	tests := []struct {
@@ -612,14 +613,14 @@ func Test_localImageName(t *testing.T) {
 func TestConfigCtx_localRegistryImagePullSecret(t *testing.T) {
 	tests := []struct {
 		name          string
-		LocalRegistry LocalRegistry
+		LocalRegistry registrytypes.RegistrySettings
 		license       *kotsv1beta1.License
 		want          string
 	}{
 		{
 			name: "nil license",
-			LocalRegistry: LocalRegistry{
-				Host:      "",
+			LocalRegistry: registrytypes.RegistrySettings{
+				Hostname:  "",
 				Namespace: "",
 				Username:  "",
 				Password:  "",
@@ -629,8 +630,8 @@ func TestConfigCtx_localRegistryImagePullSecret(t *testing.T) {
 		},
 		{
 			name: "licenseid abc",
-			LocalRegistry: LocalRegistry{
-				Host:      "",
+			LocalRegistry: registrytypes.RegistrySettings{
+				Hostname:  "",
 				Namespace: "",
 				Username:  "",
 				Password:  "",
@@ -647,8 +648,8 @@ func TestConfigCtx_localRegistryImagePullSecret(t *testing.T) {
 		},
 		{
 			name: "localregistry set",
-			LocalRegistry: LocalRegistry{
-				Host:      "example.com:5000",
+			LocalRegistry: registrytypes.RegistrySettings{
+				Hostname:  "example.com:5000",
 				Namespace: "",
 				Username:  "user",
 				Password:  "password",
diff --git a/pkg/tests/pull/cases/configcontext/testcase.yaml b/pkg/tests/pull/cases/configcontext/testcase.yaml
index be90ac447..f1243b9ef 100644
--- a/pkg/tests/pull/cases/configcontext/testcase.yaml
+++ b/pkg/tests/pull/cases/configcontext/testcase.yaml
@@ -9,7 +9,7 @@ PullOptions:
   SharedPassword: dummy-pass
   RewriteImages: true
   RewriteImageOptions:
-    Host: proxy.replicated.com
+    Hostname: proxy.replicated.com
     Username: test
     Password: fake-pass
     IsReadOnly: true
\ No newline at end of file
diff --git a/pkg/tests/pull/cases/samechartvariations/testcase.yaml b/pkg/tests/pull/cases/samechartvariations/testcase.yaml
index 6a02f110a..bd70de972 100644
--- a/pkg/tests/pull/cases/samechartvariations/testcase.yaml
+++ b/pkg/tests/pull/cases/samechartvariations/testcase.yaml
@@ -11,7 +11,7 @@ PullOptions:
   SharedPassword: dummy-pass
   RewriteImages: true
   RewriteImageOptions:
-    Host: fake-docker-proxy
+    Hostname: fake-docker-proxy
     Username: test
     Password: fake-pass
     IsReadOnly: true
\ No newline at end of file
diff --git a/pkg/tests/pull/cases/subchart-alias/testcase.yaml b/pkg/tests/pull/cases/subchart-alias/testcase.yaml
index 7dda3da7f..29a651bab 100644
--- a/pkg/tests/pull/cases/subchart-alias/testcase.yaml
+++ b/pkg/tests/pull/cases/subchart-alias/testcase.yaml
@@ -9,7 +9,7 @@ PullOptions:
   SharedPassword: dummy-pass
   RewriteImages: true
   RewriteImageOptions:
-    Host: fake-docker-proxy
+    Hostname: fake-docker-proxy
     Username: test
     Password: fake-pass
     IsReadOnly: true
\ No newline at end of file
diff --git a/pkg/tests/pull/cases/subcharts/testcase.yaml b/pkg/tests/pull/cases/subcharts/testcase.yaml
index 965e47845..107751340 100644
--- a/pkg/tests/pull/cases/subcharts/testcase.yaml
+++ b/pkg/tests/pull/cases/subcharts/testcase.yaml
@@ -9,7 +9,7 @@ PullOptions:
   SharedPassword: dummy-pass
   RewriteImages: true
   RewriteImageOptions:
-    Host: fake-docker-proxy
+    Hostname: fake-docker-proxy
     Username: test
     Password: fake-pass
     IsReadOnly: true
\ No newline at end of file
diff --git a/pkg/upstream/replicated_test.go b/pkg/upstream/replicated_test.go
index 8061aa8f0..04dc8313e 100644
--- a/pkg/upstream/replicated_test.go
+++ b/pkg/upstream/replicated_test.go
@@ -5,6 +5,7 @@ import (
 
 	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
 	"github.com/replicatedhq/kots/kotskinds/multitype"
+	registrytypes "github.com/replicatedhq/kots/pkg/registry/types"
 	"github.com/replicatedhq/kots/pkg/template"
 	"github.com/replicatedhq/kots/pkg/upstream/types"
 	"github.com/stretchr/testify/assert"
@@ -192,13 +193,13 @@ func Test_createConfigValues(t *testing.T) {
 			Default: "default_4",
 		},
 	}
-	values1, err := createConfigValues(applicationName, config, nil, nil, nil, appInfo, nil, template.LocalRegistry{}, nil)
+	values1, err := createConfigValues(applicationName, config, nil, nil, nil, appInfo, nil, registrytypes.RegistrySettings{}, nil)
 	req.NoError(err)
 	assert.Equal(t, expected1, values1.Spec.Values)
 
 	// Like an app without a config, should have exact same values
 	expected2 := configValues.Spec.Values
-	values2, err := createConfigValues(applicationName, nil, configValues, nil, nil, appInfo, nil, template.LocalRegistry{}, nil)
+	values2, err := createConfigValues(applicationName, nil, configValues, nil, nil, appInfo, nil, registrytypes.RegistrySettings{}, nil)
 	req.NoError(err)
 	assert.Equal(t, expected2, values2.Spec.Values)
 
@@ -219,7 +220,7 @@ func Test_createConfigValues(t *testing.T) {
 			Default: "default_4",
 		},
 	}
-	values3, err := createConfigValues(applicationName, config, configValues, nil, nil, appInfo, nil, template.LocalRegistry{}, nil)
+	values3, err := createConfigValues(applicationName, config, configValues, nil, nil, appInfo, nil, registrytypes.RegistrySettings{}, nil)
 	req.NoError(err)
 	assert.Equal(t, expected3, values3.Spec.Values)
 }