From 48decd7533925ac98b9cb61b54db3dc3431b7545 Mon Sep 17 00:00:00 2001 From: Salah Al Saleh Date: Mon, 11 Nov 2024 11:40:39 -0800 Subject: [PATCH] Ability to download the application archive of any version including the currently deployed one (#4998) * Ability to download the archive of any version including the currently deployed one --- .github/actions/cmx-versions/action.yaml | 2 +- .../kurl-addon-kots-publisher/action.yml | 2 +- .github/actions/version-tag/action.yml | 2 +- .github/workflows/build-test.yaml | 47 ++++++++ cmd/kots/cli/download.go | 8 ++ pkg/download/download.go | 8 ++ pkg/handlers/download.go | 114 ++++++++++++------ 7 files changed, 142 insertions(+), 41 deletions(-) diff --git a/.github/actions/cmx-versions/action.yaml b/.github/actions/cmx-versions/action.yaml index 91a1a79b30..e35c58253b 100644 --- a/.github/actions/cmx-versions/action.yaml +++ b/.github/actions/cmx-versions/action.yaml @@ -1,7 +1,7 @@ name: 'Get CMX Versions' description: 'Retrieves a list of the CMX versions to test against' runs: - using: 'node16' + using: 'node20' main: 'dist/index.js' inputs: diff --git a/.github/actions/kurl-addon-kots-publisher/action.yml b/.github/actions/kurl-addon-kots-publisher/action.yml index 6ae4fb9558..73c06a20eb 100644 --- a/.github/actions/kurl-addon-kots-publisher/action.yml +++ b/.github/actions/kurl-addon-kots-publisher/action.yml @@ -11,5 +11,5 @@ inputs: required: true description: 'GitHub token.' runs: - using: 'node16' + using: 'node20' main: 'dist/index.js' diff --git a/.github/actions/version-tag/action.yml b/.github/actions/version-tag/action.yml index 917b886b88..5d457eb432 100644 --- a/.github/actions/version-tag/action.yml +++ b/.github/actions/version-tag/action.yml @@ -4,5 +4,5 @@ outputs: GIT_TAG: description: 'Git tag if this is a tagged revision' runs: - using: 'node16' + using: 'node20' main: 'dist/index.js' \ No newline at end of file diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml index 88f9562666..8b01efe868 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build-test.yaml @@ -4186,6 +4186,53 @@ jobs: exit 1 fi + # ---- validate archives ---- # + + function validate_configmap_in_archive { + local expected_value="$1" + if ! grep -q "$expected_value" get-set-config/base/configmap.yaml; then + echo "expected base/configmap.yaml to contain $expected_value:" + cat get-set-config/base/configmap.yaml + exit 1 + fi + } + + # make latest different from current + ./bin/kots set config "$APP_SLUG" --key=username --value=latest-username --namespace "$APP_SLUG" + + # validate the archive for sequence 0 + ./bin/kots download --namespace "$APP_SLUG" --slug "$APP_SLUG" --sequence=0 --decrypt-password-values --overwrite + + validate_configmap_in_archive "username: ''" + validate_configmap_in_archive "password: ''" + validate_configmap_in_archive "email: ''" + validate_configmap_in_archive "sequence: '0'" + + # validate the archive for sequence 2 + ./bin/kots download --namespace "$APP_SLUG" --slug "$APP_SLUG" --sequence=2 --decrypt-password-values --overwrite + + validate_configmap_in_archive "username: 'example-username'" + validate_configmap_in_archive "password: 'example-password'" + validate_configmap_in_archive "email: ''" + validate_configmap_in_archive "sequence: '2'" + + # validate the archive for the currently deployed version + ./bin/kots download --namespace "$APP_SLUG" --slug "$APP_SLUG" --current --decrypt-password-values --overwrite + + validate_configmap_in_archive "username: 'updated-username'" + validate_configmap_in_archive "password: ''" + validate_configmap_in_archive "email: ''" + validate_configmap_in_archive "sequence: '5'" + + # validate the archive for the latest version + ./bin/kots download --namespace "$APP_SLUG" --slug "$APP_SLUG" --decrypt-password-values --overwrite + + validate_configmap_in_archive "username: 'latest-username'" + validate_configmap_in_archive "password: ''" + validate_configmap_in_archive "email: ''" + validate_configmap_in_archive "sequence: '6'" + + - name: Generate support bundle on failure if: failure() uses: ./.github/actions/generate-support-bundle diff --git a/cmd/kots/cli/download.go b/cmd/kots/cli/download.go index 0887d72dee..b7233628e8 100644 --- a/cmd/kots/cli/download.go +++ b/cmd/kots/cli/download.go @@ -43,6 +43,10 @@ func DownloadCmd() *cobra.Command { } } + if v.GetBool("current") && v.GetInt64("sequence") != -1 { + return errors.New("cannot use --current and --sequence together") + } + output := v.GetString("output") if output != "json" && output != "" { return errors.Errorf("output format %s not supported (allowed formats are: json)", output) @@ -58,6 +62,8 @@ func DownloadCmd() *cobra.Command { Overwrite: v.GetBool("overwrite"), Silent: output != "", DecryptPasswordValues: v.GetBool("decrypt-password-values"), + Current: v.GetBool("current"), + Sequence: v.GetInt64("sequence"), } var downloadOutput DownloadOutput @@ -97,6 +103,8 @@ func DownloadCmd() *cobra.Command { cmd.Flags().String("slug", "", "the application slug to download") cmd.Flags().Bool("decrypt-password-values", false, "decrypt password values to plaintext") cmd.Flags().StringP("output", "o", "", "output format (currently supported: json)") + cmd.Flags().Bool("current", false, "set to true to download the archive of the currently deployed app version") + cmd.Flags().Int64("sequence", -1, "sequence of the app version to download the archive for (defaults to the latest version unless --current flag is set)") return cmd } diff --git a/pkg/download/download.go b/pkg/download/download.go index 5fb9f94028..8afbd8a14a 100644 --- a/pkg/download/download.go +++ b/pkg/download/download.go @@ -20,6 +20,8 @@ type DownloadOptions struct { Overwrite bool Silent bool DecryptPasswordValues bool + Current bool + Sequence int64 } func Download(appSlug string, path string, downloadOptions DownloadOptions) error { @@ -69,6 +71,12 @@ func Download(appSlug string, path string, downloadOptions DownloadOptions) erro if downloadOptions.DecryptPasswordValues { url = fmt.Sprintf("%s&decryptPasswordValues=1", url) } + if downloadOptions.Current { + url = fmt.Sprintf("%s¤t=1", url) + } + if downloadOptions.Sequence != -1 { + url = fmt.Sprintf("%s&sequence=%d", url, downloadOptions.Sequence) + } newRequest, err := util.NewRequest("GET", url, nil) if err != nil { diff --git a/pkg/handlers/download.go b/pkg/handlers/download.go index 2ba9b09679..410ef5d79d 100644 --- a/pkg/handlers/download.go +++ b/pkg/handlers/download.go @@ -25,17 +25,17 @@ import ( // NOTE: this uses special kots token authorization func (h *Handler) DownloadApp(w http.ResponseWriter, r *http.Request) { if err := requireValidKOTSToken(w, r); err != nil { - logger.Error(err) + logger.Error(errors.Wrap(err, "failed to validate KOTS token")) return } a, err := store.GetStore().GetAppFromSlug(r.URL.Query().Get("slug")) if err != nil { - logger.Error(err) + logger.Error(errors.Wrap(err, "failed to get app from slug")) if store.GetStore().IsNotFound(err) { - w.WriteHeader(404) + w.WriteHeader(http.StatusNotFound) } else { - w.WriteHeader(500) + w.WriteHeader(http.StatusInternalServerError) } return } @@ -44,59 +44,97 @@ func (h *Handler) DownloadApp(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("decryptPasswordValues") != "" { decryptPasswordValues, err = strconv.ParseBool(r.URL.Query().Get("decryptPasswordValues")) if err != nil { - logger.Error(err) - w.WriteHeader(500) + logger.Error(errors.Wrap(err, "failed to parse decrypt password values param")) + w.WriteHeader(http.StatusInternalServerError) return } } - latestSequence, err := store.GetStore().GetLatestAppSequence(a.ID, true) - if err != nil { - logger.Error(err) - w.WriteHeader(500) - return + var sequence int64 + if r.URL.Query().Get("sequence") != "" { + s, err := strconv.ParseInt(r.URL.Query().Get("sequence"), 10, 64) + if err != nil { + logger.Error(errors.Wrap(err, "failed to parse sequence param")) + w.WriteHeader(http.StatusInternalServerError) + return + } + sequence = s + } else if r.URL.Query().Get("current") != "" { + // use the currently deployed version as the base + downstreams, err := store.GetStore().ListDownstreamsForApp(a.ID) + if err != nil { + logger.Error(errors.Wrap(err, "failed to list downstreams for app")) + w.WriteHeader(http.StatusInternalServerError) + return + } + if len(downstreams) == 0 { + logger.Error(errors.New("no downstreams found for app")) + w.WriteHeader(http.StatusInternalServerError) + return + } + currVersion, err := store.GetStore().GetCurrentDownstreamVersion(a.ID, downstreams[0].ClusterID) + if err != nil { + logger.Error(errors.Wrap(err, "failed to get current downstream version")) + w.WriteHeader(http.StatusInternalServerError) + return + } + if currVersion == nil { + logger.Error(errors.New("no current version found")) + w.WriteHeader(http.StatusInternalServerError) + return + } + sequence = currVersion.Sequence + } else { + // no sequence was specified, fall back to the latest + latestSequence, err := store.GetStore().GetLatestAppSequence(a.ID, true) + if err != nil { + logger.Error(errors.Wrap(err, "failed to get latest app sequence")) + w.WriteHeader(http.StatusInternalServerError) + return + } + sequence = latestSequence } - archivePath, err := ioutil.TempDir("", "kotsadm") + archivePath, err := os.MkdirTemp("", "kotsadm") if err != nil { - logger.Error(err) - w.WriteHeader(500) + logger.Error(errors.Wrap(err, "failed to create temp dir")) + w.WriteHeader(http.StatusInternalServerError) return } defer os.RemoveAll(archivePath) - err = store.GetStore().GetAppVersionArchive(a.ID, latestSequence, archivePath) + err = store.GetStore().GetAppVersionArchive(a.ID, sequence, archivePath) if err != nil { - logger.Error(err) - w.WriteHeader(500) + logger.Error(errors.Wrap(err, "failed to get app version archive")) + w.WriteHeader(http.StatusInternalServerError) return } if decryptPasswordValues { kotsKinds, err := kotsutil.LoadKotsKinds(archivePath) if err != nil { - logger.Error(err) - w.WriteHeader(500) + logger.Error(errors.Wrap(err, "failed to load kots kinds")) + w.WriteHeader(http.StatusInternalServerError) return } if kotsKinds.ConfigValues != nil { if err := kotsKinds.DecryptConfigValues(); err != nil { - logger.Error(err) - w.WriteHeader(500) + logger.Error(errors.Wrap(err, "failed to decrypt config values")) + w.WriteHeader(http.StatusInternalServerError) return } updated, err := kotsKinds.Marshal("kots.io", "v1beta1", "ConfigValues") if err != nil { - logger.Error(err) - w.WriteHeader(500) + logger.Error(errors.Wrap(err, "failed to marshal config values")) + w.WriteHeader(http.StatusInternalServerError) return } - if err := ioutil.WriteFile(filepath.Join(archivePath, "upstream", "userdata", "config.yaml"), []byte(updated), 0644); err != nil { - logger.Error(err) - w.WriteHeader(500) + if err := os.WriteFile(filepath.Join(archivePath, "upstream", "userdata", "config.yaml"), []byte(updated), 0644); err != nil { + logger.Error(errors.Wrap(err, "failed to write config values file")) + w.WriteHeader(http.StatusInternalServerError) return } } @@ -133,16 +171,16 @@ func (h *Handler) DownloadApp(w http.ResponseWriter, r *http.Request) { tmpDir, err := ioutil.TempDir("", "kotsadm") if err != nil { - logger.Error(err) - w.WriteHeader(500) + logger.Error(errors.Wrap(err, "failed to create temp dir")) + w.WriteHeader(http.StatusInternalServerError) return } defer os.RemoveAll(tmpDir) fileToSend := filepath.Join(tmpDir, "archive.tar.gz") if err != nil { - logger.Error(err) - w.WriteHeader(500) + logger.Error(errors.Wrap(err, "failed to process temp dir")) + w.WriteHeader(http.StatusInternalServerError) return } @@ -152,33 +190,33 @@ func (h *Handler) DownloadApp(w http.ResponseWriter, r *http.Request) { }, } if err := tarGz.Archive(paths, fileToSend); err != nil { - logger.Error(err) - w.WriteHeader(500) + logger.Error(errors.Wrap(err, "failed to create archive")) + w.WriteHeader(http.StatusInternalServerError) return } fi, err := os.Stat(fileToSend) if err != nil { - logger.Error(err) - w.WriteHeader(500) + logger.Error(errors.Wrap(err, "failed to stat archive file")) + w.WriteHeader(http.StatusInternalServerError) return } f, err := os.Open(fileToSend) if err != nil { - logger.Error(err) - w.WriteHeader(500) + logger.Error(errors.Wrap(err, "failed to open archive file")) + w.WriteHeader(http.StatusInternalServerError) return } w.Header().Set("Content-Disposition", "attachment; filename=archive.tar.gz") w.Header().Set("Content-Type", "application/gzip") w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10)) - w.WriteHeader(200) + w.WriteHeader(http.StatusOK) _, err = io.Copy(w, f) if err != nil { - logger.Error(err) + logger.Error(errors.Wrap(err, "failed to send archive file")) } }