Repository: replicatedhq/kots
Pull request #201 (2019-12-16 23:02:38)

Add support for replicated apps that contain one or more helm charts
This PR adds support to include one or more Helm charts in a replicated app. Docs to follow.


Patch:
diff --git a/kotskinds/apis/kots/v1beta1/helmchart_types.go b/kotskinds/apis/kots/v1beta1/helmchart_types.go
new file mode 100644
index 000000000..2265404fe
--- /dev/null
+++ b/kotskinds/apis/kots/v1beta1/helmchart_types.go
@@ -0,0 +1,196 @@
+/*
+Copyright 2019 Replicated, Inc..
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v1beta1
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"github.com/pkg/errors"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+type MappedChartValue struct {
+	Value string `json:",inline"`
+
+	valueType string `json:"-"`
+
+	strValue   string  `json:"-"`
+	boolValue  bool    `json:"-"`
+	floatValue float64 `json:"-"`
+
+	children       map[string]*MappedChartValue `json:"-"`
+	childrenArrays []*MappedChartValue          `json:"-"`
+}
+
+func (m *MappedChartValue) GetValue() (interface{}, error) {
+	if m.valueType == "string" {
+		return m.strValue, nil
+	}
+	if m.valueType == "bool" {
+		return m.boolValue, nil
+	}
+	if m.valueType == "float" {
+		return m.floatValue, nil
+	}
+	if m.valueType == "nil" {
+		return nil, nil
+	}
+
+	if m.valueType == "children" {
+		return m.children, nil
+	}
+	if m.valueType == "childrenArray" {
+		return m.childrenArrays, nil
+	}
+
+	return nil, errors.New("unknown value type")
+}
+
+func (m *MappedChartValue) UnmarshalJSON(value []byte) error {
+	var b interface{}
+	err := json.Unmarshal(value, &b)
+	if err != nil {
+		return err
+	}
+
+	if b == nil {
+		m.valueType = "nil"
+		return nil
+	}
+
+	if b, ok := b.(string); ok {
+		m.strValue = b
+		m.valueType = "string"
+		return nil
+	}
+
+	if b, ok := b.(bool); ok {
+		m.boolValue = b
+		m.valueType = "bool"
+		return nil
+	}
+
+	if b, ok := b.(float64); ok {
+		m.floatValue = b
+		m.valueType = "float"
+		return nil
+	}
+
+	if b, ok := b.(map[string]interface{}); ok {
+		m.children = make(map[string]*MappedChartValue)
+		for k, v := range b {
+			vv, err := json.Marshal(v)
+			if err != nil {
+				return err
+			}
+
+			m2 := &MappedChartValue{}
+			if err := m2.UnmarshalJSON(vv); err != nil {
+				return err
+			}
+
+			m.children[k] = m2
+		}
+
+		m.valueType = "children"
+
+		return nil
+	}
+
+	if b, ok := b.([]interface{}); ok {
+		m.childrenArrays = []*MappedChartValue{}
+		for _, v := range b {
+			vv, err := json.Marshal(v)
+			if err != nil {
+				return err
+			}
+
+			m2 := &MappedChartValue{}
+			if err := m2.UnmarshalJSON(vv); err != nil {
+				return err
+			}
+
+			m.childrenArrays = append(m.childrenArrays, m2)
+		}
+
+		m.valueType = "childrenArray"
+
+		return nil
+	}
+
+	return errors.Errorf("unknown mapped chart value type: %T", b)
+}
+
+type ChartIdentifier struct {
+	Name         string `json:"name"`
+	ChartVersion string `json:"chartVersion"`
+}
+
+func (h *HelmChartSpec) RenderValues() ([]string, error) {
+	keys := []string{}
+
+	for k, v := range h.Values {
+		value, err := v.GetValue()
+		if err != nil {
+			return nil, errors.Wrap(err, "failed to get value")
+		}
+
+		key := fmt.Sprintf("%s=%v", k, value)
+		keys = append(keys, key)
+	}
+
+	return keys, nil
+}
+
+// HelmChartSpec defines the desired state of HelmChartSpec
+type HelmChartSpec struct {
+	Chart   ChartIdentifier             `json:"chart"`
+	Values  map[string]MappedChartValue `json:"values,omitempty"`
+	Builder map[string]MappedChartValue `json:"builder,omitempty"`
+}
+
+// HelmChartStatus defines the observed state of HelmChart
+type HelmChartStatus struct {
+}
+
+// +genclient
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+// HelmChart is the Schema for the helmchart API
+// +k8s:openapi-gen=true
+// +kubebuilder:subresource:status
+type HelmChart struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   HelmChartSpec   `json:"spec,omitempty"`
+	Status HelmChartStatus `json:"status,omitempty"`
+}
+
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+
+// HelmChartList contains a list of HelmCharts
+type HelmChartList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []HelmChart `json:"items"`
+}
+
+func init() {
+	SchemeBuilder.Register(&HelmChart{}, &HelmChartList{})
+}
diff --git a/kotskinds/apis/kots/v1beta1/zz_generated.deepcopy.go b/kotskinds/apis/kots/v1beta1/zz_generated.deepcopy.go
index 942d5d3e4..bfa789fbd 100644
--- a/kotskinds/apis/kots/v1beta1/zz_generated.deepcopy.go
+++ b/kotskinds/apis/kots/v1beta1/zz_generated.deepcopy.go
@@ -239,6 +239,21 @@ func (in *ApplicationStatus) DeepCopy() *ApplicationStatus {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ChartIdentifier) DeepCopyInto(out *ChartIdentifier) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChartIdentifier.
+func (in *ChartIdentifier) DeepCopy() *ChartIdentifier {
+	if in == nil {
+		return nil
+	}
+	out := new(ChartIdentifier)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *Config) DeepCopyInto(out *Config) {
 	*out = *in
@@ -539,6 +554,110 @@ func (in *EntitlementValue) DeepCopy() *EntitlementValue {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HelmChart) DeepCopyInto(out *HelmChart) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	in.Spec.DeepCopyInto(&out.Spec)
+	out.Status = in.Status
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmChart.
+func (in *HelmChart) DeepCopy() *HelmChart {
+	if in == nil {
+		return nil
+	}
+	out := new(HelmChart)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *HelmChart) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HelmChartList) DeepCopyInto(out *HelmChartList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	out.ListMeta = in.ListMeta
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]HelmChart, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmChartList.
+func (in *HelmChartList) DeepCopy() *HelmChartList {
+	if in == nil {
+		return nil
+	}
+	out := new(HelmChartList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *HelmChartList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HelmChartSpec) DeepCopyInto(out *HelmChartSpec) {
+	*out = *in
+	out.Chart = in.Chart
+	if in.Values != nil {
+		in, out := &in.Values, &out.Values
+		*out = make(map[string]MappedChartValue, len(*in))
+		for key, val := range *in {
+			(*out)[key] = *val.DeepCopy()
+		}
+	}
+	if in.Builder != nil {
+		in, out := &in.Builder, &out.Builder
+		*out = make(map[string]MappedChartValue, len(*in))
+		for key, val := range *in {
+			(*out)[key] = *val.DeepCopy()
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmChartSpec.
+func (in *HelmChartSpec) DeepCopy() *HelmChartSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(HelmChartSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HelmChartStatus) DeepCopyInto(out *HelmChartStatus) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmChartStatus.
+func (in *HelmChartStatus) DeepCopy() *HelmChartStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(HelmChartStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *Installation) DeepCopyInto(out *Installation) {
 	*out = *in
@@ -729,6 +848,36 @@ func (in *LicenseStatus) DeepCopy() *LicenseStatus {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *MappedChartValue) DeepCopyInto(out *MappedChartValue) {
+	*out = *in
+	if in.children != nil {
+		in, out := &in.children, &out.children
+		*out = make(map[string]*MappedChartValue, len(*in))
+		for key, val := range *in {
+			var outVal *MappedChartValue
+			if val == nil {
+				(*out)[key] = nil
+			} else {
+				in, out := &val, &outVal
+				*out = new(MappedChartValue)
+				(*in).DeepCopyInto(*out)
+			}
+			(*out)[key] = outVal
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MappedChartValue.
+func (in *MappedChartValue) DeepCopy() *MappedChartValue {
+	if in == nil {
+		return nil
+	}
+	out := new(MappedChartValue)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *MetricGraph) DeepCopyInto(out *MetricGraph) {
 	*out = *in
diff --git a/kotskinds/client/kotsclientset/typed/kots/v1beta1/fake/fake_helmchart.go b/kotskinds/client/kotsclientset/typed/kots/v1beta1/fake/fake_helmchart.go
new file mode 100644
index 000000000..e8c822830
--- /dev/null
+++ b/kotskinds/client/kotsclientset/typed/kots/v1beta1/fake/fake_helmchart.go
@@ -0,0 +1,139 @@
+/*
+Copyright 2019 Replicated, Inc..
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+// Code generated by client-gen. DO NOT EDIT.
+
+package fake
+
+import (
+	v1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	labels "k8s.io/apimachinery/pkg/labels"
+	schema "k8s.io/apimachinery/pkg/runtime/schema"
+	types "k8s.io/apimachinery/pkg/types"
+	watch "k8s.io/apimachinery/pkg/watch"
+	testing "k8s.io/client-go/testing"
+)
+
+// FakeHelmCharts implements HelmChartInterface
+type FakeHelmCharts struct {
+	Fake *FakeKotsV1beta1
+	ns   string
+}
+
+var helmchartsResource = schema.GroupVersionResource{Group: "kots.io", Version: "v1beta1", Resource: "helmcharts"}
+
+var helmchartsKind = schema.GroupVersionKind{Group: "kots.io", Version: "v1beta1", Kind: "HelmChart"}
+
+// Get takes name of the helmChart, and returns the corresponding helmChart object, and an error if there is any.
+func (c *FakeHelmCharts) Get(name string, options v1.GetOptions) (result *v1beta1.HelmChart, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewGetAction(helmchartsResource, c.ns, name), &v1beta1.HelmChart{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1beta1.HelmChart), err
+}
+
+// List takes label and field selectors, and returns the list of HelmCharts that match those selectors.
+func (c *FakeHelmCharts) List(opts v1.ListOptions) (result *v1beta1.HelmChartList, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewListAction(helmchartsResource, helmchartsKind, c.ns, opts), &v1beta1.HelmChartList{})
+
+	if obj == nil {
+		return nil, err
+	}
+
+	label, _, _ := testing.ExtractFromListOptions(opts)
+	if label == nil {
+		label = labels.Everything()
+	}
+	list := &v1beta1.HelmChartList{ListMeta: obj.(*v1beta1.HelmChartList).ListMeta}
+	for _, item := range obj.(*v1beta1.HelmChartList).Items {
+		if label.Matches(labels.Set(item.Labels)) {
+			list.Items = append(list.Items, item)
+		}
+	}
+	return list, err
+}
+
+// Watch returns a watch.Interface that watches the requested helmCharts.
+func (c *FakeHelmCharts) Watch(opts v1.ListOptions) (watch.Interface, error) {
+	return c.Fake.
+		InvokesWatch(testing.NewWatchAction(helmchartsResource, c.ns, opts))
+
+}
+
+// Create takes the representation of a helmChart and creates it.  Returns the server's representation of the helmChart, and an error, if there is any.
+func (c *FakeHelmCharts) Create(helmChart *v1beta1.HelmChart) (result *v1beta1.HelmChart, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewCreateAction(helmchartsResource, c.ns, helmChart), &v1beta1.HelmChart{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1beta1.HelmChart), err
+}
+
+// Update takes the representation of a helmChart and updates it. Returns the server's representation of the helmChart, and an error, if there is any.
+func (c *FakeHelmCharts) Update(helmChart *v1beta1.HelmChart) (result *v1beta1.HelmChart, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewUpdateAction(helmchartsResource, c.ns, helmChart), &v1beta1.HelmChart{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1beta1.HelmChart), err
+}
+
+// UpdateStatus was generated because the type contains a Status member.
+// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus().
+func (c *FakeHelmCharts) UpdateStatus(helmChart *v1beta1.HelmChart) (*v1beta1.HelmChart, error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewUpdateSubresourceAction(helmchartsResource, "status", c.ns, helmChart), &v1beta1.HelmChart{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1beta1.HelmChart), err
+}
+
+// Delete takes name of the helmChart and deletes it. Returns an error if one occurs.
+func (c *FakeHelmCharts) Delete(name string, options *v1.DeleteOptions) error {
+	_, err := c.Fake.
+		Invokes(testing.NewDeleteAction(helmchartsResource, c.ns, name), &v1beta1.HelmChart{})
+
+	return err
+}
+
+// DeleteCollection deletes a collection of objects.
+func (c *FakeHelmCharts) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error {
+	action := testing.NewDeleteCollectionAction(helmchartsResource, c.ns, listOptions)
+
+	_, err := c.Fake.Invokes(action, &v1beta1.HelmChartList{})
+	return err
+}
+
+// Patch applies the patch and returns the patched helmChart.
+func (c *FakeHelmCharts) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1beta1.HelmChart, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewPatchSubresourceAction(helmchartsResource, c.ns, name, pt, data, subresources...), &v1beta1.HelmChart{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1beta1.HelmChart), err
+}
diff --git a/kotskinds/client/kotsclientset/typed/kots/v1beta1/fake/fake_kots_client.go b/kotskinds/client/kotsclientset/typed/kots/v1beta1/fake/fake_kots_client.go
index 4f94f1f00..67ed3e25e 100644
--- a/kotskinds/client/kotsclientset/typed/kots/v1beta1/fake/fake_kots_client.go
+++ b/kotskinds/client/kotsclientset/typed/kots/v1beta1/fake/fake_kots_client.go
@@ -43,6 +43,10 @@ func (c *FakeKotsV1beta1) ConfigValueses(namespace string) v1beta1.ConfigValuesI
 	return &FakeConfigValueses{c, namespace}
 }
 
+func (c *FakeKotsV1beta1) HelmCharts(namespace string) v1beta1.HelmChartInterface {
+	return &FakeHelmCharts{c, namespace}
+}
+
 func (c *FakeKotsV1beta1) Installations(namespace string) v1beta1.InstallationInterface {
 	return &FakeInstallations{c, namespace}
 }
diff --git a/kotskinds/client/kotsclientset/typed/kots/v1beta1/generated_expansion.go b/kotskinds/client/kotsclientset/typed/kots/v1beta1/generated_expansion.go
index 7ddb63678..048ff0e36 100644
--- a/kotskinds/client/kotsclientset/typed/kots/v1beta1/generated_expansion.go
+++ b/kotskinds/client/kotsclientset/typed/kots/v1beta1/generated_expansion.go
@@ -25,6 +25,8 @@ type ConfigExpansion interface{}
 
 type ConfigValuesExpansion interface{}
 
+type HelmChartExpansion interface{}
+
 type InstallationExpansion interface{}
 
 type LicenseExpansion interface{}
diff --git a/kotskinds/client/kotsclientset/typed/kots/v1beta1/helmchart.go b/kotskinds/client/kotsclientset/typed/kots/v1beta1/helmchart.go
new file mode 100644
index 000000000..969061090
--- /dev/null
+++ b/kotskinds/client/kotsclientset/typed/kots/v1beta1/helmchart.go
@@ -0,0 +1,190 @@
+/*
+Copyright 2019 Replicated, Inc..
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+// Code generated by client-gen. DO NOT EDIT.
+
+package v1beta1
+
+import (
+	"time"
+
+	v1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
+	scheme "github.com/replicatedhq/kots/kotskinds/client/kotsclientset/scheme"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	types "k8s.io/apimachinery/pkg/types"
+	watch "k8s.io/apimachinery/pkg/watch"
+	rest "k8s.io/client-go/rest"
+)
+
+// HelmChartsGetter has a method to return a HelmChartInterface.
+// A group's client should implement this interface.
+type HelmChartsGetter interface {
+	HelmCharts(namespace string) HelmChartInterface
+}
+
+// HelmChartInterface has methods to work with HelmChart resources.
+type HelmChartInterface interface {
+	Create(*v1beta1.HelmChart) (*v1beta1.HelmChart, error)
+	Update(*v1beta1.HelmChart) (*v1beta1.HelmChart, error)
+	UpdateStatus(*v1beta1.HelmChart) (*v1beta1.HelmChart, error)
+	Delete(name string, options *v1.DeleteOptions) error
+	DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error
+	Get(name string, options v1.GetOptions) (*v1beta1.HelmChart, error)
+	List(opts v1.ListOptions) (*v1beta1.HelmChartList, error)
+	Watch(opts v1.ListOptions) (watch.Interface, error)
+	Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1beta1.HelmChart, err error)
+	HelmChartExpansion
+}
+
+// helmCharts implements HelmChartInterface
+type helmCharts struct {
+	client rest.Interface
+	ns     string
+}
+
+// newHelmCharts returns a HelmCharts
+func newHelmCharts(c *KotsV1beta1Client, namespace string) *helmCharts {
+	return &helmCharts{
+		client: c.RESTClient(),
+		ns:     namespace,
+	}
+}
+
+// Get takes name of the helmChart, and returns the corresponding helmChart object, and an error if there is any.
+func (c *helmCharts) Get(name string, options v1.GetOptions) (result *v1beta1.HelmChart, err error) {
+	result = &v1beta1.HelmChart{}
+	err = c.client.Get().
+		Namespace(c.ns).
+		Resource("helmcharts").
+		Name(name).
+		VersionedParams(&options, scheme.ParameterCodec).
+		Do().
+		Into(result)
+	return
+}
+
+// List takes label and field selectors, and returns the list of HelmCharts that match those selectors.
+func (c *helmCharts) List(opts v1.ListOptions) (result *v1beta1.HelmChartList, err error) {
+	var timeout time.Duration
+	if opts.TimeoutSeconds != nil {
+		timeout = time.Duration(*opts.TimeoutSeconds) * time.Second
+	}
+	result = &v1beta1.HelmChartList{}
+	err = c.client.Get().
+		Namespace(c.ns).
+		Resource("helmcharts").
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Timeout(timeout).
+		Do().
+		Into(result)
+	return
+}
+
+// Watch returns a watch.Interface that watches the requested helmCharts.
+func (c *helmCharts) Watch(opts v1.ListOptions) (watch.Interface, error) {
+	var timeout time.Duration
+	if opts.TimeoutSeconds != nil {
+		timeout = time.Duration(*opts.TimeoutSeconds) * time.Second
+	}
+	opts.Watch = true
+	return c.client.Get().
+		Namespace(c.ns).
+		Resource("helmcharts").
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Timeout(timeout).
+		Watch()
+}
+
+// Create takes the representation of a helmChart and creates it.  Returns the server's representation of the helmChart, and an error, if there is any.
+func (c *helmCharts) Create(helmChart *v1beta1.HelmChart) (result *v1beta1.HelmChart, err error) {
+	result = &v1beta1.HelmChart{}
+	err = c.client.Post().
+		Namespace(c.ns).
+		Resource("helmcharts").
+		Body(helmChart).
+		Do().
+		Into(result)
+	return
+}
+
+// Update takes the representation of a helmChart and updates it. Returns the server's representation of the helmChart, and an error, if there is any.
+func (c *helmCharts) Update(helmChart *v1beta1.HelmChart) (result *v1beta1.HelmChart, err error) {
+	result = &v1beta1.HelmChart{}
+	err = c.client.Put().
+		Namespace(c.ns).
+		Resource("helmcharts").
+		Name(helmChart.Name).
+		Body(helmChart).
+		Do().
+		Into(result)
+	return
+}
+
+// UpdateStatus was generated because the type contains a Status member.
+// Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus().
+
+func (c *helmCharts) UpdateStatus(helmChart *v1beta1.HelmChart) (result *v1beta1.HelmChart, err error) {
+	result = &v1beta1.HelmChart{}
+	err = c.client.Put().
+		Namespace(c.ns).
+		Resource("helmcharts").
+		Name(helmChart.Name).
+		SubResource("status").
+		Body(helmChart).
+		Do().
+		Into(result)
+	return
+}
+
+// Delete takes name of the helmChart and deletes it. Returns an error if one occurs.
+func (c *helmCharts) Delete(name string, options *v1.DeleteOptions) error {
+	return c.client.Delete().
+		Namespace(c.ns).
+		Resource("helmcharts").
+		Name(name).
+		Body(options).
+		Do().
+		Error()
+}
+
+// DeleteCollection deletes a collection of objects.
+func (c *helmCharts) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error {
+	var timeout time.Duration
+	if listOptions.TimeoutSeconds != nil {
+		timeout = time.Duration(*listOptions.TimeoutSeconds) * time.Second
+	}
+	return c.client.Delete().
+		Namespace(c.ns).
+		Resource("helmcharts").
+		VersionedParams(&listOptions, scheme.ParameterCodec).
+		Timeout(timeout).
+		Body(options).
+		Do().
+		Error()
+}
+
+// Patch applies the patch and returns the patched helmChart.
+func (c *helmCharts) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1beta1.HelmChart, err error) {
+	result = &v1beta1.HelmChart{}
+	err = c.client.Patch(pt).
+		Namespace(c.ns).
+		Resource("helmcharts").
+		SubResource(subresources...).
+		Name(name).
+		Body(data).
+		Do().
+		Into(result)
+	return
+}
diff --git a/kotskinds/client/kotsclientset/typed/kots/v1beta1/kots_client.go b/kotskinds/client/kotsclientset/typed/kots/v1beta1/kots_client.go
index f412c5bd7..badc43245 100644
--- a/kotskinds/client/kotsclientset/typed/kots/v1beta1/kots_client.go
+++ b/kotskinds/client/kotsclientset/typed/kots/v1beta1/kots_client.go
@@ -30,6 +30,7 @@ type KotsV1beta1Interface interface {
 	ApplicationsGetter
 	ConfigsGetter
 	ConfigValuesesGetter
+	HelmChartsGetter
 	InstallationsGetter
 	LicensesGetter
 }
@@ -55,6 +56,10 @@ func (c *KotsV1beta1Client) ConfigValueses(namespace string) ConfigValuesInterfa
 	return newConfigValueses(c, namespace)
 }
 
+func (c *KotsV1beta1Client) HelmCharts(namespace string) HelmChartInterface {
+	return newHelmCharts(c, namespace)
+}
+
 func (c *KotsV1beta1Client) Installations(namespace string) InstallationInterface {
 	return newInstallations(c, namespace)
 }
diff --git a/pkg/base/helm.go b/pkg/base/helm.go
index 1286bc958..8007c1cac 100644
--- a/pkg/base/helm.go
+++ b/pkg/base/helm.go
@@ -8,7 +8,7 @@ import (
 
 	"github.com/ghodss/yaml"
 	"github.com/pkg/errors"
-	"github.com/replicatedhq/kots/pkg/upstream"
+	upstreamtypes "github.com/replicatedhq/kots/pkg/upstream/types"
 	"github.com/replicatedhq/kots/pkg/util"
 	"k8s.io/helm/pkg/chartutil"
 	"k8s.io/helm/pkg/proto/hapi/chart"
@@ -17,7 +17,7 @@ import (
 	"k8s.io/helm/pkg/timeconv"
 )
 
-func renderHelm(u *upstream.Upstream, renderOptions *RenderOptions) (*Base, error) {
+func RenderHelm(u *upstreamtypes.Upstream, renderOptions *RenderOptions) (*Base, error) {
 	chartPath, err := ioutil.TempDir("", "kots")
 	if err != nil {
 		return nil, errors.Wrap(err, "failed to create chart dir")
@@ -48,6 +48,7 @@ func renderHelm(u *upstream.Upstream, renderOptions *RenderOptions) (*Base, erro
 			return nil, errors.Wrap(err, "failed to parse helm value")
 		}
 	}
+
 	marshalledVals, err := yaml.Marshal(vals)
 	if err != nil {
 		return nil, errors.Wrap(err, "failed to marshal helm values")
diff --git a/pkg/base/render.go b/pkg/base/render.go
index 38b47d09a..2f2046645 100644
--- a/pkg/base/render.go
+++ b/pkg/base/render.go
@@ -3,7 +3,7 @@ package base
 import (
 	"github.com/pkg/errors"
 	"github.com/replicatedhq/kots/pkg/logger"
-	"github.com/replicatedhq/kots/pkg/upstream"
+	upstreamtypes "github.com/replicatedhq/kots/pkg/upstream/types"
 )
 
 type RenderOptions struct {
@@ -15,9 +15,9 @@ type RenderOptions struct {
 
 // RenderUpstream is responsible for any conversions or transpilation steps are required
 // to take an upstream and make it a valid kubernetes base
-func RenderUpstream(u *upstream.Upstream, renderOptions *RenderOptions) (*Base, error) {
+func RenderUpstream(u *upstreamtypes.Upstream, renderOptions *RenderOptions) (*Base, error) {
 	if u.Type == "helm" {
-		return renderHelm(u, renderOptions)
+		return RenderHelm(u, renderOptions)
 	}
 
 	if u.Type == "replicated" {
diff --git a/pkg/base/replicated.go b/pkg/base/replicated.go
index 7c83e6e89..c4dd7f455 100644
--- a/pkg/base/replicated.go
+++ b/pkg/base/replicated.go
@@ -6,11 +6,11 @@ import (
 	"github.com/replicatedhq/kots/pkg/crypto"
 	"github.com/replicatedhq/kots/pkg/logger"
 	"github.com/replicatedhq/kots/pkg/template"
-	"github.com/replicatedhq/kots/pkg/upstream"
+	upstreamtypes "github.com/replicatedhq/kots/pkg/upstream/types"
 	"k8s.io/client-go/kubernetes/scheme"
 )
 
-func renderReplicated(u *upstream.Upstream, renderOptions *RenderOptions) (*Base, error) {
+func renderReplicated(u *upstreamtypes.Upstream, renderOptions *RenderOptions) (*Base, error) {
 	config, configValues, license, installation := findConfig(u, renderOptions.Log)
 
 	var templateContext map[string]template.ItemValue
@@ -59,6 +59,7 @@ func renderReplicated(u *upstream.Upstream, renderOptions *RenderOptions) (*Base
 	for _, upstreamFile := range u.Files {
 		rendered, err := builder.RenderTemplate(upstreamFile.Path, string(upstreamFile.Content))
 		if err != nil {
+			renderOptions.Log.Error(errors.Errorf("Failed to render file %s. Contents are %s", upstreamFile.Path, upstreamFile.Content))
 			return nil, errors.Wrap(err, "failed to render file template")
 		}
 
@@ -131,7 +132,7 @@ func tryGetConfigFromFileContent(content []byte, log *logger.Logger) *kotsv1beta
 	return nil
 }
 
-func findConfig(u *upstream.Upstream, log *logger.Logger) (*kotsv1beta1.Config, *kotsv1beta1.ConfigValues, *kotsv1beta1.License, *kotsv1beta1.Installation) {
+func findConfig(u *upstreamtypes.Upstream, log *logger.Logger) (*kotsv1beta1.Config, *kotsv1beta1.ConfigValues, *kotsv1beta1.License, *kotsv1beta1.Installation) {
 	var config *kotsv1beta1.Config
 	var values *kotsv1beta1.ConfigValues
 	var license *kotsv1beta1.License
diff --git a/pkg/pull/archive.go b/pkg/pull/archive.go
index 3a879c41e..1999e7abc 100644
--- a/pkg/pull/archive.go
+++ b/pkg/pull/archive.go
@@ -12,7 +12,7 @@ import (
 	"github.com/mholt/archiver"
 	"github.com/pkg/errors"
 	"github.com/replicatedhq/kots/pkg/base"
-	"github.com/replicatedhq/kots/pkg/upstream"
+	upstreamtypes "github.com/replicatedhq/kots/pkg/upstream/types"
 	"github.com/replicatedhq/kots/pkg/util"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -20,7 +20,7 @@ import (
 	"k8s.io/client-go/kubernetes/scheme"
 )
 
-func writeArchiveAsConfigMap(pullOptions PullOptions, u *upstream.Upstream, baseDir string) error {
+func writeArchiveAsConfigMap(pullOptions PullOptions, u *upstreamtypes.Upstream, baseDir string) error {
 	// Package this app into a bundle so that the Admin Console can write it as the first version...
 	tarGz := archiver.TarGz{
 		Tar: &archiver.Tar{
diff --git a/pkg/pull/pull.go b/pkg/pull/pull.go
index 4e3a48e4d..2179947fb 100644
--- a/pkg/pull/pull.go
+++ b/pkg/pull/pull.go
@@ -16,6 +16,7 @@ import (
 	"github.com/replicatedhq/kots/pkg/logger"
 	"github.com/replicatedhq/kots/pkg/midstream"
 	"github.com/replicatedhq/kots/pkg/upstream"
+	upstreamtypes "github.com/replicatedhq/kots/pkg/upstream/types"
 	corev1 "k8s.io/api/core/v1"
 	"k8s.io/client-go/kubernetes/scheme"
 	"sigs.k8s.io/kustomize/v3/pkg/image"
@@ -165,13 +166,13 @@ func Pull(upstreamURI string, pullOptions PullOptions) (string, error) {
 
 	includeAdminConsole := uri.Scheme == "replicated" && !pullOptions.ExcludeAdminConsole
 
-	writeUpstreamOptions := upstream.WriteOptions{
+	writeUpstreamOptions := upstreamtypes.WriteOptions{
 		RootDir:             pullOptions.RootDir,
 		CreateAppDir:        pullOptions.CreateAppDir,
 		IncludeAdminConsole: includeAdminConsole,
 		SharedPassword:      pullOptions.SharedPassword,
 	}
-	if err := u.WriteUpstream(writeUpstreamOptions); err != nil {
+	if err := upstream.WriteUpstream(u, writeUpstreamOptions); err != nil {
 		log.FinishSpinnerWithError()
 		return "", errors.Wrap(err, "failed to write upstream")
 	}
@@ -210,7 +211,7 @@ func Pull(upstreamURI string, pullOptions PullOptions) (string, error) {
 				}
 			}
 
-			newImages, err := u.CopyUpstreamImages(writeUpstreamImageOptions)
+			newImages, err := upstream.CopyUpstreamImages(u, writeUpstreamImageOptions)
 			if err != nil {
 				return "", errors.Wrap(err, "failed to write upstream images")
 			}
@@ -245,7 +246,7 @@ func Pull(upstreamURI string, pullOptions PullOptions) (string, error) {
 			// only run the TagAndPushImagesFromFiles code if the "copy directly" code hasn't already run
 			var rewrittenImages []image.Image
 			if images == nil {
-				rewrittenImages, err = u.TagAndPushUpstreamImages(pushUpstreamImageOptions)
+				rewrittenImages, err = upstream.TagAndPushUpstreamImages(u, pushUpstreamImageOptions)
 				if err != nil {
 					return "", errors.Wrap(err, "failed to push upstream images")
 				}
@@ -256,7 +257,7 @@ func Pull(upstreamURI string, pullOptions PullOptions) (string, error) {
 				CreateAppDir: pullOptions.CreateAppDir,
 				Log:          log,
 			}
-			affectedObjects, err := u.FindObjectsWithImages(findObjectsOptions)
+			affectedObjects, err := upstream.FindObjectsWithImages(u, findObjectsOptions)
 			if err != nil {
 				return "", errors.Wrap(err, "failed to find objects with images")
 			}
@@ -298,7 +299,7 @@ func Pull(upstreamURI string, pullOptions PullOptions) (string, error) {
 			},
 			Log: log,
 		}
-		rewrittenImages, affectedObjects, err := u.FindPrivateImages(findPrivateImagesOptions)
+		rewrittenImages, affectedObjects, err := upstream.FindPrivateImages(u, findPrivateImagesOptions)
 		if err != nil {
 			return "", errors.Wrap(err, "failed to push upstream images")
 		}
@@ -497,7 +498,7 @@ func findAirgapMetaInDir(root string) (*kotsv1beta1.Airgap, error) {
 	return nil, nil
 }
 
-func imagesDirFromOptions(upstream *upstream.Upstream, pullOptions PullOptions) string {
+func imagesDirFromOptions(upstream *upstreamtypes.Upstream, pullOptions PullOptions) string {
 	if pullOptions.RewriteImageOptions.ImageFiles != "" {
 		return pullOptions.RewriteImageOptions.ImageFiles
 	}
diff --git a/pkg/rewrite/rewrite.go b/pkg/rewrite/rewrite.go
index ed3f8617d..f6a1daf79 100644
--- a/pkg/rewrite/rewrite.go
+++ b/pkg/rewrite/rewrite.go
@@ -13,6 +13,7 @@ import (
 	"github.com/replicatedhq/kots/pkg/logger"
 	"github.com/replicatedhq/kots/pkg/midstream"
 	"github.com/replicatedhq/kots/pkg/upstream"
+	upstreamtypes "github.com/replicatedhq/kots/pkg/upstream/types"
 	corev1 "k8s.io/api/core/v1"
 	"sigs.k8s.io/kustomize/v3/pkg/image"
 )
@@ -64,12 +65,12 @@ func Rewrite(rewriteOptions RewriteOptions) error {
 
 	includeAdminConsole := false
 
-	writeUpstreamOptions := upstream.WriteOptions{
+	writeUpstreamOptions := upstreamtypes.WriteOptions{
 		RootDir:             rewriteOptions.RootDir,
 		CreateAppDir:        rewriteOptions.CreateAppDir,
 		IncludeAdminConsole: includeAdminConsole,
 	}
-	if err := u.WriteUpstream(writeUpstreamOptions); err != nil {
+	if err := upstream.WriteUpstream(u, writeUpstreamOptions); err != nil {
 		log.FinishSpinnerWithError()
 		return errors.Wrap(err, "failed to write upstream")
 	}
@@ -104,7 +105,7 @@ func Rewrite(rewriteOptions RewriteOptions) error {
 			writeUpstreamImageOptions.SourceRegistry.Password = fetchOptions.License.Spec.LicenseID
 		}
 
-		newImages, err := u.CopyUpstreamImages(writeUpstreamImageOptions)
+		newImages, err := upstream.CopyUpstreamImages(u, writeUpstreamImageOptions)
 		if err != nil {
 			return errors.Wrap(err, "failed to write upstream images")
 		}
@@ -116,7 +117,7 @@ func Rewrite(rewriteOptions RewriteOptions) error {
 		CreateAppDir: rewriteOptions.CreateAppDir,
 		Log:          log,
 	}
-	affectedObjects, err := u.FindObjectsWithImages(findObjectsOptions)
+	affectedObjects, err := upstream.FindObjectsWithImages(u, findObjectsOptions)
 	if err != nil {
 		return errors.Wrap(err, "failed to find objects with images")
 	}
@@ -213,7 +214,7 @@ func Rewrite(rewriteOptions RewriteOptions) error {
 	return nil
 }
 
-func imagesDirFromOptions(upstream *upstream.Upstream, rewriteOptions RewriteOptions) string {
+func imagesDirFromOptions(upstream *upstreamtypes.Upstream, rewriteOptions RewriteOptions) string {
 	if rewriteOptions.CreateAppDir {
 		return filepath.Join(rewriteOptions.RootDir, upstream.Name, "images")
 	}
diff --git a/pkg/upstream/admin-console.go b/pkg/upstream/admin-console.go
index 4db781814..d0c19a078 100644
--- a/pkg/upstream/admin-console.go
+++ b/pkg/upstream/admin-console.go
@@ -9,6 +9,7 @@ import (
 	"github.com/manifoldco/promptui"
 	"github.com/pkg/errors"
 	"github.com/replicatedhq/kots/pkg/kotsadm"
+	"github.com/replicatedhq/kots/pkg/upstream/types"
 	appsv1 "k8s.io/api/apps/v1"
 	corev1 "k8s.io/api/core/v1"
 	"k8s.io/client-go/kubernetes/scheme"
@@ -26,7 +27,7 @@ type UpstreamSettings struct {
 	AutoCreateClusterToken string
 }
 
-func generateAdminConsoleFiles(renderDir string, sharedPassword string) ([]UpstreamFile, error) {
+func generateAdminConsoleFiles(renderDir string, sharedPassword string) ([]types.UpstreamFile, error) {
 	if _, err := os.Stat(path.Join(renderDir, "admin-console")); os.IsNotExist(err) {
 		settings := &UpstreamSettings{
 			SharedPassword:         sharedPassword,
@@ -100,8 +101,8 @@ func loadUpstreamSettingsFromDeployment(settings *UpstreamSettings, deployment *
 	}
 }
 
-func generateNewAdminConsoleFiles(settings *UpstreamSettings) ([]UpstreamFile, error) {
-	upstreamFiles := []UpstreamFile{}
+func generateNewAdminConsoleFiles(settings *UpstreamSettings) ([]types.UpstreamFile, error) {
+	upstreamFiles := []types.UpstreamFile{}
 
 	deployOptions := kotsadm.DeployOptions{
 		Namespace:              "default",
@@ -130,7 +131,7 @@ func generateNewAdminConsoleFiles(settings *UpstreamSettings) ([]UpstreamFile, e
 		return nil, errors.Wrap(err, "failed to get minio yaml")
 	}
 	for n, v := range adminConsoleDocs {
-		upstreamFile := UpstreamFile{
+		upstreamFile := types.UpstreamFile{
 			Path:    path.Join("admin-console", n),
 			Content: v,
 		}
diff --git a/pkg/upstream/fetch.go b/pkg/upstream/fetch.go
index 260b0557d..26559e72c 100644
--- a/pkg/upstream/fetch.go
+++ b/pkg/upstream/fetch.go
@@ -6,6 +6,7 @@ import (
 	"github.com/pkg/errors"
 	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
 	"github.com/replicatedhq/kots/pkg/crypto"
+	"github.com/replicatedhq/kots/pkg/upstream/types"
 	"github.com/replicatedhq/kots/pkg/util"
 )
 
@@ -24,7 +25,7 @@ type FetchOptions struct {
 	CurrentVersionLabel string
 }
 
-func FetchUpstream(upstreamURI string, fetchOptions *FetchOptions) (*Upstream, error) {
+func FetchUpstream(upstreamURI string, fetchOptions *FetchOptions) (*types.Upstream, error) {
 	upstream, err := downloadUpstream(upstreamURI, fetchOptions)
 	if err != nil {
 		return nil, errors.Wrap(err, "download upstream failed")
@@ -33,7 +34,7 @@ func FetchUpstream(upstreamURI string, fetchOptions *FetchOptions) (*Upstream, e
 	return upstream, nil
 }
 
-func downloadUpstream(upstreamURI string, fetchOptions *FetchOptions) (*Upstream, error) {
+func downloadUpstream(upstreamURI string, fetchOptions *FetchOptions) (*types.Upstream, error) {
 	if !util.IsURL(upstreamURI) {
 		return readFilesFromPath(upstreamURI)
 	}
diff --git a/pkg/upstream/files.go b/pkg/upstream/files.go
index 42c5f7d65..df3004bac 100644
--- a/pkg/upstream/files.go
+++ b/pkg/upstream/files.go
@@ -2,12 +2,13 @@ package upstream
 
 import (
 	"github.com/pkg/errors"
+	"github.com/replicatedhq/kots/pkg/upstream/types"
 )
 
-func readFilesFromPath(filepath string) (*Upstream, error) {
+func readFilesFromPath(filepath string) (*types.Upstream, error) {
 	return nil, errors.New("readFilesFromPath not implemented")
 }
 
-func readFilesFromURI(upstreamURI string) (*Upstream, error) {
+func readFilesFromURI(upstreamURI string) (*types.Upstream, error) {
 	return nil, errors.New("readFilesFromURI not implemented")
 }
diff --git a/pkg/upstream/git.go b/pkg/upstream/git.go
index bb2ca3a2c..81488696d 100644
--- a/pkg/upstream/git.go
+++ b/pkg/upstream/git.go
@@ -2,8 +2,9 @@ package upstream
 
 import (
 	"github.com/pkg/errors"
+	"github.com/replicatedhq/kots/pkg/upstream/types"
 )
 
-func downloadGit(gitURI string) (*Upstream, error) {
+func downloadGit(gitURI string) (*types.Upstream, error) {
 	return nil, errors.New("downloadGit not implemented")
 }
diff --git a/pkg/upstream/helm.go b/pkg/upstream/helm.go
index 3c9757aa0..36cc65155 100644
--- a/pkg/upstream/helm.go
+++ b/pkg/upstream/helm.go
@@ -15,6 +15,7 @@ import (
 
 	"github.com/Masterminds/semver"
 	"github.com/pkg/errors"
+	"github.com/replicatedhq/kots/pkg/upstream/types"
 	"github.com/replicatedhq/kots/pkg/util"
 	"k8s.io/helm/cmd/helm/search"
 	"k8s.io/helm/pkg/downloader"
@@ -52,7 +53,7 @@ func getUpdatesHelm(u *url.URL, repoURI string) ([]Update, error) {
 	return updates, nil
 }
 
-func downloadHelm(u *url.URL, repoURI string) (*Upstream, error) {
+func downloadHelm(u *url.URL, repoURI string) (*types.Upstream, error) {
 	repoName, chartName, chartVersion, err := parseHelmURL(u)
 	if err != nil {
 		return nil, errors.Wrap(err, "failed to parse helm uri")
@@ -124,19 +125,15 @@ func downloadHelm(u *url.URL, repoURI string) (*Upstream, error) {
 			return nil, errors.Wrap(err, "failed to download chart")
 		}
 
-		files, err := readTarGz(path.Join(archiveDir, fmt.Sprintf("%s-%s.tgz", chartName, chartVersion)))
+		upstream, err := chartArchiveToSparseUpstream(path.Join(archiveDir, fmt.Sprintf("%s-%s.tgz", chartName, chartVersion)))
 		if err != nil {
-			return nil, errors.Wrap(err, "failed to read chart archive")
+			return nil, errors.Wrap(err, "failed to parse chart archive as upstream")
 		}
 
-		upstream := &Upstream{
-			URI:          u.RequestURI(),
-			Name:         chartName,
-			Type:         "helm",
-			Files:        files,
-			UpdateCursor: chartVersion,
-			VersionLabel: chartVersion,
-		}
+		upstream.URI = u.RequestURI()
+		upstream.Name = chartName
+		upstream.UpdateCursor = chartVersion
+		upstream.VersionLabel = chartVersion
 
 		return upstream, nil
 	}
@@ -144,6 +141,20 @@ func downloadHelm(u *url.URL, repoURI string) (*Upstream, error) {
 	return nil, errors.New("chart version not found")
 }
 
+func chartArchiveToSparseUpstream(chartArchivePath string) (*types.Upstream, error) {
+	files, err := readTarGz(chartArchivePath)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to read chart archive")
+	}
+
+	upstream := &types.Upstream{
+		Type:  "helm",
+		Files: files,
+	}
+
+	return upstream, nil
+}
+
 func helmLoadRepositoriesIndex(helmHome, repoName, repoURI string) (*search.Index, error) {
 	if repoURI == "" {
 		repoURI = getKnownHelmRepoURI(repoName)
@@ -233,7 +244,7 @@ func getKnownHelmRepoURI(repoName string) string {
 	return val
 }
 
-func readTarGz(source string) ([]UpstreamFile, error) {
+func readTarGz(source string) ([]types.UpstreamFile, error) {
 	f, err := os.Open(source)
 	if err != nil {
 		return nil, errors.Wrap(err, "failed to open archive")
@@ -247,7 +258,7 @@ func readTarGz(source string) ([]UpstreamFile, error) {
 
 	tarReader := tar.NewReader(gzf)
 
-	upstreamFiles := []UpstreamFile{}
+	upstreamFiles := []types.UpstreamFile{}
 	for {
 		header, err := tarReader.Next()
 
@@ -267,7 +278,7 @@ func readTarGz(source string) ([]UpstreamFile, error) {
 			if err != nil {
 				return nil, errors.Wrap(err, "failed to read file from tar archive")
 			}
-			upstreamFile := UpstreamFile{
+			upstreamFile := types.UpstreamFile{
 				Path:    name,
 				Content: buf.Bytes(),
 			}
@@ -291,7 +302,7 @@ func readTarGz(source string) ([]UpstreamFile, error) {
 
 		}
 
-		cleanedUpstreamFiles := []UpstreamFile{}
+		cleanedUpstreamFiles := []types.UpstreamFile{}
 		for _, file := range upstreamFiles {
 			d, f := path.Split(file.Path)
 			d2 := strings.Split(d, string(os.PathSeparator))
diff --git a/pkg/upstream/http.go b/pkg/upstream/http.go
index 1f4c8511f..5b3ad9895 100644
--- a/pkg/upstream/http.go
+++ b/pkg/upstream/http.go
@@ -2,8 +2,9 @@ package upstream
 
 import (
 	"github.com/pkg/errors"
+	"github.com/replicatedhq/kots/pkg/upstream/types"
 )
 
-func downloadHttp(httpURI string) (*Upstream, error) {
+func downloadHttp(httpURI string) (*types.Upstream, error) {
 	return nil, errors.New("downloadHttp not implemented")
 }
diff --git a/pkg/upstream/push_images.go b/pkg/upstream/push_images.go
index 666adaef4..2abb623d4 100644
--- a/pkg/upstream/push_images.go
+++ b/pkg/upstream/push_images.go
@@ -12,6 +12,7 @@ import (
 	"github.com/replicatedhq/kots/pkg/docker/registry"
 	"github.com/replicatedhq/kots/pkg/image"
 	"github.com/replicatedhq/kots/pkg/logger"
+	"github.com/replicatedhq/kots/pkg/upstream/types"
 	kustomizeimage "sigs.k8s.io/kustomize/v3/pkg/image"
 )
 
@@ -25,7 +26,7 @@ type PushUpstreamImageOptions struct {
 	DestinationRegistry registry.RegistryOptions
 }
 
-func (u *Upstream) TagAndPushUpstreamImages(options PushUpstreamImageOptions) ([]kustomizeimage.Image, error) {
+func TagAndPushUpstreamImages(u *types.Upstream, options PushUpstreamImageOptions) ([]kustomizeimage.Image, error) {
 	formatDirs, err := ioutil.ReadDir(options.ImagesDir)
 	if err != nil {
 		return nil, errors.Wrap(err, "failed to read images dir")
diff --git a/pkg/upstream/replicated.go b/pkg/upstream/replicated.go
index 2c7443f61..5bff299e6 100644
--- a/pkg/upstream/replicated.go
+++ b/pkg/upstream/replicated.go
@@ -21,16 +21,19 @@ import (
 	dockerref "github.com/containers/image/docker/reference"
 	"github.com/pkg/errors"
 	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
+	"github.com/replicatedhq/kots/pkg/base"
 	"github.com/replicatedhq/kots/pkg/crypto"
 	"github.com/replicatedhq/kots/pkg/docker/registry"
 	"github.com/replicatedhq/kots/pkg/image"
 	"github.com/replicatedhq/kots/pkg/k8sdoc"
 	"github.com/replicatedhq/kots/pkg/logger"
 	"github.com/replicatedhq/kots/pkg/template"
+	"github.com/replicatedhq/kots/pkg/upstream/types"
 	"github.com/replicatedhq/kots/pkg/util"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	serializer "k8s.io/apimachinery/pkg/runtime/serializer/json"
 	"k8s.io/client-go/kubernetes/scheme"
+	"k8s.io/helm/pkg/chartutil"
 	kustomizeimage "sigs.k8s.io/kustomize/v3/pkg/image"
 )
 
@@ -109,7 +112,7 @@ func getUpdatesReplicated(u *url.URL, localPath string, currentCursor, versionLa
 	return updates, nil
 }
 
-func downloadReplicated(u *url.URL, localPath string, rootDir string, useAppDir bool, license *kotsv1beta1.License, existingConfigValues *kotsv1beta1.ConfigValues, updateCursor, versionLabel string, cipher *crypto.AESCipher) (*Upstream, error) {
+func downloadReplicated(u *url.URL, localPath string, rootDir string, useAppDir bool, license *kotsv1beta1.License, existingConfigValues *kotsv1beta1.ConfigValues, updateCursor, versionLabel string, cipher *crypto.AESCipher) (*types.Upstream, error) {
 	var release *Release
 
 	if localPath != "" {
@@ -191,7 +194,7 @@ func downloadReplicated(u *url.URL, localPath string, rootDir string, useAppDir
 		return nil, errors.Wrap(err, "failed to get files from release")
 	}
 
-	upstream := &Upstream{
+	upstream := &types.Upstream{
 		URI:          u.RequestURI(),
 		Name:         application.Name,
 		Files:        files,
@@ -554,6 +557,24 @@ func findConfigValuesInFile(filename string) (*kotsv1beta1.ConfigValues, error)
 	return nil, nil
 }
 
+func tryParsingAsHelmChartGVK(content []byte) *kotsv1beta1.HelmChart {
+	decode := scheme.Codecs.UniversalDeserializer().Decode
+	obj, gvk, err := decode(content, nil, nil)
+	if err != nil {
+		return nil
+	}
+
+	if gvk.Group == "kots.io" {
+		if gvk.Version == "v1beta1" {
+			if gvk.Kind == "HelmChart" {
+				return obj.(*kotsv1beta1.HelmChart)
+			}
+		}
+	}
+
+	return nil
+}
+
 func findConfigInRelease(release *Release) *kotsv1beta1.Config {
 	for _, content := range release.Manifests {
 		decode := scheme.Codecs.UniversalDeserializer().Decode
@@ -574,6 +595,49 @@ func findConfigInRelease(release *Release) *kotsv1beta1.Config {
 	return nil
 }
 
+func findHelmChartArchiveInRelease(release *Release, kotsHelmChart *kotsv1beta1.HelmChart) ([]byte, error) {
+	for _, content := range release.Manifests {
+		decoded, err := base64.StdEncoding.DecodeString(string(content))
+		if err != nil {
+			// not encoded, not a chart archive
+			continue
+		}
+
+		// decoded is a chart tar gz, so it has a Chart.yaml
+		// we need this
+		chartArchivePath, err := ioutil.TempFile("", "chart")
+		if err != nil {
+			return nil, errors.Wrap(err, "failed to create temp file for chart archive path")
+		}
+		defer os.Remove(chartArchivePath.Name())
+		if err := ioutil.WriteFile(chartArchivePath.Name(), decoded, 0644); err != nil {
+			return nil, errors.Wrap(err, "failed to write chart to temp file")
+		}
+
+		files, err := readTarGz(chartArchivePath.Name())
+		if err != nil {
+			return nil, errors.Wrap(err, "failed to read chart archive")
+		}
+
+		for _, chartFile := range files {
+			if chartFile.Path == "Chart.yaml" {
+				chartManifest, err := chartutil.UnmarshalChartfile(chartFile.Content)
+				if err != nil {
+					return nil, errors.Wrap(err, "failed to unmarshal chart yaml")
+				}
+
+				if chartManifest.GetName() == kotsHelmChart.Spec.Chart.Name {
+					if chartManifest.GetVersion() == kotsHelmChart.Spec.Chart.ChartVersion {
+						return decoded, nil
+					}
+				}
+			}
+		}
+	}
+
+	return nil, errors.Errorf("unable to find helm chart archive for chart name %s, version %s", kotsHelmChart.Spec.Chart.Name, kotsHelmChart.Spec.Chart.ChartVersion)
+}
+
 func findAppInRelease(release *Release) *kotsv1beta1.Application {
 	for _, content := range release.Manifests {
 		decode := scheme.Codecs.UniversalDeserializer().Decode
@@ -608,11 +672,88 @@ func findAppInRelease(release *Release) *kotsv1beta1.Application {
 	return app
 }
 
-func releaseToFiles(release *Release) ([]UpstreamFile, error) {
-	upstreamFiles := []UpstreamFile{}
+func releaseToFiles(release *Release) ([]types.UpstreamFile, error) {
+	upstreamFiles := []types.UpstreamFile{}
+
+	// prerender the release looking for helm charts
+	// which are only identifyed as base64 encoded tar streams
+	ignoredFilenames := []string{}
+	for filename, content := range release.Manifests {
+		_, err := base64.StdEncoding.DecodeString(string(content))
+		if err != nil {
+			continue
+		}
+
+		// if it base64 decoded, we should ignore thie file
+		// because it's (probably) a helm chart
+		ignoredFilenames = append(ignoredFilenames, filename)
+	}
 
 	for filename, content := range release.Manifests {
-		upstreamFile := UpstreamFile{
+		ignore := false
+		for _, ignoredFilename := range ignoredFilenames {
+			if filename == ignoredFilename {
+				ignore = true
+			}
+		}
+
+		if ignore {
+			continue
+		}
+
+		kotsHelmChart := tryParsingAsHelmChartGVK(content)
+		if kotsHelmChart != nil {
+			archive, err := findHelmChartArchiveInRelease(release, kotsHelmChart)
+			if err != nil {
+				return nil, errors.Wrap(err, "failed to find helm chart archive in release")
+			}
+			tmpFile, err := ioutil.TempFile("", "kots")
+			if err != nil {
+				return nil, errors.Wrap(err, "failed to create temp file")
+			}
+			_, err = io.Copy(tmpFile, bytes.NewReader(archive))
+			if err != nil {
+				return nil, errors.Wrap(err, "failed to copy chart to temp file")
+			}
+
+			helmUpstream, err := chartArchiveToSparseUpstream(tmpFile.Name())
+			if err != nil {
+				return nil, errors.Wrap(err, "failed to fetch helm dependency")
+			}
+			helmUpstream.Name = kotsHelmChart.Spec.Chart.Name
+
+			localValues, err := kotsHelmChart.Spec.RenderValues()
+			if err != nil {
+				return nil, errors.Wrap(err, "failed to render local values for chart")
+			}
+
+			helmBase, err := base.RenderHelm(helmUpstream, &base.RenderOptions{
+				SplitMultiDocYAML: true,
+				Namespace:         "",
+				HelmOptions:       localValues,
+				Log:               nil,
+			})
+			if err != nil {
+				return nil, errors.Wrap(err, "failed to render helm chart in upstream")
+			}
+
+			for _, helmBaseFile := range helmBase.Files {
+				// this is a little bit of an abuse of the next function
+				if !helmBaseFile.ShouldBeIncludedInBaseFilesystem(false) {
+					continue
+				}
+
+				upstreamFile := types.UpstreamFile{
+					Path:    filepath.Join("charts", kotsHelmChart.Name, helmBaseFile.Path),
+					Content: helmBaseFile.Content,
+				}
+
+				upstreamFiles = append(upstreamFiles, upstreamFile)
+			}
+
+		}
+
+		upstreamFile := types.UpstreamFile{
 			Path:    filename,
 			Content: content,
 		}
@@ -621,8 +762,8 @@ func releaseToFiles(release *Release) ([]UpstreamFile, error) {
 	}
 
 	// Stash the user data for this search (we will readd at the end)
-	userdataFiles := []UpstreamFile{}
-	withoutUserdataFiles := []UpstreamFile{}
+	userdataFiles := []types.UpstreamFile{}
+	withoutUserdataFiles := []types.UpstreamFile{}
 	for _, file := range upstreamFiles {
 		d, _ := path.Split(file.Path)
 		dirs := strings.Split(d, string(os.PathSeparator))
@@ -647,7 +788,7 @@ func releaseToFiles(release *Release) ([]UpstreamFile, error) {
 
 		}
 
-		cleanedUpstreamFiles := []UpstreamFile{}
+		cleanedUpstreamFiles := []types.UpstreamFile{}
 		for _, file := range withoutUserdataFiles {
 			d, f := path.Split(file.Path)
 			d2 := strings.Split(d, string(os.PathSeparator))
@@ -740,7 +881,7 @@ type FindPrivateImagesOptions struct {
 	Log                *logger.Logger
 }
 
-func (u *Upstream) FindPrivateImages(options FindPrivateImagesOptions) ([]kustomizeimage.Image, []*k8sdoc.Doc, error) {
+func FindPrivateImages(u *types.Upstream, options FindPrivateImagesOptions) ([]kustomizeimage.Image, []*k8sdoc.Doc, error) {
 	rootDir := options.RootDir
 	if options.CreateAppDir {
 		rootDir = path.Join(rootDir, u.Name)
@@ -782,7 +923,7 @@ type FindObjectsWithImagesOptions struct {
 	Log          *logger.Logger
 }
 
-func (u *Upstream) FindObjectsWithImages(options FindObjectsWithImagesOptions) ([]*k8sdoc.Doc, error) {
+func FindObjectsWithImages(u *types.Upstream, options FindObjectsWithImagesOptions) ([]*k8sdoc.Doc, error) {
 	rootDir := options.RootDir
 	if options.CreateAppDir {
 		rootDir = path.Join(rootDir, u.Name)
diff --git a/pkg/upstream/upstream.go b/pkg/upstream/types/types.go
similarity index 55%
rename from pkg/upstream/upstream.go
rename to pkg/upstream/types/types.go
index 76594817a..0ded5ce8f 100644
--- a/pkg/upstream/upstream.go
+++ b/pkg/upstream/types/types.go
@@ -1,6 +1,8 @@
-package upstream
+package types
 
 import (
+	"path"
+
 	kotsscheme "github.com/replicatedhq/kots/kotskinds/client/kotsclientset/scheme"
 	"k8s.io/client-go/kubernetes/scheme"
 )
@@ -24,3 +26,19 @@ type Upstream struct {
 	ReleaseNotes  string
 	EncryptionKey string
 }
+
+type WriteOptions struct {
+	RootDir             string
+	CreateAppDir        bool
+	IncludeAdminConsole bool
+	SharedPassword      string
+}
+
+func (u *Upstream) GetBaseDir(options WriteOptions) string {
+	renderDir := options.RootDir
+	if options.CreateAppDir {
+		renderDir = path.Join(renderDir, u.Name)
+	}
+
+	return path.Join(renderDir, "base")
+}
diff --git a/pkg/upstream/write.go b/pkg/upstream/write.go
index d7fe8cf95..4c41e08c3 100644
--- a/pkg/upstream/write.go
+++ b/pkg/upstream/write.go
@@ -9,19 +9,13 @@ import (
 	"github.com/pkg/errors"
 	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
 	"github.com/replicatedhq/kots/pkg/crypto"
+	"github.com/replicatedhq/kots/pkg/upstream/types"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime/serializer/json"
 	"k8s.io/client-go/kubernetes/scheme"
 )
 
-type WriteOptions struct {
-	RootDir             string
-	CreateAppDir        bool
-	IncludeAdminConsole bool
-	SharedPassword      string
-}
-
-func (u *Upstream) WriteUpstream(options WriteOptions) error {
+func WriteUpstream(u *types.Upstream, options types.WriteOptions) error {
 	renderDir := options.RootDir
 	if options.CreateAppDir {
 		renderDir = path.Join(renderDir, u.Name)
@@ -95,7 +89,7 @@ func (u *Upstream) WriteUpstream(options WriteOptions) error {
 					return errors.Wrap(err, "failed to replace values with previous values")
 				}
 
-				updatedValues := UpstreamFile{
+				updatedValues := types.UpstreamFile{
 					Path:    f.Path,
 					Content: mergedValues,
 				}
@@ -139,15 +133,6 @@ func (u *Upstream) WriteUpstream(options WriteOptions) error {
 	return nil
 }
 
-func (u *Upstream) GetBaseDir(options WriteOptions) string {
-	renderDir := options.RootDir
-	if options.CreateAppDir {
-		renderDir = path.Join(renderDir, u.Name)
-	}
-
-	return path.Join(renderDir, "base")
-}
-
 func getEncryptionKey(previousInstallationContent []byte) (string, error) {
 	if previousInstallationContent == nil {
 		cipher, err := crypto.NewAESCipher()
diff --git a/pkg/upstream/write_images.go b/pkg/upstream/write_images.go
index ee202ab1a..1c2391dcb 100644
--- a/pkg/upstream/write_images.go
+++ b/pkg/upstream/write_images.go
@@ -8,6 +8,7 @@ import (
 	"github.com/replicatedhq/kots/pkg/docker/registry"
 	"github.com/replicatedhq/kots/pkg/image"
 	"github.com/replicatedhq/kots/pkg/logger"
+	"github.com/replicatedhq/kots/pkg/upstream/types"
 	kustomizeimage "sigs.k8s.io/kustomize/v3/pkg/image"
 )
 
@@ -21,7 +22,7 @@ type WriteUpstreamImageOptions struct {
 	ReportWriter   io.Writer
 }
 
-func (u *Upstream) CopyUpstreamImages(options WriteUpstreamImageOptions) ([]kustomizeimage.Image, error) {
+func CopyUpstreamImages(u *types.Upstream, options WriteUpstreamImageOptions) ([]kustomizeimage.Image, error) {
 	rootDir := options.RootDir
 	if options.CreateAppDir {
 		rootDir = path.Join(rootDir, u.Name)


Test patch:
diff --git a/kotskinds/apis/kots/v1beta1/v1beta1tests/helmchart_types_test.go b/kotskinds/apis/kots/v1beta1/v1beta1tests/helmchart_types_test.go
new file mode 100644
index 000000000..493f60670
--- /dev/null
+++ b/kotskinds/apis/kots/v1beta1/v1beta1tests/helmchart_types_test.go
@@ -0,0 +1,63 @@
+package v1beta1tests
+
+import (
+	"testing"
+
+	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
+	kotsscheme "github.com/replicatedhq/kots/kotskinds/client/kotsclientset/scheme"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"k8s.io/client-go/kubernetes/scheme"
+)
+
+func Test_HelmChart(t *testing.T) {
+	data := `apiVersion: kots.io/v1beta1
+kind: HelmChart
+metadata:
+  name: test
+spec:
+  # chart identifies a matching chart from a .tgz
+  chart:
+    name: test
+    chartVersion: 1.5.0-beta.2
+
+  # values are used in the customer environment, as a pre-render step
+  # these values will be supplied to helm template
+  values:
+    secretKey: '{{repl ConfigOption "secret_key"}}'
+    components:
+      worker:
+        replicaCount: repl{{ ConfigOption "worker_replica_count"}}
+
+    externalCacheDSN: repl{{ if ConfigOptionEquals "redis_location" "redis_location_external"}}{{repl ConfigOption "external_redis_dsn"}}{{repl end}}
+
+    ingress:
+      enabled: repl{{ ConfigOptionEquals "ingress_enabled" "ingress_enabled_yes"}}
+
+  # builder values provide a way to render the chart with all images
+  # and manifests. this is used in replicated to create airgap packages
+  builder:
+    ingress:
+      enabled: true
+      test: ~
+      1: 4
+      a: 4.0
+      b: ["a", "b"]
+`
+
+	kotsscheme.AddToScheme(scheme.Scheme)
+
+	decode := scheme.Codecs.UniversalDeserializer().Decode
+	obj, gvk, err := decode([]byte(data), nil, nil)
+	require.NoError(t, err)
+
+	assert.Equal(t, "kots.io", gvk.Group)
+	assert.Equal(t, "v1beta1", gvk.Version)
+	assert.Equal(t, "HelmChart", gvk.Kind)
+
+	helmChart := obj.(*kotsv1beta1.HelmChart)
+
+	assert.Equal(t, "test", helmChart.Spec.Chart.Name)
+	assert.Equal(t, "1.5.0-beta.2", helmChart.Spec.Chart.ChartVersion)
+
+}
diff --git a/kotskinds/apis/kots/v1beta1/v1beta1tests/license_types_test.go b/kotskinds/apis/kots/v1beta1/v1beta1tests/license_types_test.go
index bb8ea2777..46f6a50c5 100644
--- a/kotskinds/apis/kots/v1beta1/v1beta1tests/license_types_test.go
+++ b/kotskinds/apis/kots/v1beta1/v1beta1tests/license_types_test.go
@@ -4,6 +4,7 @@ import (
 	"testing"
 
 	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
+	kotsscheme "github.com/replicatedhq/kots/kotskinds/client/kotsclientset/scheme"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"k8s.io/client-go/kubernetes/scheme"
@@ -40,6 +41,8 @@ spec:
       value: "123asd"
   signature: IA==`
 
+	kotsscheme.AddToScheme(scheme.Scheme)
+
 	decode := scheme.Codecs.UniversalDeserializer().Decode
 	obj, gvk, err := decode([]byte(data), nil, nil)
 	require.NoError(t, err)
diff --git a/pkg/upstream/replicated_test.go b/pkg/upstream/replicated_test.go
index 9484ff045..94c9665b0 100644
--- a/pkg/upstream/replicated_test.go
+++ b/pkg/upstream/replicated_test.go
@@ -5,6 +5,7 @@ import (
 	"testing"
 
 	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
+	"github.com/replicatedhq/kots/pkg/upstream/types"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -72,7 +73,7 @@ func Test_releaseToFiles(t *testing.T) {
 	tests := []struct {
 		name     string
 		release  *Release
-		expected []UpstreamFile
+		expected []types.UpstreamFile
 	}{
 		{
 			name: "with common prefix",
@@ -82,12 +83,12 @@ func Test_releaseToFiles(t *testing.T) {
 					"manifests/service.yaml":    []byte("---"),
 				},
 			},
-			expected: []UpstreamFile{
-				UpstreamFile{
+			expected: []types.UpstreamFile{
+				types.UpstreamFile{
 					Path:    "deployment.yaml",
 					Content: []byte("---"),
 				},
-				UpstreamFile{
+				types.UpstreamFile{
 					Path:    "service.yaml",
 					Content: []byte("---"),
 				},
@@ -101,12 +102,12 @@ func Test_releaseToFiles(t *testing.T) {
 					"service.yaml":              []byte("---"),
 				},
 			},
-			expected: []UpstreamFile{
-				UpstreamFile{
+			expected: []types.UpstreamFile{
+				types.UpstreamFile{
 					Path:    "manifests/deployment.yaml",
 					Content: []byte("---"),
 				},
-				UpstreamFile{
+				types.UpstreamFile{
 					Path:    "service.yaml",
 					Content: []byte("---"),
 				},
@@ -121,16 +122,16 @@ func Test_releaseToFiles(t *testing.T) {
 					"userdata/values.yaml":      []byte("---"),
 				},
 			},
-			expected: []UpstreamFile{
-				UpstreamFile{
+			expected: []types.UpstreamFile{
+				types.UpstreamFile{
 					Path:    "deployment.yaml",
 					Content: []byte("---"),
 				},
-				UpstreamFile{
+				types.UpstreamFile{
 					Path:    "service.yaml",
 					Content: []byte("---"),
 				},
-				UpstreamFile{
+				types.UpstreamFile{
 					Path:    "userdata/values.yaml",
 					Content: []byte("---"),
 				},