Repository: replicatedhq/kots
Pull request #33 (2019-09-02 22:50:24)

Dir cleanup
Implementing pull for updates for replicated apps


Patch:
diff --git a/cmd/kots/cli/install.go b/cmd/kots/cli/install.go
index 3d5a1d76c..a857b3a03 100644
--- a/cmd/kots/cli/install.go
+++ b/cmd/kots/cli/install.go
@@ -162,14 +162,14 @@ func InstallCmd() *cobra.Command {
 	}
 
 	cmd.Flags().String("kubeconfig", filepath.Join(homeDir(), ".kube", "config"), "the kubeconfig to use")
-	cmd.Flags().String("namespace", "default", "the namespace to deploy to")
+	cmd.Flags().StringP("namespace", "n", "default", "the namespace to deploy to")
 	cmd.Flags().Bool("include-ship", false, "include the shipinit/edit/update and watch components")
 	cmd.Flags().Bool("include-github", false, "set up for github login")
 	cmd.Flags().String("shared-password", "", "shared password to apply")
 	cmd.Flags().String("service-type", "ClusterIP", "the service type to create")
 	cmd.Flags().Int32("node-port", 0, "the nodeport to assign to the service, when service-type is set to NodePort")
 	cmd.Flags().String("hostname", "localhost:8800", "the hostname to that the admin console will be exposed on")
-	cmd.Flags().StringP("name", "n", "", "name of the application to use in the Admin Console")
+	cmd.Flags().String("name", "", "name of the application to use in the Admin Console")
 	cmd.Flags().String("local-path", "", "specify a local-path to test the behavior of rendering a replicated app locally (only supported on replicated app types currently)")
 	cmd.Flags().String("license-file", "", "path to a license file to use when download a replicated app")
 
diff --git a/cmd/kots/cli/pull.go b/cmd/kots/cli/pull.go
index 764031471..a6e7c4c76 100644
--- a/cmd/kots/cli/pull.go
+++ b/cmd/kots/cli/pull.go
@@ -38,7 +38,9 @@ func PullCmd() *cobra.Command {
 				ExcludeKotsKinds:    v.GetBool("exclude-kots-kinds"),
 				ExcludeAdminConsole: v.GetBool("exclude-admin-console"),
 				SharedPassword:      v.GetString("shared-password"),
+				CreateAppDir:        true,
 			}
+
 			renderDir, err := pull.Pull(args[0], pullOptions)
 			if err != nil {
 				return err
diff --git a/ffi/main.go b/ffi/main.go
index 42e524cbd..e1582f3b8 100644
--- a/ffi/main.go
+++ b/ffi/main.go
@@ -3,21 +3,114 @@ package main
 import "C"
 
 import (
-	"os"
 	"fmt"
-	"path"
 	"io/ioutil"
+	"os"
+	"path"
 
-	"github.com/replicatedhq/kots/pkg/pull"
 	"github.com/mholt/archiver"
+	"github.com/pkg/errors"
 	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
 	kotsscheme "github.com/replicatedhq/kots/kotskinds/client/kotsclientset/scheme"
+	"github.com/replicatedhq/kots/pkg/pull"
 	"k8s.io/client-go/kubernetes/scheme"
 )
 
+//export UpdateCheck
+func UpdateCheck(fromArchivePath string) int {
+	tmpRoot, err := ioutil.TempDir("", "kots")
+	if err != nil {
+		fmt.Printf("failed to create temp path: %s\n", err.Error())
+		return -1
+	}
+	defer os.RemoveAll(tmpRoot)
+
+	// extract the current archive to this root
+	tarGz := archiver.TarGz{
+		Tar: &archiver.Tar{
+			ImplicitTopLevelFolder: false,
+		},
+	}
+	if err := tarGz.Unarchive(fromArchivePath, tmpRoot); err != nil {
+		fmt.Printf("failed to unarchive: %s\n", err.Error())
+		return -1
+	}
+
+	beforeCursor, err := readCursorFromPath(tmpRoot)
+	if err != nil {
+		fmt.Printf("failed to read cursor file: %s\n", err.Error())
+		return -1
+	}
+
+	expectedLicenseFile := path.Join(tmpRoot, "upstream", "userdata", "license.yaml")
+	_, err = os.Stat(expectedLicenseFile)
+	if err != nil {
+		fmt.Printf("failed to find license file in archive\n")
+		return -1
+	}
+	licenseData, err := ioutil.ReadFile(expectedLicenseFile)
+	if err != nil {
+		fmt.Printf("failed to read license file: %s\n", err.Error())
+		return -1
+	}
+
+	kotsscheme.AddToScheme(scheme.Scheme)
+	decode := scheme.Codecs.UniversalDeserializer().Decode
+	obj, _, err := decode([]byte(licenseData), nil, nil)
+	if err != nil {
+		fmt.Printf("failed to decode license data: %s\n", err.Error())
+		return -1
+	}
+	license := obj.(*kotsv1beta1.License)
+
+	pullOptions := pull.PullOptions{
+		LicenseFile:         expectedLicenseFile,
+		RootDir:             tmpRoot,
+		ExcludeKotsKinds:    true,
+		ExcludeAdminConsole: true,
+		CreateAppDir:        false,
+	}
+
+	if _, err := pull.Pull(fmt.Sprintf("replicated://%s", license.Spec.AppSlug), pullOptions); err != nil {
+		fmt.Printf("failed to pull upstream: %s\n", err.Error())
+		return 1
+	}
+
+	afterCursor, err := readCursorFromPath(tmpRoot)
+	if err != nil {
+		fmt.Printf("failed to read cursor file after update: %s\n", err.Error())
+		return -1
+	}
+
+	fmt.Printf("Result of checking for updates for %s: Before: %s, After %s\n", license.Spec.AppSlug, beforeCursor, afterCursor)
+
+	isUpdateAvailable := string(beforeCursor) != string(afterCursor)
+	if !isUpdateAvailable {
+		return 0
+	}
+
+	paths := []string{
+		path.Join(tmpRoot, "upstream"),
+		path.Join(tmpRoot, "base"),
+		path.Join(tmpRoot, "overlays"),
+	}
+
+	err = os.Remove(fromArchivePath)
+	if err != nil {
+		fmt.Printf("failed to delete archive to replace: %s\n", err.Error())
+		return -1
+	}
+
+	if err := tarGz.Archive(paths, fromArchivePath); err != nil {
+		fmt.Printf("failed to write archive: %s\n", err.Error())
+		return -1
+	}
+
+	return 1
+}
 
 //export PullFromLicense
-func PullFromLicense(licenseData string, downstream string, outputFile string) int { 
+func PullFromLicense(licenseData string, downstream string, outputFile string) int {
 	kotsscheme.AddToScheme(scheme.Scheme)
 	decode := scheme.Codecs.UniversalDeserializer().Decode
 	obj, _, err := decode([]byte(licenseData), nil, nil)
@@ -46,16 +139,17 @@ func PullFromLicense(licenseData string, downstream string, outputFile string) i
 		return 1
 	}
 	defer os.RemoveAll(tmpRoot)
-	
+
 	pullOptions := pull.PullOptions{
-		Downstreams: []string{downstream},
-		LicenseFile: licenseFile.Name(),
-		ExcludeKotsKinds: true,
-		RootDir: tmpRoot,
+		Downstreams:         []string{downstream},
+		LicenseFile:         licenseFile.Name(),
+		ExcludeKotsKinds:    true,
+		RootDir:             tmpRoot,
 		ExcludeAdminConsole: true,
+		CreateAppDir:        false,
 	}
 
-	if _, err := pull.Pull(fmt.Sprintf("replicated://%", license.Spec.AppSlug), pullOptions); err != nil {
+	if _, err := pull.Pull(fmt.Sprintf("replicated://%s", license.Spec.AppSlug), pullOptions); err != nil {
 		fmt.Printf("failed to pull upstream: %s\n", err.Error())
 		return 1
 	}
@@ -67,44 +161,44 @@ func PullFromLicense(licenseData string, downstream string, outputFile string) i
 		},
 	}
 
-	// the tmp dir has the app name in it now
-	subdirs, err := ioutil.ReadDir(tmpRoot)
-	if err != nil {
-		fmt.Printf("unable to read dir: %s\n", err.Error())
+	paths := []string{
+		path.Join(tmpRoot, "upstream"),
+		path.Join(tmpRoot, "base"),
+		path.Join(tmpRoot, "overlays"),
+	}
+
+	if err := tarGz.Archive(paths, outputFile); err != nil {
+		fmt.Printf("failed to write archive: %s", err.Error())
 		return 1
 	}
 
-	uploadRootDir := ""
-	for _, subdir := range subdirs {
-		if subdir.IsDir() {
-			if subdir.Name() == "." {
-				continue
-			}
-			if subdir.Name() == ".." {
-				continue
-			}
+	return 0
+}
 
-			uploadRootDir = path.Join(tmpRoot, subdir.Name())
-			break
-		}
+func readCursorFromPath(rootPath string) (string, error) {
+	installationFilePath := path.Join(rootPath, "upstream", "userdata", "installation.yaml")
+	_, err := os.Stat(installationFilePath)
+	if os.IsNotExist(err) {
+		return "", nil
 	}
-	if uploadRootDir == "" {
-		fmt.Println("failed to find upload root dir")
-		return 1
+	if err != nil {
+		return "", errors.Wrap(err, "failed to open file")
 	}
 
-	paths := []string{
-		path.Join(uploadRootDir, "upstream"),
-		path.Join(uploadRootDir, "base"),
-		path.Join(uploadRootDir, "overlays"),
+	installationData, err := ioutil.ReadFile(installationFilePath)
+	if err != nil {
+		return "", errors.Wrap(err, "failed to read update installation file")
 	}
 
-	if err := tarGz.Archive(paths, outputFile); err != nil {
-		fmt.Printf("failed to write archive: %s", err.Error())
-		return 1
+	kotsscheme.AddToScheme(scheme.Scheme)
+	decode := scheme.Codecs.UniversalDeserializer().Decode
+	obj, _, err := decode([]byte(installationData), nil, nil)
+	if err != nil {
+		return "", errors.Wrap(err, "failed to devode installation data")
 	}
 
-	return 0
+	installation := obj.(*kotsv1beta1.Installation)
+	return installation.Spec.UpdateCursor, nil
 }
 
 func main() {}
diff --git a/kotskinds/apis/kots/v1beta1/installation_types.go b/kotskinds/apis/kots/v1beta1/installation_types.go
new file mode 100644
index 000000000..d51caf526
--- /dev/null
+++ b/kotskinds/apis/kots/v1beta1/installation_types.go
@@ -0,0 +1,57 @@
+/*
+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 (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// InstallationSpec defines the desired state of InstallationSpec
+type InstallationSpec struct {
+	UpdateCursor string `json:"updateCursor,omitEmpty"`
+	VersionLabel string `json:"versionLabel,omitEmpty"`
+}
+
+// InstallationStatus defines the observed state of Installation
+type InstallationStatus struct {
+}
+
+// +genclient
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+// Iinstallation is the Schema for the installation API
+// +k8s:openapi-gen=true
+// +kubebuilder:subresource:status
+type Installation struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   InstallationSpec   `json:"spec,omitempty"`
+	Status InstallationStatus `json:"status,omitempty"`
+}
+
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+
+// InstallationList contains a list of Installations
+type InstallationList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []Installation `json:"items"`
+}
+
+func init() {
+	SchemeBuilder.Register(&Installation{}, &InstallationList{})
+}
diff --git a/kotskinds/apis/kots/v1beta1/zz_generated.deepcopy.go b/kotskinds/apis/kots/v1beta1/zz_generated.deepcopy.go
index 355f49a59..e198b5791 100644
--- a/kotskinds/apis/kots/v1beta1/zz_generated.deepcopy.go
+++ b/kotskinds/apis/kots/v1beta1/zz_generated.deepcopy.go
@@ -367,6 +367,95 @@ func (in *ConfigValuesStatus) DeepCopy() *ConfigValuesStatus {
 	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
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	out.Spec = in.Spec
+	out.Status = in.Status
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Installation.
+func (in *Installation) DeepCopy() *Installation {
+	if in == nil {
+		return nil
+	}
+	out := new(Installation)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *Installation) 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 *InstallationList) DeepCopyInto(out *InstallationList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	out.ListMeta = in.ListMeta
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]Installation, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstallationList.
+func (in *InstallationList) DeepCopy() *InstallationList {
+	if in == nil {
+		return nil
+	}
+	out := new(InstallationList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *InstallationList) 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 *InstallationSpec) DeepCopyInto(out *InstallationSpec) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstallationSpec.
+func (in *InstallationSpec) DeepCopy() *InstallationSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(InstallationSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *InstallationStatus) DeepCopyInto(out *InstallationStatus) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstallationStatus.
+func (in *InstallationStatus) DeepCopy() *InstallationStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(InstallationStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *License) DeepCopyInto(out *License) {
 	*out = *in
diff --git a/kotskinds/client/kotsclientset/typed/kots/v1beta1/fake/fake_installation.go b/kotskinds/client/kotsclientset/typed/kots/v1beta1/fake/fake_installation.go
new file mode 100644
index 000000000..b1d76a682
--- /dev/null
+++ b/kotskinds/client/kotsclientset/typed/kots/v1beta1/fake/fake_installation.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"
+)
+
+// FakeInstallations implements InstallationInterface
+type FakeInstallations struct {
+	Fake *FakeKotsV1beta1
+	ns   string
+}
+
+var installationsResource = schema.GroupVersionResource{Group: "kots.io", Version: "v1beta1", Resource: "installations"}
+
+var installationsKind = schema.GroupVersionKind{Group: "kots.io", Version: "v1beta1", Kind: "Installation"}
+
+// Get takes name of the installation, and returns the corresponding installation object, and an error if there is any.
+func (c *FakeInstallations) Get(name string, options v1.GetOptions) (result *v1beta1.Installation, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewGetAction(installationsResource, c.ns, name), &v1beta1.Installation{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1beta1.Installation), err
+}
+
+// List takes label and field selectors, and returns the list of Installations that match those selectors.
+func (c *FakeInstallations) List(opts v1.ListOptions) (result *v1beta1.InstallationList, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewListAction(installationsResource, installationsKind, c.ns, opts), &v1beta1.InstallationList{})
+
+	if obj == nil {
+		return nil, err
+	}
+
+	label, _, _ := testing.ExtractFromListOptions(opts)
+	if label == nil {
+		label = labels.Everything()
+	}
+	list := &v1beta1.InstallationList{ListMeta: obj.(*v1beta1.InstallationList).ListMeta}
+	for _, item := range obj.(*v1beta1.InstallationList).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 installations.
+func (c *FakeInstallations) Watch(opts v1.ListOptions) (watch.Interface, error) {
+	return c.Fake.
+		InvokesWatch(testing.NewWatchAction(installationsResource, c.ns, opts))
+
+}
+
+// Create takes the representation of a installation and creates it.  Returns the server's representation of the installation, and an error, if there is any.
+func (c *FakeInstallations) Create(installation *v1beta1.Installation) (result *v1beta1.Installation, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewCreateAction(installationsResource, c.ns, installation), &v1beta1.Installation{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1beta1.Installation), err
+}
+
+// Update takes the representation of a installation and updates it. Returns the server's representation of the installation, and an error, if there is any.
+func (c *FakeInstallations) Update(installation *v1beta1.Installation) (result *v1beta1.Installation, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewUpdateAction(installationsResource, c.ns, installation), &v1beta1.Installation{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1beta1.Installation), 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 *FakeInstallations) UpdateStatus(installation *v1beta1.Installation) (*v1beta1.Installation, error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewUpdateSubresourceAction(installationsResource, "status", c.ns, installation), &v1beta1.Installation{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1beta1.Installation), err
+}
+
+// Delete takes name of the installation and deletes it. Returns an error if one occurs.
+func (c *FakeInstallations) Delete(name string, options *v1.DeleteOptions) error {
+	_, err := c.Fake.
+		Invokes(testing.NewDeleteAction(installationsResource, c.ns, name), &v1beta1.Installation{})
+
+	return err
+}
+
+// DeleteCollection deletes a collection of objects.
+func (c *FakeInstallations) DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error {
+	action := testing.NewDeleteCollectionAction(installationsResource, c.ns, listOptions)
+
+	_, err := c.Fake.Invokes(action, &v1beta1.InstallationList{})
+	return err
+}
+
+// Patch applies the patch and returns the patched installation.
+func (c *FakeInstallations) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1beta1.Installation, err error) {
+	obj, err := c.Fake.
+		Invokes(testing.NewPatchSubresourceAction(installationsResource, c.ns, name, pt, data, subresources...), &v1beta1.Installation{})
+
+	if obj == nil {
+		return nil, err
+	}
+	return obj.(*v1beta1.Installation), 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 e34d9a362..b96c64804 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
@@ -39,6 +39,10 @@ func (c *FakeKotsV1beta1) ConfigValueses(namespace string) v1beta1.ConfigValuesI
 	return &FakeConfigValueses{c, namespace}
 }
 
+func (c *FakeKotsV1beta1) Installations(namespace string) v1beta1.InstallationInterface {
+	return &FakeInstallations{c, namespace}
+}
+
 func (c *FakeKotsV1beta1) Licenses(namespace string) v1beta1.LicenseInterface {
 	return &FakeLicenses{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 e687eadc9..eea61f23b 100644
--- a/kotskinds/client/kotsclientset/typed/kots/v1beta1/generated_expansion.go
+++ b/kotskinds/client/kotsclientset/typed/kots/v1beta1/generated_expansion.go
@@ -23,4 +23,6 @@ type ConfigExpansion interface{}
 
 type ConfigValuesExpansion interface{}
 
+type InstallationExpansion interface{}
+
 type LicenseExpansion interface{}
diff --git a/kotskinds/client/kotsclientset/typed/kots/v1beta1/installation.go b/kotskinds/client/kotsclientset/typed/kots/v1beta1/installation.go
new file mode 100644
index 000000000..2f668a896
--- /dev/null
+++ b/kotskinds/client/kotsclientset/typed/kots/v1beta1/installation.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"
+)
+
+// InstallationsGetter has a method to return a InstallationInterface.
+// A group's client should implement this interface.
+type InstallationsGetter interface {
+	Installations(namespace string) InstallationInterface
+}
+
+// InstallationInterface has methods to work with Installation resources.
+type InstallationInterface interface {
+	Create(*v1beta1.Installation) (*v1beta1.Installation, error)
+	Update(*v1beta1.Installation) (*v1beta1.Installation, error)
+	UpdateStatus(*v1beta1.Installation) (*v1beta1.Installation, error)
+	Delete(name string, options *v1.DeleteOptions) error
+	DeleteCollection(options *v1.DeleteOptions, listOptions v1.ListOptions) error
+	Get(name string, options v1.GetOptions) (*v1beta1.Installation, error)
+	List(opts v1.ListOptions) (*v1beta1.InstallationList, error)
+	Watch(opts v1.ListOptions) (watch.Interface, error)
+	Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1beta1.Installation, err error)
+	InstallationExpansion
+}
+
+// installations implements InstallationInterface
+type installations struct {
+	client rest.Interface
+	ns     string
+}
+
+// newInstallations returns a Installations
+func newInstallations(c *KotsV1beta1Client, namespace string) *installations {
+	return &installations{
+		client: c.RESTClient(),
+		ns:     namespace,
+	}
+}
+
+// Get takes name of the installation, and returns the corresponding installation object, and an error if there is any.
+func (c *installations) Get(name string, options v1.GetOptions) (result *v1beta1.Installation, err error) {
+	result = &v1beta1.Installation{}
+	err = c.client.Get().
+		Namespace(c.ns).
+		Resource("installations").
+		Name(name).
+		VersionedParams(&options, scheme.ParameterCodec).
+		Do().
+		Into(result)
+	return
+}
+
+// List takes label and field selectors, and returns the list of Installations that match those selectors.
+func (c *installations) List(opts v1.ListOptions) (result *v1beta1.InstallationList, err error) {
+	var timeout time.Duration
+	if opts.TimeoutSeconds != nil {
+		timeout = time.Duration(*opts.TimeoutSeconds) * time.Second
+	}
+	result = &v1beta1.InstallationList{}
+	err = c.client.Get().
+		Namespace(c.ns).
+		Resource("installations").
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Timeout(timeout).
+		Do().
+		Into(result)
+	return
+}
+
+// Watch returns a watch.Interface that watches the requested installations.
+func (c *installations) 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("installations").
+		VersionedParams(&opts, scheme.ParameterCodec).
+		Timeout(timeout).
+		Watch()
+}
+
+// Create takes the representation of a installation and creates it.  Returns the server's representation of the installation, and an error, if there is any.
+func (c *installations) Create(installation *v1beta1.Installation) (result *v1beta1.Installation, err error) {
+	result = &v1beta1.Installation{}
+	err = c.client.Post().
+		Namespace(c.ns).
+		Resource("installations").
+		Body(installation).
+		Do().
+		Into(result)
+	return
+}
+
+// Update takes the representation of a installation and updates it. Returns the server's representation of the installation, and an error, if there is any.
+func (c *installations) Update(installation *v1beta1.Installation) (result *v1beta1.Installation, err error) {
+	result = &v1beta1.Installation{}
+	err = c.client.Put().
+		Namespace(c.ns).
+		Resource("installations").
+		Name(installation.Name).
+		Body(installation).
+		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 *installations) UpdateStatus(installation *v1beta1.Installation) (result *v1beta1.Installation, err error) {
+	result = &v1beta1.Installation{}
+	err = c.client.Put().
+		Namespace(c.ns).
+		Resource("installations").
+		Name(installation.Name).
+		SubResource("status").
+		Body(installation).
+		Do().
+		Into(result)
+	return
+}
+
+// Delete takes name of the installation and deletes it. Returns an error if one occurs.
+func (c *installations) Delete(name string, options *v1.DeleteOptions) error {
+	return c.client.Delete().
+		Namespace(c.ns).
+		Resource("installations").
+		Name(name).
+		Body(options).
+		Do().
+		Error()
+}
+
+// DeleteCollection deletes a collection of objects.
+func (c *installations) 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("installations").
+		VersionedParams(&listOptions, scheme.ParameterCodec).
+		Timeout(timeout).
+		Body(options).
+		Do().
+		Error()
+}
+
+// Patch applies the patch and returns the patched installation.
+func (c *installations) Patch(name string, pt types.PatchType, data []byte, subresources ...string) (result *v1beta1.Installation, err error) {
+	result = &v1beta1.Installation{}
+	err = c.client.Patch(pt).
+		Namespace(c.ns).
+		Resource("installations").
+		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 b04850780..eb032ec9a 100644
--- a/kotskinds/client/kotsclientset/typed/kots/v1beta1/kots_client.go
+++ b/kotskinds/client/kotsclientset/typed/kots/v1beta1/kots_client.go
@@ -29,6 +29,7 @@ type KotsV1beta1Interface interface {
 	ApplicationsGetter
 	ConfigsGetter
 	ConfigValuesesGetter
+	InstallationsGetter
 	LicensesGetter
 }
 
@@ -49,6 +50,10 @@ func (c *KotsV1beta1Client) ConfigValueses(namespace string) ConfigValuesInterfa
 	return newConfigValueses(c, namespace)
 }
 
+func (c *KotsV1beta1Client) Installations(namespace string) InstallationInterface {
+	return newInstallations(c, namespace)
+}
+
 func (c *KotsV1beta1Client) Licenses(namespace string) LicenseInterface {
 	return newLicenses(c, namespace)
 }
diff --git a/pkg/base/base.go b/pkg/base/base.go
index 94b0516cf..fe815c0ac 100644
--- a/pkg/base/base.go
+++ b/pkg/base/base.go
@@ -47,10 +47,6 @@ func (f BaseFile) ShouldBeIncludedInBaseKustomization(excludeKotsKinds bool) boo
 // ShouldBeIncludedInBaseFilesystem attempts to determine if this is a valid Kubernetes manifest.
 // It accomplished this by trying to unmarshal the YAML and looking for a apiVersion and Kind
 func (f BaseFile) ShouldBeIncludedInBaseFilesystem(excludeKotsKinds bool) bool {
-	if f.Path == ".kotsCursor" {
-		return true
-	}
-
 	o := OverlySimpleGVK{}
 
 	if err := yaml.Unmarshal(f.Content, &o); err != nil {
diff --git a/pkg/base/helm.go b/pkg/base/helm.go
index 915070371..8f7a4b321 100644
--- a/pkg/base/helm.go
+++ b/pkg/base/helm.go
@@ -101,13 +101,6 @@ func renderHelm(u *upstream.Upstream, renderOptions *RenderOptions) (*Base, erro
 		baseFiles = cleanedBaseFiles
 	}
 
-	// Add a file with the update cursor
-	cursorFile := BaseFile{
-		Path:    ".kotsCursor",
-		Content: []byte(u.UpdateCursor),
-	}
-	baseFiles = append(baseFiles, cursorFile)
-
 	return &Base{
 		Files: baseFiles,
 	}, nil
diff --git a/pkg/base/replicated.go b/pkg/base/replicated.go
index 6c38688bb..84d1020a0 100644
--- a/pkg/base/replicated.go
+++ b/pkg/base/replicated.go
@@ -59,13 +59,6 @@ func renderReplicated(u *upstream.Upstream, renderOptions *RenderOptions) (*Base
 		baseFiles = append(baseFiles, baseFile)
 	}
 
-	// Add a file with the update cursor
-	cursorFile := BaseFile{
-		Path:    ".kotsCursor",
-		Content: []byte(u.UpdateCursor),
-	}
-	baseFiles = append(baseFiles, cursorFile)
-
 	base := Base{
 		Files: baseFiles,
 	}
diff --git a/pkg/pull/pull.go b/pkg/pull/pull.go
index fd0628f37..d0fc85b9c 100644
--- a/pkg/pull/pull.go
+++ b/pkg/pull/pull.go
@@ -26,6 +26,7 @@ type PullOptions struct {
 	ExcludeKotsKinds    bool
 	ExcludeAdminConsole bool
 	SharedPassword      string
+	CreateAppDir        bool
 }
 
 // PullApplicationMetadata will return the application metadata yaml, if one is
@@ -101,7 +102,7 @@ func Pull(upstreamURI string, pullOptions PullOptions) (string, error) {
 
 	writeUpstreamOptions := upstream.WriteOptions{
 		RootDir:             pullOptions.RootDir,
-		CreateAppDir:        true,
+		CreateAppDir:        pullOptions.CreateAppDir,
 		IncludeAdminConsole: includeAdminConsole,
 		SharedPassword:      pullOptions.SharedPassword,
 	}
@@ -171,7 +172,7 @@ func Pull(upstreamURI string, pullOptions PullOptions) (string, error) {
 		}
 	}
 
-	return path.Join(pullOptions.RootDir, u.Name, u.GetBaseDir(writeUpstreamOptions)), nil
+	return path.Join(pullOptions.RootDir, u.Name), nil
 }
 
 func parseLicenseFromFile(filename string) (*kotsv1beta1.License, error) {
diff --git a/pkg/upload/archive.go b/pkg/upload/archive.go
index 5853a051d..a8fe74e73 100644
--- a/pkg/upload/archive.go
+++ b/pkg/upload/archive.go
@@ -8,6 +8,9 @@ import (
 
 	"github.com/mholt/archiver"
 	"github.com/pkg/errors"
+	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
+	kotsscheme "github.com/replicatedhq/kots/kotskinds/client/kotsclientset/scheme"
+	"k8s.io/client-go/kubernetes/scheme"
 )
 
 func createUploadableArchive(rootPath string) (string, error) {
@@ -41,8 +44,8 @@ func createUploadableArchive(rootPath string) (string, error) {
 }
 
 func findUpdateCursor(rootPath string) (string, error) {
-	cursorFilePath := path.Join(rootPath, "base", ".kotsCursor")
-	_, err := os.Stat(cursorFilePath)
+	installationFilePath := path.Join(rootPath, "upstream", "userdata", "installation.yaml")
+	_, err := os.Stat(installationFilePath)
 	if os.IsNotExist(err) {
 		return "", nil
 	}
@@ -50,12 +53,21 @@ func findUpdateCursor(rootPath string) (string, error) {
 		return "", errors.Wrap(err, "failed to open file")
 	}
 
-	b, err := ioutil.ReadFile(cursorFilePath)
+	installationData, err := ioutil.ReadFile(installationFilePath)
 	if err != nil {
-		return "", errors.Wrap(err, "failed to read update cursor file")
+		return "", errors.Wrap(err, "failed to read update installation file")
 	}
 
-	return string(b), nil
+	kotsscheme.AddToScheme(scheme.Scheme)
+	decode := scheme.Codecs.UniversalDeserializer().Decode
+	obj, _, err := decode([]byte(installationData), nil, nil)
+	if err != nil {
+		return "", errors.Wrap(err, "failed to devode installation data")
+	}
+
+	installation := obj.(*kotsv1beta1.Installation)
+
+	return installation.Spec.UpdateCursor, nil
 }
 
 func findLicense(rootPath string) (*string, error) {
diff --git a/pkg/upstream/replicated.go b/pkg/upstream/replicated.go
index 08cdf9c0c..fdf5a0a7f 100644
--- a/pkg/upstream/replicated.go
+++ b/pkg/upstream/replicated.go
@@ -38,6 +38,7 @@ type App struct {
 
 type Release struct {
 	UpdateCursor string
+	VersionLabel string
 	Manifests    map[string][]byte
 }
 
@@ -103,6 +104,7 @@ func downloadReplicated(u *url.URL, localPath string, license *kotsv1beta1.Licen
 		Files:        files,
 		Type:         "replicated",
 		UpdateCursor: release.UpdateCursor,
+		VersionLabel: release.VersionLabel,
 	}
 
 	return upstream, nil
@@ -231,6 +233,7 @@ func downloadReplicatedApp(replicatedUpstream *ReplicatedUpstream, license *kots
 	defer getResp.Body.Close()
 
 	updateCursor := getResp.Header.Get("X-Replicated-Sequence")
+	versionLabel := getResp.Header.Get("X-Replicated-VersionLabel")
 
 	gzf, err := gzip.NewReader(getResp.Body)
 	if err != nil {
@@ -240,6 +243,7 @@ func downloadReplicatedApp(replicatedUpstream *ReplicatedUpstream, license *kots
 	release := Release{
 		Manifests:    make(map[string][]byte),
 		UpdateCursor: updateCursor,
+		VersionLabel: versionLabel,
 	}
 	tarReader := tar.NewReader(gzf)
 	i := 0
@@ -402,12 +406,26 @@ func releaseToFiles(release *Release) ([]UpstreamFile, error) {
 		upstreamFiles = append(upstreamFiles, upstreamFile)
 	}
 
+	// Stash the user data for this search (we will readd at the end)
+	userdataFiles := []UpstreamFile{}
+	withoutUserdataFiles := []UpstreamFile{}
+	for _, file := range upstreamFiles {
+		d, _ := path.Split(file.Path)
+		dirs := strings.Split(d, string(os.PathSeparator))
+
+		if dirs[0] == "userdata" {
+			userdataFiles = append(userdataFiles, file)
+		} else {
+			withoutUserdataFiles = append(withoutUserdataFiles, file)
+		}
+	}
+
 	// remove any common prefix from all files
-	if len(upstreamFiles) > 0 {
-		firstFileDir, _ := path.Split(upstreamFiles[0].Path)
+	if len(withoutUserdataFiles) > 0 {
+		firstFileDir, _ := path.Split(withoutUserdataFiles[0].Path)
 		commonPrefix := strings.Split(firstFileDir, string(os.PathSeparator))
 
-		for _, file := range upstreamFiles {
+		for _, file := range withoutUserdataFiles {
 			d, _ := path.Split(file.Path)
 			dirs := strings.Split(d, string(os.PathSeparator))
 
@@ -416,7 +434,7 @@ func releaseToFiles(release *Release) ([]UpstreamFile, error) {
 		}
 
 		cleanedUpstreamFiles := []UpstreamFile{}
-		for _, file := range upstreamFiles {
+		for _, file := range withoutUserdataFiles {
 			d, f := path.Split(file.Path)
 			d2 := strings.Split(d, string(os.PathSeparator))
 
@@ -429,6 +447,9 @@ func releaseToFiles(release *Release) ([]UpstreamFile, error) {
 
 		upstreamFiles = cleanedUpstreamFiles
 	}
+
+	upstreamFiles = append(upstreamFiles, userdataFiles...)
+
 	return upstreamFiles, nil
 }
 
diff --git a/pkg/upstream/upstream.go b/pkg/upstream/upstream.go
index 16f91edb0..382249889 100644
--- a/pkg/upstream/upstream.go
+++ b/pkg/upstream/upstream.go
@@ -11,4 +11,5 @@ type Upstream struct {
 	Type         string
 	Files        []UpstreamFile
 	UpdateCursor string
+	VersionLabel string
 }
diff --git a/pkg/upstream/write.go b/pkg/upstream/write.go
index 9c3e515a7..6d265e311 100644
--- a/pkg/upstream/write.go
+++ b/pkg/upstream/write.go
@@ -9,6 +9,7 @@ import (
 	"github.com/pkg/errors"
 	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
 	kotsscheme "github.com/replicatedhq/kots/kotskinds/client/kotsclientset/scheme"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime/serializer/json"
 	"k8s.io/client-go/kubernetes/scheme"
 )
@@ -93,6 +94,25 @@ func (u *Upstream) WriteUpstream(options WriteOptions) error {
 		}
 	}
 
+	// Write the installation status (update cursor, etc)
+	installation := kotsv1beta1.Installation{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: "kots.io/v1beta1",
+			Kind:       "Installation",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name: u.Name,
+		},
+		Spec: kotsv1beta1.InstallationSpec{
+			UpdateCursor: u.UpdateCursor,
+			VersionLabel: u.VersionLabel,
+		},
+	}
+	err = ioutil.WriteFile(path.Join(renderDir, "userdata/installation.yaml"), mustMarshalInstallation(&installation), 0644)
+	if err != nil {
+		return errors.Wrap(err, "failed to write installation")
+	}
+
 	return nil
 }
 
@@ -137,3 +157,16 @@ func mergeValues(previousValues []byte, applicationDeliveredValues []byte) ([]by
 
 	return b.Bytes(), nil
 }
+
+func mustMarshalInstallation(installation *kotsv1beta1.Installation) []byte {
+	kotsscheme.AddToScheme(scheme.Scheme)
+
+	s := json.NewYAMLSerializer(json.DefaultMetaFactory, scheme.Scheme, scheme.Scheme)
+
+	var b bytes.Buffer
+	if err := s.Encode(installation, &b); err != nil {
+		panic(err)
+	}
+
+	return b.Bytes()
+}


Test patch:
diff --git a/pkg/upstream/replicated_test.go b/pkg/upstream/replicated_test.go
index ef8fbc5aa..e1a806209 100644
--- a/pkg/upstream/replicated_test.go
+++ b/pkg/upstream/replicated_test.go
@@ -65,3 +65,85 @@ func Test_parseReplicatedURL(t *testing.T) {
 		})
 	}
 }
+
+func Test_releaseToFiles(t *testing.T) {
+	tests := []struct {
+		name     string
+		release  *Release
+		expected []UpstreamFile
+	}{
+		{
+			name: "with common prefix",
+			release: &Release{
+				Manifests: map[string][]byte{
+					"manifests/deployment.yaml": []byte("---"),
+					"manifests/service.yaml":    []byte("---"),
+				},
+			},
+			expected: []UpstreamFile{
+				UpstreamFile{
+					Path:    "deployment.yaml",
+					Content: []byte("---"),
+				},
+				UpstreamFile{
+					Path:    "service.yaml",
+					Content: []byte("---"),
+				},
+			},
+		},
+		{
+			name: "without common prefix",
+			release: &Release{
+				Manifests: map[string][]byte{
+					"manifests/deployment.yaml": []byte("---"),
+					"service.yaml":              []byte("---"),
+				},
+			},
+			expected: []UpstreamFile{
+				UpstreamFile{
+					Path:    "manifests/deployment.yaml",
+					Content: []byte("---"),
+				},
+				UpstreamFile{
+					Path:    "service.yaml",
+					Content: []byte("---"),
+				},
+			},
+		},
+		{
+			name: "common prefix, with userdata",
+			release: &Release{
+				Manifests: map[string][]byte{
+					"manifests/deployment.yaml": []byte("---"),
+					"manifests/service.yaml":    []byte("---"),
+					"userdata/values.yaml":      []byte("---"),
+				},
+			},
+			expected: []UpstreamFile{
+				UpstreamFile{
+					Path:    "deployment.yaml",
+					Content: []byte("---"),
+				},
+				UpstreamFile{
+					Path:    "service.yaml",
+					Content: []byte("---"),
+				},
+				UpstreamFile{
+					Path:    "userdata/values.yaml",
+					Content: []byte("---"),
+				},
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			req := require.New(t)
+
+			actual, err := releaseToFiles(test.release)
+			req.NoError(err)
+
+			assert.ElementsMatch(t, test.expected, actual)
+		})
+	}
+}