Repository: replicatedhq/kots
Pull request #237 (2020-01-03 23:47:43)

Support airgap updates


Patch:
diff --git a/ffi/updatedownload.go b/ffi/updatedownload.go
index 766b78e41..3b3b36d14 100644
--- a/ffi/updatedownload.go
+++ b/ffi/updatedownload.go
@@ -9,6 +9,8 @@ import (
 	"os"
 	"path/filepath"
 
+	"github.com/pkg/errors"
+	"github.com/replicatedhq/kots/pkg/cursor"
 	"github.com/replicatedhq/kots/pkg/pull"
 )
 
@@ -135,3 +137,160 @@ func UpdateDownload(socket, fromArchivePath, namespace, registryJson, cursor str
 		ffiResult = NewFFIResult(1)
 	}()
 }
+
+//export UpdateDownloadFromAirgap
+func UpdateDownloadFromAirgap(socket, fromArchivePath, namespace, registryJson, airgapFile string) {
+	go func() {
+		var ffiResult *FFIResult
+
+		statusClient, err := connectToStatusServer(socket)
+		if err != nil {
+			fmt.Printf("failed to connect to status server: %s\n", err)
+			return
+		}
+		defer func() {
+			statusClient.end(ffiResult)
+		}()
+
+		registryInfo := struct {
+			Host      string `json:"registryHostname"`
+			Username  string `json:"registryUsername"`
+			Password  string `json:"registryPassword"`
+			Namespace string `json:"namespace"`
+		}{}
+		if err := json.Unmarshal([]byte(registryJson), &registryInfo); err != nil {
+			fmt.Printf("failed to unmarshal registry info: %s\n", err.Error())
+			ffiResult = NewFFIResult(-1).WithError(err)
+			return
+		}
+
+		airgapRoot, err := ioutil.TempDir("", "airgap")
+		if err != nil {
+			fmt.Printf("failed to create temp airgap path: %s\n", err.Error())
+			ffiResult = NewFFIResult(-1).WithError(err)
+			return
+		}
+		defer os.RemoveAll(airgapRoot)
+
+		_, err = extractArchive(airgapRoot, airgapFile)
+		if err != nil {
+			fmt.Printf("failed to extract airgap archive: %s\n", err.Error())
+			ffiResult = NewFFIResult(-1).WithError(err)
+			return
+		}
+
+		tmpRoot, err := ioutil.TempDir("", "kots")
+		if err != nil {
+			fmt.Printf("failed to create temp release path: %s\n", err.Error())
+			ffiResult = NewFFIResult(-1).WithError(err)
+			return
+		}
+		defer os.RemoveAll(tmpRoot)
+
+		tarGz, err := extractArchive(tmpRoot, fromArchivePath)
+		if err != nil {
+			fmt.Printf("failed to extract release archive: %s\n", err.Error())
+			ffiResult = NewFFIResult(-1).WithError(err)
+			return
+		}
+
+		installationFilePath := filepath.Join(tmpRoot, "upstream", "userdata", "installation.yaml")
+		beforeCursor, err := readCursorFromPath(installationFilePath)
+		if err != nil {
+			fmt.Printf("failed to read cursor file: %s\n", err.Error())
+			ffiResult = NewFFIResult(-1).WithError(err)
+			return
+		}
+
+		expectedLicenseFile := filepath.Join(tmpRoot, "upstream", "userdata", "license.yaml")
+		license, err := loadLicenseFromPath(expectedLicenseFile)
+		if err != nil {
+			fmt.Printf("failed to load license: %s\n", err.Error())
+			ffiResult = NewFFIResult(-1).WithError(err)
+			return
+		}
+
+		pullOptions := pull.PullOptions{
+			LicenseFile:         expectedLicenseFile,
+			Namespace:           namespace,
+			ConfigFile:          filepath.Join(tmpRoot, "upstream", "userdata", "config.yaml"),
+			AirgapRoot:          airgapRoot,
+			InstallationFile:    installationFilePath,
+			UpdateCursor:        beforeCursor,
+			RootDir:             tmpRoot,
+			ExcludeKotsKinds:    true,
+			ExcludeAdminConsole: true,
+			CreateAppDir:        false,
+			ReportWriter:        statusClient.getOutputWriter(),
+			RewriteImages:       true,
+			RewriteImageOptions: pull.RewriteImageOptions{
+				Host:      registryInfo.Host,
+				Namespace: registryInfo.Namespace,
+				Username:  registryInfo.Username,
+				Password:  registryInfo.Password,
+			},
+		}
+
+		if _, err := pull.Pull(fmt.Sprintf("replicated://%s", license.Spec.AppSlug), pullOptions); err != nil {
+			fmt.Printf("failed to pull upstream: %s\n", err.Error())
+			ffiResult = NewFFIResult(-1).WithError(err)
+			return
+		}
+
+		afterCursor, err := readCursorFromPath(installationFilePath)
+		if err != nil {
+			fmt.Printf("failed to read cursor file after update: %s\n", err.Error())
+			ffiResult = NewFFIResult(-1).WithError(err)
+			return
+		}
+
+		fmt.Printf("Result of checking for updates for %s: Before: %s, After %s\n", license.Spec.AppSlug, beforeCursor, afterCursor)
+
+		bc, err := cursor.NewCursor(string(beforeCursor))
+		if err != nil {
+			fmt.Printf("failed to parse current cursor: %s\n", err.Error())
+			ffiResult = NewFFIResult(-1).WithError(err)
+			return
+		}
+
+		ac, err := cursor.NewCursor(string(afterCursor))
+		if err != nil {
+			fmt.Printf("failed to parse update cursor: %s\n", err.Error())
+			ffiResult = NewFFIResult(-1).WithError(err)
+			return
+		}
+
+		if !bc.Comparable(ac) {
+			err := errors.Errorf("cannot compare %q and %q", beforeCursor, afterCursor)
+			fmt.Printf("%s\n", err.Error())
+			ffiResult = NewFFIResult(-1).WithError(err)
+			return
+		}
+
+		if !bc.Before(ac) {
+			ffiResult = NewFFIResult(0)
+			return
+		}
+
+		paths := []string{
+			filepath.Join(tmpRoot, "upstream"),
+			filepath.Join(tmpRoot, "base"),
+			filepath.Join(tmpRoot, "overlays"),
+		}
+
+		err = os.Remove(fromArchivePath)
+		if err != nil {
+			fmt.Printf("failed to delete archive to replace: %s\n", err.Error())
+			ffiResult = NewFFIResult(-1).WithError(err)
+			return
+		}
+
+		if err := tarGz.Archive(paths, fromArchivePath); err != nil {
+			fmt.Printf("failed to write archive: %s\n", err.Error())
+			ffiResult = NewFFIResult(-1).WithError(err)
+			return
+		}
+
+		ffiResult = NewFFIResult(1)
+	}()
+}
diff --git a/pkg/cursor/cursor.go b/pkg/cursor/cursor.go
new file mode 100644
index 000000000..27f397394
--- /dev/null
+++ b/pkg/cursor/cursor.go
@@ -0,0 +1,46 @@
+package cursor
+
+import (
+	"strconv"
+
+	"github.com/pkg/errors"
+)
+
+type Cursor interface {
+	Comparable(Cursor) bool
+	Equal(Cursor) bool
+	Before(Cursor) bool
+	After(Cursor) bool
+}
+
+type SequenceCursor struct {
+	cursor uint64
+}
+
+func NewCursor(s string) (Cursor, error) {
+	if c, err := strconv.ParseUint(s, 10, 64); err == nil {
+		return SequenceCursor{cursor: c}, nil
+	}
+	return nil, errors.Errorf("cannot use %q to construct cursor", s)
+}
+
+func (c SequenceCursor) Comparable(o Cursor) bool {
+	switch o.(type) {
+	case SequenceCursor, *SequenceCursor:
+		return true
+	default:
+		return false
+	}
+}
+
+func (c SequenceCursor) Equal(o Cursor) bool {
+	return c.cursor == o.(SequenceCursor).cursor
+}
+
+func (c SequenceCursor) Before(o Cursor) bool {
+	return c.cursor < o.(SequenceCursor).cursor
+}
+
+func (c SequenceCursor) After(o Cursor) bool {
+	return c.cursor > o.(SequenceCursor).cursor
+}
diff --git a/pkg/upstream/fetch.go b/pkg/upstream/fetch.go
index 26559e72c..63e73da16 100644
--- a/pkg/upstream/fetch.go
+++ b/pkg/upstream/fetch.go
@@ -56,7 +56,7 @@ func downloadUpstream(upstreamURI string, fetchOptions *FetchOptions) (*types.Up
 		return downloadHelm(u, fetchOptions.HelmRepoURI)
 	}
 	if u.Scheme == "replicated" {
-		return downloadReplicated(u, fetchOptions.LocalPath, fetchOptions.RootDir, fetchOptions.UseAppDir, fetchOptions.License, fetchOptions.ConfigValues, fetchOptions.CurrentCursor, pickVersionLabel(fetchOptions), cipher)
+		return downloadReplicated(u, fetchOptions.LocalPath, fetchOptions.RootDir, fetchOptions.UseAppDir, fetchOptions.License, fetchOptions.ConfigValues, pickCursor(fetchOptions), pickVersionLabel(fetchOptions), cipher)
 	}
 	if u.Scheme == "git" {
 		return downloadGit(upstreamURI)
@@ -74,3 +74,10 @@ func pickVersionLabel(fetchOptions *FetchOptions) string {
 	}
 	return fetchOptions.CurrentVersionLabel
 }
+
+func pickCursor(fetchOptions *FetchOptions) string {
+	if fetchOptions.Airgap != nil && fetchOptions.Airgap.Spec.UpdateCursor != "" {
+		return fetchOptions.Airgap.Spec.UpdateCursor
+	}
+	return fetchOptions.CurrentCursor
+}


Test patch:
diff --git a/pkg/cursor/cursor_test.go b/pkg/cursor/cursor_test.go
new file mode 100644
index 000000000..2aeed7f8b
--- /dev/null
+++ b/pkg/cursor/cursor_test.go
@@ -0,0 +1,77 @@
+package cursor
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func Test_Comparable(t *testing.T) {
+	tests := []struct {
+		name    string
+		c1      string
+		c1Error bool
+		c2      string
+		c2Error bool
+
+		isComparable bool
+		isEqual      bool
+		isBefore     bool
+		isAfter      bool
+	}{
+		{
+			name:         "comparable and equal",
+			c1:           "10",
+			c2:           "10",
+			isEqual:      true,
+			isComparable: true,
+		},
+		{
+			name:         "comparable and before",
+			c1:           "1",
+			c2:           "10",
+			isBefore:     true,
+			isComparable: true,
+		},
+		{
+			name:         "comparable and after",
+			c1:           "100",
+			c2:           "10",
+			isAfter:      true,
+			isComparable: true,
+		},
+		{
+			name:    "not valid cursor",
+			c1:      "1.0.4",
+			c2:      "10",
+			c1Error: true,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			c1, err := NewCursor(test.c1)
+			if test.c1Error {
+				assert.Error(t, err)
+			} else {
+				assert.NoError(t, err)
+			}
+
+			c2, err := NewCursor(test.c2)
+			if test.c2Error {
+				assert.Error(t, err)
+			} else {
+				assert.NoError(t, err)
+			}
+
+			if c1 == nil || c2 == nil {
+				return
+			}
+
+			assert.Equal(t, test.isComparable, c1.Comparable(c2))
+			assert.Equal(t, test.isEqual, c1.Equal(c2))
+			assert.Equal(t, test.isBefore, c1.Before(c2))
+			assert.Equal(t, test.isAfter, c1.After(c2))
+		})
+	}
+}