diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml index 19ae937023..abab0b18f8 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build-test.yaml @@ -1939,13 +1939,6 @@ jobs: exit $EXIT_CODE fi - # validate that preflight checks ran - JSON_PATH="jsonpath={.data['automated-install-slug-$APP_SLUG']}" - if [ "$(kubectl get cm kotsadm-tasks -n "$APP_SLUG" -o "$JSON_PATH" | grep -c pending_preflight)" != "1" ]; then - echo "Preflight checks did not run" - exit 1 - fi - COUNTER=1 while [ "$(./bin/kots get apps --namespace "$APP_SLUG" | awk 'NR>1{print $2}')" != "ready" ]; do ((COUNTER += 1)) diff --git a/cmd/kots/cli/airgap-update.go b/cmd/kots/cli/airgap-update.go index 3425532ae5..2d28e5bc7d 100644 --- a/cmd/kots/cli/airgap-update.go +++ b/cmd/kots/cli/airgap-update.go @@ -17,6 +17,7 @@ import ( "github.com/replicatedhq/kots/pkg/image" imagetypes "github.com/replicatedhq/kots/pkg/image/types" "github.com/replicatedhq/kots/pkg/k8sutil" + "github.com/replicatedhq/kots/pkg/kotsutil" "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/tasks" "github.com/replicatedhq/kots/pkg/upload" @@ -88,14 +89,10 @@ func AirgapUpdateCmd() *cobra.Command { return errors.Wrap(err, "failed to stat airgap bundle") } - updateFiles := []string{ - "airgap.yaml", - "app.tar.gz", - } - if util.IsEmbeddedCluster() { - updateFiles = append(updateFiles, "embedded-cluster/artifacts/kots.tar.gz") + updateFiles, err := getAirgapUpdateFiles(airgapBundle) + if err != nil { + return errors.Wrap(err, "failed to get airgap update files") } - airgapUpdate, err := archives.FilterAirgapBundle(airgapBundle, updateFiles) if err != nil { return errors.Wrap(err, "failed to create filtered airgap bundle") @@ -170,6 +167,34 @@ func getProgressWriter(v *viper.Viper, log *logger.CLILogger) io.Writer { return os.Stdout } +func getAirgapUpdateFiles(airgapBundle string) ([]string, error) { + airgap, err := kotsutil.FindAirgapMetaInBundle(airgapBundle) + if err != nil { + return nil, errors.Wrap(err, "failed to find airgap meta in bundle") + } + + if airgap.Spec.EmbeddedClusterArtifacts == nil { + return nil, errors.New("embedded cluster artifacts not found in airgap bundle") + } + + if airgap.Spec.EmbeddedClusterArtifacts.Metadata == "" { + return nil, errors.New("embedded cluster metadata not found in airgap bundle") + } + + if airgap.Spec.EmbeddedClusterArtifacts.AdditionalArtifacts == nil { + return nil, errors.New("embedded cluster additional artifacts not found in airgap bundle") + } + + files := []string{ + "airgap.yaml", + "app.tar.gz", + airgap.Spec.EmbeddedClusterArtifacts.Metadata, + airgap.Spec.EmbeddedClusterArtifacts.AdditionalArtifacts["kots"], + } + + return files, nil +} + func uploadAirgapUpdate(airgapBundle string, uploadEndpoint string, namespace string) error { buffer := bytes.NewBuffer(nil) writer := multipart.NewWriter(buffer) diff --git a/cmd/kots/cli/version.go b/cmd/kots/cli/version.go index b23d265e92..ff3d682063 100644 --- a/cmd/kots/cli/version.go +++ b/cmd/kots/cli/version.go @@ -29,22 +29,18 @@ func VersionCmd() *cobra.Command { output := v.GetString("output") + isLatest, latestVer, err := buildversion.IsLatestRelease() versionOutput := VersionOutput{ Version: buildversion.Version(), } - if !v.GetBool("skip-checks") { - isLatest, latestVer, err := buildversion.IsLatestRelease() - if err == nil && !isLatest { - versionOutput.LatestVersion = latestVer - versionOutput.InstallLatest = "curl https://kots.io/install | bash" - } + if err == nil && !isLatest { + versionOutput.LatestVersion = latestVer + versionOutput.InstallLatest = "curl https://kots.io/install | bash" } if output != "json" && output != "" { return errors.Errorf("output format %s not supported (allowed formats are: json)", output) - } - - if output == "json" { + } else if output == "json" { // marshal JSON outputJSON, err := json.Marshal(versionOutput) if err != nil { @@ -66,8 +62,6 @@ func VersionCmd() *cobra.Command { } cmd.Flags().StringP("output", "o", "", "output format (currently supported: json)") - cmd.Flags().Bool("skip-checks", false, "skip any checks and just print the version") - cmd.Flags().MarkHidden("skip-checks") return cmd } diff --git a/pkg/airgap/update.go b/pkg/airgap/update.go index b1719d551f..35986a9863 100644 --- a/pkg/airgap/update.go +++ b/pkg/airgap/update.go @@ -38,7 +38,7 @@ func UpdateAppFromECBundle(appSlug string, airgapBundlePath string) (finalError finishedChan <- finalError }() - kotsBin, err := archives.GetKOTSBinFromAirgapBundle(airgapBundlePath) + kotsBin, err := kotsutil.GetKOTSBinFromAirgapBundle(airgapBundlePath) if err != nil { return errors.Wrap(err, "failed to get kots binary from airgap bundle") } diff --git a/pkg/api/handlers/types/types.go b/pkg/api/handlers/types/types.go index 933e1a0e01..4db3ee406f 100644 --- a/pkg/api/handlers/types/types.go +++ b/pkg/api/handlers/types/types.go @@ -82,8 +82,6 @@ type ResponseGitOps struct { type ResponseCluster struct { ID string `json:"id"` Slug string `json:"slug"` - // IsUpgrading represents whether the embedded cluster is currently being upgraded - IsUpgrading bool `json:"isUpgrading"` // State represents the current state of the most recently deployed embedded cluster config State string `json:"state,omitempty"` } diff --git a/pkg/archives/airgap.go b/pkg/archives/airgap.go index 41afcfd4d0..6515efad43 100644 --- a/pkg/archives/airgap.go +++ b/pkg/archives/airgap.go @@ -94,20 +94,3 @@ func FilterAirgapBundle(airgapBundle string, filesToKeep []string) (string, erro return f.Name(), nil } - -func GetKOTSBinFromAirgapBundle(airgapBundle string) (string, error) { - kotsTGZ, err := GetFileFromTGZArchive("embedded-cluster/artifacts/kots.tar.gz", airgapBundle) - if err != nil { - return "", errors.Wrap(err, "failed to get kots tarball from airgap bundle") - } - defer os.Remove(kotsTGZ) - - kotsBin, err := GetFileFromTGZArchive("kots", kotsTGZ) - if err != nil { - return "", errors.Wrap(err, "failed to get kots binary from kots tarball") - } - if err := os.Chmod(kotsBin, 0755); err != nil { - return "", errors.Wrap(err, "failed to chmod kots binary") - } - return kotsBin, nil -} diff --git a/pkg/buildversion/buildversion.go b/pkg/buildversion/buildversion.go index a40334d4fa..25ca4911c7 100644 --- a/pkg/buildversion/buildversion.go +++ b/pkg/buildversion/buildversion.go @@ -5,7 +5,6 @@ import ( "io/ioutil" "net/http" "runtime" - "strings" "time" semver "github.com/Masterminds/semver/v3" @@ -85,10 +84,6 @@ func GetUserAgent() string { return fmt.Sprintf("KOTS/%s", Version()) } -func IsSameVersion(version string) bool { - return strings.TrimPrefix(Version(), "v") == strings.TrimPrefix(version, "v") -} - // IsLatestRelease queries github for the latest release in the project repo. If that release has a semver greater // than the current release, it returns false and the new latest release semver. Otherwise, it returns true or error func IsLatestRelease() (bool, string, error) { diff --git a/pkg/embeddedcluster/util.go b/pkg/embeddedcluster/util.go index c55e7534ab..8a7ece4667 100644 --- a/pkg/embeddedcluster/util.go +++ b/pkg/embeddedcluster/util.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "sort" - "time" embeddedclusterv1beta1 "github.com/replicatedhq/embedded-cluster-kinds/apis/v1beta1" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" @@ -77,27 +76,18 @@ func ListInstallations(ctx context.Context, kbClient kbclient.Client) ([]embedde return installationList.Items, nil } -// WaitForInstallation will block until the current installation object reaches a terminal state. -func WaitForInstallation(ctx context.Context, kbClient kbclient.Client) error { - for { - select { - case <-ctx.Done(): - return fmt.Errorf("context cancelled") - default: - ins, err := GetCurrentInstallation(ctx, kbClient) - if err != nil { - return fmt.Errorf("failed to get current installation: %w", err) - } - switch ins.Status.State { - case embeddedclusterv1beta1.InstallationStateInstalled, - embeddedclusterv1beta1.InstallationStateObsolete, - embeddedclusterv1beta1.InstallationStateFailed, - embeddedclusterv1beta1.InstallationStateHelmChartUpdateFailure: - return nil - } - time.Sleep(5 * time.Second) - } +func InstallationSucceeded(ctx context.Context, ins *embeddedclusterv1beta1.Installation) bool { + return ins.Status.State == embeddedclusterv1beta1.InstallationStateInstalled +} + +func InstallationFailed(ctx context.Context, ins *embeddedclusterv1beta1.Installation) bool { + switch ins.Status.State { + case embeddedclusterv1beta1.InstallationStateFailed, + embeddedclusterv1beta1.InstallationStateHelmChartUpdateFailure, + embeddedclusterv1beta1.InstallationStateObsolete: + return true } + return false } // ClusterConfig will extract the current cluster configuration from the latest installation diff --git a/pkg/handlers/app.go b/pkg/handlers/app.go index 707bffd0c0..d351a68a58 100644 --- a/pkg/handlers/app.go +++ b/pkg/handlers/app.go @@ -25,7 +25,6 @@ import ( storetypes "github.com/replicatedhq/kots/pkg/store/types" "github.com/replicatedhq/kots/pkg/tasks" "github.com/replicatedhq/kots/pkg/update" - upgradeservicedeploy "github.com/replicatedhq/kots/pkg/upgradeservice/deploy" "github.com/replicatedhq/kots/pkg/util" "github.com/replicatedhq/kots/pkg/version" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" @@ -278,14 +277,7 @@ func responseAppFromApp(ctx context.Context, a *apptypes.App) (*types.ResponseAp ID: d.ClusterID, Slug: d.ClusterSlug, } - if util.IsEmbeddedCluster() { - isUpgrading, err := upgradeservicedeploy.IsClusterUpgrading(ctx, a.Slug) - if err != nil { - return nil, errors.Wrap(err, "failed to check if cluster is upgrading") - } - cluster.IsUpgrading = isUpgrading - kbClient, err := k8sutil.GetKubeClient(ctx) if err != nil { return nil, fmt.Errorf("failed to get kubeclient: %w", err) diff --git a/pkg/handlers/dashboard.go b/pkg/handlers/dashboard.go index 029e248bbf..f3c0b3ec0a 100644 --- a/pkg/handlers/dashboard.go +++ b/pkg/handlers/dashboard.go @@ -11,6 +11,7 @@ import ( "github.com/replicatedhq/kots/pkg/k8sutil" "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/store" + "github.com/replicatedhq/kots/pkg/util" "github.com/replicatedhq/kots/pkg/version" ) @@ -39,20 +40,6 @@ func (h *Handler) GetAppDashboard(w http.ResponseWriter, r *http.Request) { return } - kbClient, err := k8sutil.GetKubeClient(r.Context()) - if err != nil { - logger.Error(err) - w.WriteHeader(500) - return - } - - ecInstallation, err := embeddedcluster.GetCurrentInstallation(r.Context(), kbClient) - if err != nil { - logger.Error(err) - w.WriteHeader(500) - return - } - parentSequence, err := store.GetStore().GetCurrentParentSequence(a.ID, clusterID) if err != nil { logger.Error(err) @@ -79,11 +66,28 @@ func (h *Handler) GetAppDashboard(w http.ResponseWriter, r *http.Request) { metrics = version.GetMetricCharts(graphs, prometheusAddress) } + embeddedClusterState := "" + if util.IsEmbeddedCluster() { + kbClient, err := k8sutil.GetKubeClient(r.Context()) + if err != nil { + logger.Error(err) + w.WriteHeader(500) + return + } + ecInstallation, err := embeddedcluster.GetCurrentInstallation(r.Context(), kbClient) + if err != nil { + logger.Error(err) + w.WriteHeader(500) + return + } + embeddedClusterState = ecInstallation.Status.State + } + getAppDashboardResponse := GetAppDashboardResponse{ AppStatus: appStatus, Metrics: metrics, PrometheusAddress: prometheusAddress, - EmbeddedClusterState: ecInstallation.Status.State, + EmbeddedClusterState: embeddedClusterState, } JSON(w, 200, getAppDashboardResponse) diff --git a/pkg/handlers/upgrade_service.go b/pkg/handlers/upgrade_service.go index dbc728f249..4ae3af9aff 100644 --- a/pkg/handlers/upgrade_service.go +++ b/pkg/handlers/upgrade_service.go @@ -9,8 +9,6 @@ import ( "github.com/phayes/freeport" "github.com/pkg/errors" apptypes "github.com/replicatedhq/kots/pkg/app/types" - "github.com/replicatedhq/kots/pkg/archives" - "github.com/replicatedhq/kots/pkg/buildversion" "github.com/replicatedhq/kots/pkg/kotsutil" "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/replicatedapp" @@ -21,6 +19,7 @@ import ( "github.com/replicatedhq/kots/pkg/upgradeservice" upgradeservicetask "github.com/replicatedhq/kots/pkg/upgradeservice/task" upgradeservicetypes "github.com/replicatedhq/kots/pkg/upgradeservice/types" + "github.com/replicatedhq/kots/pkg/util" ) type StartUpgradeServiceRequest struct { @@ -212,7 +211,7 @@ func getUpgradeServiceParams(a *apptypes.App, r StartUpgradeServiceRequest) (*up return nil, errors.Wrap(err, "failed to parse app license") } - var updateKOTSVersion string + var updateECVersion string var updateKOTSBin string var updateAirgapBundle string @@ -222,32 +221,27 @@ func getUpgradeServiceParams(a *apptypes.App, r StartUpgradeServiceRequest) (*up return nil, errors.Wrap(err, "failed to get airgap update") } updateAirgapBundle = au - kb, err := archives.GetKOTSBinFromAirgapBundle(au) + kb, err := kotsutil.GetKOTSBinFromAirgapBundle(au) if err != nil { return nil, errors.Wrap(err, "failed to get kots binary from airgap bundle") } updateKOTSBin = kb - kv, err := kotsutil.GetKOTSVersionFromBinary(kb) + ecv, err := kotsutil.GetECVersionFromAirgapBundle(au) if err != nil { return nil, errors.Wrap(err, "failed to get kots version from binary") } - updateKOTSVersion = kv + updateECVersion = ecv } else { - kv, err := replicatedapp.GetKOTSVersionForRelease(license, r.VersionLabel) + kb, err := replicatedapp.DownloadKOTSBinary(license, r.VersionLabel) if err != nil { - return nil, errors.Wrap(err, "failed to get kots version for release") + return nil, errors.Wrap(err, "failed to download kots binary") } - updateKOTSVersion = kv - - if buildversion.IsSameVersion(kv) { - updateKOTSBin = kotsutil.GetKOTSBinPath() - } else { - kb, err := replicatedapp.DownloadKOTSBinary(license, r.VersionLabel) - if err != nil { - return nil, errors.Wrap(err, "failed to download kots binary") - } - updateKOTSBin = kb + updateKOTSBin = kb + ecv, err := replicatedapp.GetECVersionForRelease(license, r.VersionLabel) + if err != nil { + return nil, errors.Wrap(err, "failed to get kots version for release") } + updateECVersion = ecv } port, err := freeport.GetFreePort() @@ -273,11 +267,11 @@ func getUpgradeServiceParams(a *apptypes.App, r StartUpgradeServiceRequest) (*up UpdateVersionLabel: r.VersionLabel, UpdateCursor: r.UpdateCursor, UpdateChannelID: r.ChannelID, + UpdateECVersion: updateECVersion, + UpdateKOTSBin: updateKOTSBin, UpdateAirgapBundle: updateAirgapBundle, - CurrentKOTSVersion: buildversion.Version(), - UpdateKOTSVersion: updateKOTSVersion, - UpdateKOTSBin: updateKOTSBin, + CurrentECVersion: util.EmbeddedClusterVersion(), RegistryEndpoint: registrySettings.Hostname, RegistryUsername: registrySettings.Username, diff --git a/pkg/k8sutil/deployment.go b/pkg/k8sutil/deployment.go index e25a166a3e..251f652c4e 100644 --- a/pkg/k8sutil/deployment.go +++ b/pkg/k8sutil/deployment.go @@ -30,7 +30,7 @@ func WaitForDeploymentReady(ctx context.Context, clientset kubernetes.Interface, time.Sleep(time.Second) - if time.Now().Sub(start) > timeout { + if time.Since(start) > timeout { return &types.ErrorTimeout{Message: "timeout waiting for deployment to become ready"} } } diff --git a/pkg/kotsutil/kots.go b/pkg/kotsutil/kots.go index fb6c7b20ea..242bc0e54f 100644 --- a/pkg/kotsutil/kots.go +++ b/pkg/kotsutil/kots.go @@ -9,7 +9,6 @@ import ( "encoding/json" "fmt" "os" - "os/exec" "path" "path/filepath" "strconv" @@ -1531,16 +1530,71 @@ func SaveInstallation(installation *kotsv1beta1.Installation, upstreamDir string return nil } -func GetKOTSVersionFromBinary(kotsBin string) (string, error) { - output, err := exec.Command(kotsBin, "version", "--skip-checks", "-ojson").Output() +func GetKOTSBinFromAirgapBundle(airgapBundle string) (string, error) { + airgap, err := FindAirgapMetaInBundle(airgapBundle) if err != nil { - return "", errors.Wrap(err, "failed to get kots version") + return "", errors.Wrap(err, "failed to find airgap meta in bundle") } - var v struct { - Version string `json:"version"` + if airgap.Spec.EmbeddedClusterArtifacts == nil { + return "", errors.New("airgap bundle does not contain embedded cluster artifacts") } - if err := json.Unmarshal(output, &v); err != nil { - return "", errors.Wrap(err, "failed to unmarshal kots version") + if airgap.Spec.EmbeddedClusterArtifacts.AdditionalArtifacts == nil { + return "", errors.New("airgap bundle does not contain additional embedded cluster artifacts") } - return v.Version, nil + + location, ok := airgap.Spec.EmbeddedClusterArtifacts.AdditionalArtifacts["kots"] + if !ok { + return "", errors.New("airgap bundle does not contain kots binary") + } + + kotsTGZ, err := archives.GetFileFromTGZArchive(location, airgapBundle) + if err != nil { + return "", errors.Wrap(err, "failed to get kots tarball from airgap bundle") + } + defer os.Remove(kotsTGZ) + + kotsBin, err := archives.GetFileFromTGZArchive("kots", kotsTGZ) + if err != nil { + return "", errors.Wrap(err, "failed to get kots binary from kots tarball") + } + if err := os.Chmod(kotsBin, 0755); err != nil { + return "", errors.Wrap(err, "failed to chmod kots binary") + } + return kotsBin, nil +} + +func GetECVersionFromAirgapBundle(airgapBundle string) (string, error) { + airgap, err := FindAirgapMetaInBundle(airgapBundle) + if err != nil { + return "", errors.Wrap(err, "failed to find airgap meta in bundle") + } + if airgap.Spec.EmbeddedClusterArtifacts == nil { + return "", errors.New("airgap bundle does not contain embedded cluster artifacts") + } + if airgap.Spec.EmbeddedClusterArtifacts.Metadata == "" { + return "", errors.New("airgap bundle does not contain metadata") + } + + metadataContent, err := archives.GetFileContentFromTGZArchive(airgap.Spec.EmbeddedClusterArtifacts.Metadata, airgapBundle) + if err != nil { + return "", errors.Wrap(err, "failed to get embedded cluster metadata from airgap bundle") + } + + // use minimal/generic struct to avoid schema mismatches + type ecMetadata struct { + Versions map[string]string + } + var meta ecMetadata + if err := json.Unmarshal(metadataContent, &meta); err != nil { + return "", errors.Wrap(err, "failed to unmarshal embedded cluster metadata") + } + if meta.Versions == nil { + return "", errors.New("versions not found in embedded cluster metadata") + } + + ecVersion, ok := meta.Versions["Installer"] + if !ok { + return "", errors.New("installer version not found in embedded cluster metadata") + } + return ecVersion, nil } diff --git a/pkg/operator/operator.go b/pkg/operator/operator.go index e81aa5dd03..de011fdaa2 100644 --- a/pkg/operator/operator.go +++ b/pkg/operator/operator.go @@ -18,7 +18,6 @@ import ( "github.com/replicatedhq/kots/pkg/apparchive" appstatetypes "github.com/replicatedhq/kots/pkg/appstate/types" "github.com/replicatedhq/kots/pkg/binaries" - "github.com/replicatedhq/kots/pkg/buildversion" "github.com/replicatedhq/kots/pkg/embeddedcluster" "github.com/replicatedhq/kots/pkg/filestore" identitydeploy "github.com/replicatedhq/kots/pkg/identity/deploy" @@ -41,6 +40,7 @@ import ( supportbundletypes "github.com/replicatedhq/kots/pkg/supportbundle/types" "github.com/replicatedhq/kots/pkg/template" "github.com/replicatedhq/kots/pkg/update" + upgradeservicetask "github.com/replicatedhq/kots/pkg/upgradeservice/task" "github.com/replicatedhq/kots/pkg/util" "github.com/replicatedhq/kotskinds/multitype" velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" @@ -929,32 +929,42 @@ func (o *Operator) watchDeployments() { go cmInformer.Run(context.Background().Done()) } -func (o *Operator) reconcileDeployment(cm *corev1.ConfigMap) error { - // CAUTION: changes to the kots version field can break backwards compatibility - kotsVersion := cm.Data["kots-version"] - if kotsVersion == "" { - return errors.New("kots version not found in deployment configmap") +func (o *Operator) reconcileDeployment(cm *corev1.ConfigMap) (finalError error) { + if cm.Data["requires-cluster-upgrade"] == "true" { + // wait for cluster upgrade even if the embedded cluster version doesn't match yet + // in order to continuously report progress to the user + if err := o.waitForClusterUpgrade(cm.Data["app-slug"]); err != nil { + return errors.Wrap(err, "failed to wait for cluster upgrade") + } + } + + // CAUTION: changes to the embedded cluster version field can break backwards compatibility + ecVersion := cm.Data["embedded-cluster-version"] + if ecVersion == "" { + return errors.New("embedded cluster version not found in deployment") } - if !buildversion.IsSameVersion(kotsVersion) { - logger.Infof("deployment has kots version (%s) which does not match current kots version (%s). will not reconcile...", kotsVersion, buildversion.Version()) + if ecVersion != util.EmbeddedClusterVersion() { + logger.Infof("deployment has embedded cluster version (%s) which does not match current embedded cluster version (%s). will not process...", ecVersion, util.EmbeddedClusterVersion()) return nil } - logger.Infof("reconciling deployment (%s) for app (%s)", cm.Data["version-label"], cm.Data["app-slug"]) + logger.Infof("processing deployment (%s) for app (%s)", cm.Data["version-label"], cm.Data["app-slug"]) - if cm.Data["requires-cluster-upgrade"] == "true" { - kbClient, err := k8sutil.GetKubeClient(context.Background()) - if err != nil { - return errors.Wrap(err, "failed to get kube client") - } - logger.Infof("waiting for cluster upgrade to finish") - if err := embeddedcluster.WaitForInstallation(context.Background(), kbClient); err != nil { - return errors.Wrap(err, "failed to wait for embedded cluster installation") - } + if err := upgradeservicetask.SetStatusUpgradingApp(cm.Data["app-slug"], ""); err != nil { + return errors.Wrap(err, "failed to set task status to upgrading app") } - // ensure deployment gets processed once defer func() { + if finalError == nil { + if err := upgradeservicetask.ClearStatus(cm.Data["app-slug"]); err != nil { + logger.Error(errors.Wrap(err, "failed to clear task status")) + } + } else { + if err := upgradeservicetask.SetStatusUpgradeFailed(cm.Data["app-slug"], finalError.Error()); err != nil { + logger.Error(errors.Wrap(err, "failed to set task status to failed")) + } + } + // ensure deployment gets processed once cm.Labels["kots.io/processed"] = "true" _, err := o.k8sClientset.CoreV1().ConfigMaps(cm.Namespace).Update(context.Background(), cm, metav1.UpdateOptions{}) if err != nil { @@ -1024,3 +1034,30 @@ func (o *Operator) reconcileDeployment(cm *corev1.ConfigMap) error { return nil } + +func (o *Operator) waitForClusterUpgrade(appSlug string) error { + kbClient, err := k8sutil.GetKubeClient(context.Background()) + if err != nil { + return errors.Wrap(err, "failed to get kube client") + } + logger.Infof("waiting for cluster upgrade to finish") + for { + ins, err := embeddedcluster.GetCurrentInstallation(context.Background(), kbClient) + if err != nil { + return errors.Wrap(err, "failed to wait for embedded cluster installation") + } + if embeddedcluster.InstallationSucceeded(context.Background(), ins) { + return nil + } + if embeddedcluster.InstallationFailed(context.Background(), ins) { + if err := upgradeservicetask.SetStatusUpgradeFailed(appSlug, ins.Status.Reason); err != nil { + return errors.Wrap(err, "failed to set task status to failed") + } + return nil // we try to deploy the app even if the cluster upgrade failed + } + if err := upgradeservicetask.SetStatusUpgradingCluster(appSlug, ins.Status.State); err != nil { + return errors.Wrap(err, "failed to set task status to upgrading cluster") + } + time.Sleep(5 * time.Second) + } +} diff --git a/pkg/replicatedapp/embeddedcluster.go b/pkg/replicatedapp/embeddedcluster.go index e7e939fab4..543683ab25 100644 --- a/pkg/replicatedapp/embeddedcluster.go +++ b/pkg/replicatedapp/embeddedcluster.go @@ -8,15 +8,14 @@ import ( "io" "net/http" "os" - "strings" "github.com/pkg/errors" "github.com/replicatedhq/kots/pkg/util" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" ) -func GetKOTSVersionForRelease(license *kotsv1beta1.License, versionLabel string) (string, error) { - url := fmt.Sprintf("%s/clusterconfig/version/AdminConsole?versionLabel=%s", license.Spec.Endpoint, versionLabel) +func GetECVersionForRelease(license *kotsv1beta1.License, versionLabel string) (string, error) { + url := fmt.Sprintf("%s/clusterconfig/version/Installer?versionLabel=%s", license.Spec.Endpoint, versionLabel) req, err := util.NewRequest("GET", url, nil) if err != nil { return "", errors.Wrap(err, "failed to call newrequest") @@ -46,9 +45,7 @@ func GetKOTSVersionForRelease(license *kotsv1beta1.License, versionLabel string) return "", errors.Wrap(err, "failed to unmarshal response") } - // strip off the build number - version := strings.Split(response.Version, "-build")[0] - return version, nil + return response.Version, nil } func DownloadKOTSBinary(license *kotsv1beta1.License, versionLabel string) (string, error) { diff --git a/pkg/store/kotsstore/migrations.go b/pkg/store/kotsstore/migrations.go index a48f2cb94e..9cd1057d59 100644 --- a/pkg/store/kotsstore/migrations.go +++ b/pkg/store/kotsstore/migrations.go @@ -9,7 +9,6 @@ import ( "github.com/replicatedhq/kots/pkg/kotsutil" "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/persistence" - "github.com/replicatedhq/kots/pkg/tasks" "github.com/rqlite/gorqlite" ) @@ -49,9 +48,6 @@ func (s *KOTSStore) RunMigrations() { if err := s.migrateSupportBundlesFromRqlite(); err != nil { logger.Error(errors.Wrap(err, "failed to migrate support bundles")) } - if err := tasks.MigrateTasksFromRqlite(); err != nil { - logger.Error(errors.Wrap(err, "failed to migrate tasks")) - } } func (s *KOTSStore) migrateKotsAppSpec() error { diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index 420b00009c..be897e93a4 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -1,30 +1,16 @@ package tasks import ( - "context" - "encoding/json" "fmt" - "os" "time" - "github.com/gofrs/flock" "github.com/pkg/errors" - "github.com/replicatedhq/kots/pkg/k8sutil" - kotsadmtypes "github.com/replicatedhq/kots/pkg/kotsadm/types" "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/persistence" "github.com/replicatedhq/kots/pkg/util" "github.com/rqlite/gorqlite" - corev1 "k8s.io/api/core/v1" - kuberneteserrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -const tasksConfigMapName = "kotsadm-tasks" - -// use a file lock instead of a mutex as tasks are shared with the upgrade service which runs in a separate process -var tasksLock = flock.New(os.TempDir() + "/kotsadm-tasks.lock") - type TaskStatus struct { Message string `json:"message"` Status string `json:"status"` @@ -64,231 +50,94 @@ func StartTaskMonitor(taskID string, finishedChan <-chan error) { }() } -func SetTaskStatus(id string, message string, status string) error { - tasksLock.Lock() - defer tasksLock.Unlock() - - configmap, err := getConfigmap() - if err != nil { - return errors.Wrap(err, "failed to get task status configmap") - } +func StartTicker(taskID string, finishedChan <-chan struct{}) { + go func() { + for { + select { + case <-time.After(time.Second * 2): + if err := UpdateTaskStatusTimestamp(taskID); err != nil { + logger.Error(err) + } + case <-finishedChan: + return + } + } + }() +} - if configmap.Data == nil { - configmap.Data = map[string]string{} - } +func SetTaskStatus(id string, message string, status string) error { + db := persistence.MustGetDBSession() - b, err := json.Marshal(TaskStatus{ - Message: message, - Status: status, - UpdatedAt: time.Now(), + query := ` +INSERT INTO api_task_status (id, updated_at, current_message, status) +VALUES (?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE SET + updated_at = EXCLUDED.updated_at, + current_message = EXCLUDED.current_message, + status = EXCLUDED.status +` + wr, err := db.WriteOneParameterized(gorqlite.ParameterizedStatement{ + Query: query, + Arguments: []interface{}{id, time.Now().Unix(), message, status}, }) if err != nil { - return errors.Wrap(err, "failed to marshal task status") - } - - configmap.Data[id] = string(b) - - if err := updateConfigmap(configmap); err != nil { - return errors.Wrap(err, "failed to update task status configmap") + return fmt.Errorf("failed to write: %v: %v", err, wr.Err) } return nil } func UpdateTaskStatusTimestamp(id string) error { - tasksLock.Lock() - defer tasksLock.Unlock() - - configmap, err := getConfigmap() - if err != nil { - return errors.Wrap(err, "failed to get task status configmap") - } - - if configmap.Data == nil { - configmap.Data = map[string]string{} - } - - data, ok := configmap.Data[id] - if !ok { - return nil - } - - ts := TaskStatus{} - if err := json.Unmarshal([]byte(data), &ts); err != nil { - return errors.Wrap(err, "failed to unmarshal task status") - } - ts.UpdatedAt = time.Now() + db := persistence.MustGetDBSession() - b, err := json.Marshal(ts) + query := `UPDATE api_task_status SET updated_at = ? WHERE id = ?` + wr, err := db.WriteOneParameterized(gorqlite.ParameterizedStatement{ + Query: query, + Arguments: []interface{}{time.Now().Unix(), id}, + }) if err != nil { - return errors.Wrap(err, "failed to marshal task status") - } - - configmap.Data[id] = string(b) - - if err := updateConfigmap(configmap); err != nil { - return errors.Wrap(err, "failed to update task status configmap") + return fmt.Errorf("failed to write: %v: %v", err, wr.Err) } return nil } func ClearTaskStatus(id string) error { - tasksLock.Lock() - defer tasksLock.Unlock() + db := persistence.MustGetDBSession() - configmap, err := getConfigmap() + query := `DELETE FROM api_task_status WHERE id = ?` + wr, err := db.WriteOneParameterized(gorqlite.ParameterizedStatement{ + Query: query, + Arguments: []interface{}{id}, + }) if err != nil { - return errors.Wrap(err, "failed to get task status configmap") - } - - if configmap.Data == nil { - configmap.Data = map[string]string{} - } - - _, ok := configmap.Data[id] - if !ok { - return nil - } - - delete(configmap.Data, id) - - if err := updateConfigmap(configmap); err != nil { - return errors.Wrap(err, "failed to update task status configmap") + return fmt.Errorf("failed to write: %v: %v", err, wr.Err) } return nil } func GetTaskStatus(id string) (string, string, error) { - tasksLock.Lock() - defer tasksLock.Unlock() - - configmap, err := getConfigmap() - if err != nil { - return "", "", errors.Wrap(err, "failed to get task status configmap") - } - - if configmap.Data == nil { - return "", "", nil - } - - marshalled, ok := configmap.Data[id] - if !ok { - return "", "", nil - } - - ts := TaskStatus{} - if err := json.Unmarshal([]byte(marshalled), &ts); err != nil { - return "", "", errors.Wrap(err, "error unmarshalling task status") - } - - if ts.UpdatedAt.Before(time.Now().Add(-1 * time.Minute)) { - return "", "", nil - } - - return ts.Status, ts.Message, nil -} - -func getConfigmap() (*corev1.ConfigMap, error) { - clientset, err := k8sutil.GetClientset() - if err != nil { - return nil, errors.Wrap(err, "failed to get clientset") - } - - existingConfigmap, err := clientset.CoreV1().ConfigMaps(util.PodNamespace).Get(context.TODO(), tasksConfigMapName, metav1.GetOptions{}) - if err != nil && !kuberneteserrors.IsNotFound(err) { - return nil, errors.Wrap(err, "failed to get configmap") - } else if kuberneteserrors.IsNotFound(err) { - configmap := corev1.ConfigMap{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "v1", - Kind: "ConfigMap", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: tasksConfigMapName, - Namespace: util.PodNamespace, - Labels: kotsadmtypes.GetKotsadmLabels(), - }, - Data: map[string]string{}, - } - - createdConfigmap, err := clientset.CoreV1().ConfigMaps(util.PodNamespace).Create(context.TODO(), &configmap, metav1.CreateOptions{}) - if err != nil { - return nil, errors.Wrap(err, "failed to create configmap") - } - - return createdConfigmap, nil - } - - return existingConfigmap, nil -} - -func updateConfigmap(configmap *corev1.ConfigMap) error { - clientset, err := k8sutil.GetClientset() - if err != nil { - return errors.Wrap(err, "failed to get clientset") - } - - _, err = clientset.CoreV1().ConfigMaps(util.PodNamespace).Update(context.Background(), configmap, metav1.UpdateOptions{}) - if err != nil { - return errors.Wrap(err, "failed to update config map") - } - - return nil -} - -func MigrateTasksFromRqlite() error { db := persistence.MustGetDBSession() - query := `select updated_at, current_message, status from api_task_status` - rows, err := db.QueryOne(query) - if err != nil { - return fmt.Errorf("failed to select tasks for migration: %v: %v", err, rows.Err) - } - - taskCm, err := getConfigmap() + // only return the status if it was updated in the last minute + query := `SELECT status, current_message from api_task_status WHERE id = ? AND strftime('%s', 'now') - updated_at < 60` + rows, err := db.QueryOneParameterized(gorqlite.ParameterizedStatement{ + Query: query, + Arguments: []interface{}{id}, + }) if err != nil { - return errors.Wrap(err, "failed to get task status configmap") + return "", "", fmt.Errorf("failed to query app: %v: %v", err, rows.Err) } - - if taskCm.Data == nil { - taskCm.Data = map[string]string{} - } - - for rows.Next() { - var id string - var status gorqlite.NullString - var message gorqlite.NullString - - ts := TaskStatus{} - if err := rows.Scan(&id, &ts.UpdatedAt, &message, &status); err != nil { - return errors.Wrap(err, "failed to scan task status") - } - - if status.Valid { - ts.Status = status.String - } - if message.Valid { - ts.Message = message.String - } - - b, err := json.Marshal(ts) - if err != nil { - return errors.Wrap(err, "failed to marshal task status") - } - - taskCm.Data[id] = string(b) - } - - if err := updateConfigmap(taskCm); err != nil { - return errors.Wrap(err, "failed to update task status configmap") + if !rows.Next() { + return "", "", nil } - query = `delete from api_task_status` - if wr, err := db.WriteOne(query); err != nil { - return fmt.Errorf("failed to delete tasks from db: %v: %v", err, wr.Err) + var status gorqlite.NullString + var message gorqlite.NullString + if err := rows.Scan(&status, &message); err != nil { + return "", "", errors.Wrap(err, "failed to scan") } - return nil + return status.String, message.String, nil } diff --git a/pkg/upgradeservice/deploy/deploy.go b/pkg/upgradeservice/deploy/deploy.go index 9b2f4b7c3e..a55bab57de 100644 --- a/pkg/upgradeservice/deploy/deploy.go +++ b/pkg/upgradeservice/deploy/deploy.go @@ -6,6 +6,7 @@ import ( "time" "github.com/pkg/errors" + embeddedclusterv1beta1 "github.com/replicatedhq/embedded-cluster-kinds/apis/v1beta1" "github.com/replicatedhq/kots/pkg/apparchive" "github.com/replicatedhq/kots/pkg/embeddedcluster" "github.com/replicatedhq/kots/pkg/k8sutil" @@ -66,6 +67,8 @@ type DeployOptions struct { } func Deploy(opts DeployOptions) error { + // put the app version archive in the object store so the operator + // of the new kots version can retrieve it to deploy the app tgzArchiveKey := fmt.Sprintf( "deployments/%s/%s-%s.tar.gz", opts.Params.AppSlug, @@ -104,23 +107,28 @@ func Deploy(opts DeployOptions) error { return nil } - // TODO NOW: no error is shown if pod restarts during cluster upgrade - if err := task.SetStatusUpgradingCluster(opts.Params.AppSlug, "Upgrading cluster..."); err != nil { + // a cluster upgrade is required. that's a long running process, and there's a high chance + // kots will be upgraded and restart during the process. so we run the upgrade in a goroutine + // and report the status back to the ui for the user to see the progress. + // the kots operator takes care of reporting the progress after the deployment gets created + + if err := task.SetStatusUpgradingCluster(opts.Params.AppSlug, embeddedclusterv1beta1.InstallationStateEnqueued); err != nil { return errors.Wrap(err, "failed to set task status") } go func() (finalError error) { - finishedChan := make(chan error) - defer close(finishedChan) - - tasks.StartTaskMonitor(task.GetID(opts.Params.AppSlug), finishedChan) defer func() { if finalError != nil { - logger.Error(finalError) + if err := task.SetStatusUpgradeFailed(opts.Params.AppSlug, finalError.Error()); err != nil { + logger.Error(errors.Wrap(err, "failed to set task status to upgrade failed")) + } } - finishedChan <- finalError }() + finishedCh := make(chan struct{}) + defer close(finishedCh) + tasks.StartTicker(task.GetID(opts.Params.AppSlug), finishedCh) + if err := embeddedcluster.StartClusterUpgrade(context.Background(), opts.KotsKinds, opts.RegistrySettings); err != nil { return errors.Wrap(err, "failed to start cluster upgrade") } @@ -195,7 +203,7 @@ func createDeployment(opts createDeploymentOptions) error { "skip-preflights": fmt.Sprintf("%t", opts.isSkipPreflights), "continue-with-failed-preflights": fmt.Sprintf("%t", opts.continueWithFailedPreflights), "preflight-result": preflightResult, - "kots-version": opts.params.UpdateKOTSVersion, + "embedded-cluster-version": opts.params.UpdateECVersion, "requires-cluster-upgrade": fmt.Sprintf("%t", opts.requiresClusterUpgrade), }, } @@ -213,54 +221,28 @@ func createDeployment(opts createDeploymentOptions) error { return nil } +// waitForDeployment waits for the deployment to be processed by the kots operator. +// this is only used when a cluster upgrade is not required. func waitForDeployment(ctx context.Context, appSlug string) error { clientset, err := k8sutil.GetClientset() if err != nil { return errors.Wrap(err, "failed to get clientset") } + start := time.Now() for { - s, err := clientset.CoreV1().ConfigMaps(util.PodNamespace).Get(ctx, getDeploymentName(appSlug), metav1.GetOptions{}) + cm, err := clientset.CoreV1().ConfigMaps(util.PodNamespace).Get(ctx, getDeploymentName(appSlug), metav1.GetOptions{}) if err != nil { return errors.Wrap(err, "failed to get configmap") } - if s.Labels["kots.io/processed"] == "true" { + if cm.Labels != nil && cm.Labels["kots.io/processed"] == "true" { return nil } - time.Sleep(1 * time.Second) + if time.Sleep(1 * time.Second); time.Since(start) > 15*time.Second { + return errors.New("timed out waiting for deployment to be processed") + } } } func getDeploymentName(appSlug string) string { return fmt.Sprintf("kotsadm-%s-deployment", appSlug) } - -// IsClusterUpgrading returns true if: -// - the upgrade service task status is upgrading cluster OR -// - the deployment requires a cluster upgrade and has not been processed yet -func IsClusterUpgrading(ctx context.Context, appSlug string) (bool, error) { - isUpgrading, err := task.IsStatusUpgradingCluster(appSlug) - if err != nil { - return false, errors.Wrap(err, "failed to get task status") - } - if isUpgrading { - return true, nil - } - clientset, err := k8sutil.GetClientset() - if err != nil { - return false, errors.Wrap(err, "failed to get clientset") - } - cm, err := clientset.CoreV1().ConfigMaps(util.PodNamespace).Get(ctx, getDeploymentName(appSlug), metav1.GetOptions{}) - if err != nil { - if kuberneteserrors.IsNotFound(err) { - return false, nil - } - return false, errors.Wrap(err, "failed to get configmap") - } - if cm.Labels["kots.io/processed"] == "true" { - return false, nil - } - if cm.Data["requires-cluster-upgrade"] == "true" { - return true, nil - } - return false, nil -} diff --git a/pkg/upgradeservice/handlers/info.go b/pkg/upgradeservice/handlers/info.go index 245e792b8a..ccdb94c310 100644 --- a/pkg/upgradeservice/handlers/info.go +++ b/pkg/upgradeservice/handlers/info.go @@ -11,7 +11,6 @@ import ( type InfoResponse struct { Success bool `json:"success"` Error string `json:"error,omitempty"` - KOTSVersion string `json:"kotsVersion"` HasPreflight bool `json:"hasPreflight"` IsConfigurable bool `json:"isConfigurable"` } @@ -32,7 +31,6 @@ func (h *Handler) Info(w http.ResponseWriter, r *http.Request) { } response.Success = true - response.KOTSVersion = params.UpdateKOTSVersion response.HasPreflight = kotsKinds.HasPreflights() response.IsConfigurable = kotsKinds.IsConfigurable() diff --git a/pkg/upgradeservice/task/task.go b/pkg/upgradeservice/task/task.go index f2999f5726..cd0c66646e 100644 --- a/pkg/upgradeservice/task/task.go +++ b/pkg/upgradeservice/task/task.go @@ -3,7 +3,6 @@ package task import ( "fmt" - "github.com/pkg/errors" "github.com/replicatedhq/kots/pkg/tasks" ) @@ -13,6 +12,8 @@ type Status string const ( StatusStarting Status = "starting" StatusUpgradingCluster Status = "upgrading-cluster" + StatusUpgradingApp Status = "upgrading-app" + StatusUpgradeFailed Status = "upgrade-failed" ) // CAUTION: modifying this task id will break backwards compatibility @@ -24,6 +25,10 @@ func GetStatus(appSlug string) (string, string, error) { return tasks.GetTaskStatus(GetID(appSlug)) } +func ClearStatus(appSlug string) error { + return tasks.ClearTaskStatus(GetID(appSlug)) +} + func SetStatusStarting(appSlug string, msg string) error { return tasks.SetTaskStatus(GetID(appSlug), msg, string(StatusStarting)) } @@ -32,10 +37,10 @@ func SetStatusUpgradingCluster(appSlug string, msg string) error { return tasks.SetTaskStatus(GetID(appSlug), msg, string(StatusUpgradingCluster)) } -func IsStatusUpgradingCluster(appSlug string) (bool, error) { - status, _, err := GetStatus(appSlug) - if err != nil { - return false, errors.Wrap(err, "failed to get status") - } - return status == string(StatusUpgradingCluster), nil +func SetStatusUpgradingApp(appSlug string, msg string) error { + return tasks.SetTaskStatus(GetID(appSlug), msg, string(StatusUpgradingApp)) +} + +func SetStatusUpgradeFailed(appSlug string, msg string) error { + return tasks.SetTaskStatus(GetID(appSlug), msg, string(StatusUpgradeFailed)) } diff --git a/pkg/upgradeservice/types/types.go b/pkg/upgradeservice/types/types.go index de99b458dc..d5c2bdcda3 100644 --- a/pkg/upgradeservice/types/types.go +++ b/pkg/upgradeservice/types/types.go @@ -22,11 +22,11 @@ type UpgradeServiceParams struct { UpdateVersionLabel string `yaml:"updateVersionLabel"` UpdateCursor string `yaml:"updateCursor"` UpdateChannelID string `yaml:"updateChannelID"` + UpdateECVersion string `yaml:"updateECVersion"` + UpdateKOTSBin string `yaml:"updateKotsBin"` UpdateAirgapBundle string `yaml:"updateAirgapBundle"` - CurrentKOTSVersion string `yaml:"currentKotsVersion"` - UpdateKOTSVersion string `yaml:"updateKotsVersion"` - UpdateKOTSBin string `yaml:"updateKotsBin"` + CurrentECVersion string `yaml:"currentECVersion"` RegistryEndpoint string `yaml:"registryEndpoint"` RegistryUsername string `yaml:"registryUsername"` diff --git a/web/src/ConnectionTerminated.jsx b/web/src/ConnectionTerminated.jsx index ecb0929e72..5bd173838d 100644 --- a/web/src/ConnectionTerminated.jsx +++ b/web/src/ConnectionTerminated.jsx @@ -40,9 +40,12 @@ export default class ConnectionTerminated extends Component { 10000 ) .then(async (res) => { - if (res.status === 401) { - Utilities.logoutUser(); - return; + if (!res.ok) { + if (res.status === 401) { + Utilities.logoutUser(); + return; + } + throw new Error(`Unexpected status code: ${res.status}`); } this.props.setTerminatedState(false); }) diff --git a/web/src/Root.tsx b/web/src/Root.tsx index 72b9b42656..cfeac50a1d 100644 --- a/web/src/Root.tsx +++ b/web/src/Root.tsx @@ -54,7 +54,7 @@ import SnapshotRestore from "@components/snapshots/SnapshotRestore"; import AppSnapshots from "@components/snapshots/AppSnapshots"; import AppSnapshotRestore from "@components/snapshots/AppSnapshotRestore"; import EmbeddedClusterViewNode from "@components/apps/EmbeddedClusterViewNode"; -import EmbeddedClusterUpgrading from "@components/clusters/EmbeddedClusterUpgrading"; +import UpgradeStatusModal from "@components/modals/UpgradeStatusModal"; // react-query client const queryClient = new QueryClient(); @@ -89,7 +89,10 @@ type State = { appSlugFromMetadata: string | null; adminConsoleMetadata: Metadata | null; connectionTerminated: boolean; - showClusterUpgradeModal: boolean; + showUpgradeStatusModal: boolean; + upgradeStatus?: string; + upgradeMessage?: string; + upgradeAppSlug?: string; clusterState: string; errLoggingOut: string; featureFlags: object; @@ -117,7 +120,10 @@ const Root = () => { appNameSpace: null, adminConsoleMetadata: null, connectionTerminated: false, - showClusterUpgradeModal: false, + showUpgradeStatusModal: false, + upgradeStatus: "", + upgradeMessage: "", + upgradeAppSlug: "", clusterState: "", errLoggingOut: "", featureFlags: {}, @@ -237,8 +243,6 @@ const Root = () => { const apps = response.apps; setState({ appsList: apps, - showClusterUpgradeModal: Utilities.shouldShowClusterUpgradeModal(apps), - clusterState: Utilities.getClusterState(apps), }); return apps; } catch (err) { @@ -246,6 +250,44 @@ const Root = () => { } }; + const fetchUpgradeStatus = async (appSlug) => { + try { + const res = await fetch(`${process.env.API_ENDPOINT}/app/${appSlug}/task/upgrade-service`, { + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + method: "GET", + }); + if (!res.ok) { + if (res.status === 401) { + Utilities.logoutUser(); + return; + } + console.log("failed to get upgrade service status, unexpected status code", res.status); + return; + } + const response = await res.json(); + const status = response.status; + if (status === "upgrading-cluster" || status === "upgrading-app" || status === "upgrade-failed") { + setState({ + showUpgradeStatusModal: true, + upgradeStatus: status, + upgradeMessage: response.currentMessage, + upgradeAppSlug: appSlug, + }); + return; + } + if (state.showUpgradeStatusModal) { + // upgrade finished, reload the page + window.location.reload(); + return; + } + } catch (err) { + throw err; + } + }; + const fetchKotsAppMetadata = async () => { setState({ fetchingMetadata: true }); @@ -300,12 +342,15 @@ const Root = () => { }, 10000 ) - .then(async (result) => { - if (result.status === 401) { - Utilities.logoutUser(); - return; + .then(async (res) => { + if (!res.ok) { + if (res.status === 401) { + Utilities.logoutUser(); + return; + } + throw new Error(`Unexpected status code: ${res.status}`); } - const body = await result.json(); + const body = await res.json(); setState({ connectionTerminated: false, snapshotInProgressApps: body.snapshotInProgressApps, @@ -325,13 +370,17 @@ const Root = () => { }); }; - const onRootMounted = () => { + const onRootMounted = async () => { fetchKotsAppMetadata(); if (Utilities.isLoggedIn()) { ping(); getAppsList().then((appsList) => { - if (appsList?.length > 0 && window.location.pathname === "/apps") { - const { slug } = appsList[0]; + if (!appsList?.length) { + return; + } + const { slug } = appsList[0]; + fetchUpgradeStatus(slug); + if (window.location.pathname === "/apps") { history.replace(`/app/${slug}`); } }); @@ -684,13 +733,14 @@ const Root = () => { appNameSpace={state.appNameSpace} appName={state.selectedAppName} refetchAppsList={getAppsList} + refetchUpgradeStatus={fetchUpgradeStatus} snapshotInProgressApps={state.snapshotInProgressApps} ping={ping} isEmbeddedCluster={Boolean( state.adminConsoleMetadata?.isEmbeddedCluster )} - showClusterUpgradeModal={ - state.showClusterUpgradeModal + showUpgradeStatusModal={ + state.showUpgradeStatusModal } /> } @@ -704,13 +754,14 @@ const Root = () => { appNameSpace={state.appNameSpace} appName={state.selectedAppName} refetchAppsList={getAppsList} + refetchUpgradeStatus={fetchUpgradeStatus} snapshotInProgressApps={state.snapshotInProgressApps} ping={ping} isEmbeddedCluster={Boolean( state.adminConsoleMetadata?.isEmbeddedCluster )} - showClusterUpgradeModal={ - state.showClusterUpgradeModal + showUpgradeStatusModal={ + state.showUpgradeStatusModal } /> } @@ -863,33 +914,51 @@ const Root = () => { - - {!state.showClusterUpgradeModal ? ( - { + // cannot close the modal while upgrading + if (state.upgradeStatus === "upgrade-failed") { + setState({ showUpgradeStatusModal: false }); + } + }} + shouldReturnFocusAfterClose={false} + contentLabel="Upgrade status modal" + ariaHideApp={false} + className="Modal DefaultSize" + > + setState({ showUpgradeStatusModal: false })} connectionTerminated={state.connectionTerminated} - appLogo={state.appLogo} setTerminatedState={(status: boolean) => setState({ connectionTerminated: status }) } /> - ) : ( - + ) : ( + + setState({ connectionTerminated: status }) } /> - )} - + + )} ); }; diff --git a/web/src/components/apps/AppDetailPage.tsx b/web/src/components/apps/AppDetailPage.tsx index 90bcbfb693..7d3527d0e5 100644 --- a/web/src/components/apps/AppDetailPage.tsx +++ b/web/src/components/apps/AppDetailPage.tsx @@ -32,7 +32,8 @@ type Props = { refetchAppMetadata: () => void; snapshotInProgressApps: string[]; isEmbeddedCluster: boolean; - showClusterUpgradeModal: boolean; + refetchUpgradeStatus: (appSlug: string) => Promise; + showUpgradeStatusModal: boolean; }; type State = { @@ -134,8 +135,8 @@ function AppDetailPage(props: Props) { ? appsError.message : "Unexpected error when fetching apps"; let displayErrorModal = true; - if (props.showClusterUpgradeModal) { - // don't show apps error modal if cluster is upgrading + if (props.showUpgradeStatusModal) { + // don't show apps error modal if an upgrade is in progress gettingAppErrMsg = ""; displayErrorModal = false; } @@ -410,6 +411,8 @@ function AppDetailPage(props: Props) { toggleErrorModal: toggleErrorModal, toggleIsBundleUploading: toggleIsBundleUploading, updateCallback: refetchData, + refetchUpgradeStatus: props.refetchUpgradeStatus, + showUpgradeStatusModal: props.showUpgradeStatusModal, }; const lastItem = Utilities.getSubnavItemForRoute( diff --git a/web/src/components/apps/AppVersionHistory.tsx b/web/src/components/apps/AppVersionHistory.tsx index 68fc4dd3cc..14ed462629 100644 --- a/web/src/components/apps/AppVersionHistory.tsx +++ b/web/src/components/apps/AppVersionHistory.tsx @@ -84,6 +84,8 @@ type Props = { toggleErrorModal: () => void; toggleIsBundleUploading: (isUploading: boolean) => void; updateCallback: () => void; + refetchUpgradeStatus: (appSlug: string) => Promise; + showUpgradeStatusModal: boolean; }; } & RouterProps; @@ -251,8 +253,9 @@ class AppVersionHistory extends Component { handleIframeMessage = (event) => { if (event.data.message === "closeUpgradeServiceModal") { this.setState({ shouldShowUpgradeServiceModal: false }); - if (this.props.outletContext.updateCallback) { - this.props.outletContext.updateCallback(); + if (this.props.outletContext.refetchUpgradeStatus) { + const { app } = this.props.outletContext; + this.props.outletContext.refetchUpgradeStatus(app.slug); } this.fetchAvailableUpdates(false); this.fetchKotsDownstreamHistory(); @@ -300,13 +303,18 @@ class AppVersionHistory extends Component { if (this.state.shouldShowUpgradeServiceModal && this.iframeRef.current) { window.addEventListener("message", this.handleIframeMessage); } - if ( lastProps.params.slug !== this.props.params.slug || lastProps.outletContext.app.id !== this.props.outletContext.app.id ) { this.fetchKotsDownstreamHistory(); } + if ( + lastProps.outletContext.isEmbeddedCluster !== this.props.outletContext.isEmbeddedCluster && + this.props.outletContext.isEmbeddedCluster + ) { + this.fetchAvailableUpdates(); + } if ( this.props.outletContext.app.downstream.pendingVersions.length > 0 && this.state.updatesAvailable === false @@ -1499,7 +1507,7 @@ class AppVersionHistory extends Component { } }; - startUpgraderService = (update: AvailableUpdate) => { + startUpgradeService = (update: AvailableUpdate) => { this.setState({ upgradeService: { versionLabel: update.versionLabel, @@ -1670,6 +1678,7 @@ class AppVersionHistory extends Component { redeployVersionErrMsg, resetRedeployErrorMessage, resetMakingCurrentReleaseErrorMessage, + showUpgradeStatusModal, } = this.props.outletContext; const { @@ -2094,7 +2103,7 @@ class AppVersionHistory extends Component { updates={this.state.availableUpdates} showReleaseNotes={this.showReleaseNotes} upgradeService={this.state.upgradeService} - startUpgraderService={this.startUpgraderService} + startUpgradeService={this.startUpgradeService} isAirgap={app?.isAirgap} airgapUploader={airgapUploader} /> @@ -2210,7 +2219,7 @@ class AppVersionHistory extends Component { sequence={this.state.selectedSequence} /> - {errorMsg && ( + {errorMsg && !showUpgradeStatusModal && ( void; + startUpgradeService: (version: AvailableUpdate) => void; airgapUploader: AirgapUploader | null; isAirgap: boolean; }) => { @@ -97,7 +97,7 @@ const AvailableUpdatesComponent = ({ )} + + + ); + } + + let status; + if (props.status === "upgrading-cluster") { + status = Utilities.humanReadableClusterState(props.message); + } else if (props.status === "upgrading-app") { + status = "Almost done"; + } + + return ( +
+
+ +
+

+ Cluster update in progress +

+ {props.connectionTerminated ? ( +

+ The API cannot be reached because the cluster is updating. Stay on this + page to automatically reconnect when the update is complete. +

+ ) : ( +

+ The page will automatically refresh when the update is complete.

+ Status: {status} +

+ )} +
+ ); +}; + +export default UpgradeStatusModal; diff --git a/web/src/features/Dashboard/components/Dashboard.tsx b/web/src/features/Dashboard/components/Dashboard.tsx index f1b92249ff..4dee63d546 100644 --- a/web/src/features/Dashboard/components/Dashboard.tsx +++ b/web/src/features/Dashboard/components/Dashboard.tsx @@ -69,6 +69,7 @@ type OutletContext = { refreshAppData: () => void; toggleIsBundleUploading: (isUploading: boolean) => void; updateCallback: () => void | null; + showUpgradeStatusModal: boolean; }; // TODO: update these strings so that they are not nullable (maybe just set default to "") @@ -164,6 +165,7 @@ const Dashboard = () => { refreshAppData, toggleIsBundleUploading, updateCallback, + showUpgradeStatusModal, }: OutletContext = useOutletContext(); const params = useParams(); const airgapUploader = useRef(null); @@ -226,8 +228,8 @@ const Dashboard = () => { }; const onUpdateDownloadStatusError = (data: Error) => { - if (Utilities.isClusterUpgrading(app)) { - // if the cluster is upgrading, we don't want to show an error + if (showUpgradeStatusModal) { + // if an upgrade is in progress, we don't want to show an error return; } @@ -589,8 +591,8 @@ const Dashboard = () => { }; const onError = (err: Error) => { - if (Utilities.isClusterUpgrading(app)) { - // if the cluster is upgrading, we don't want to show an error + if (showUpgradeStatusModal) { + // if an upgrade is in progress, we don't want to show an error return; } @@ -697,6 +699,7 @@ const Dashboard = () => { viewAirgapUploadError={() => toggleViewAirgapUploadError()} showAutomaticUpdatesModal={showAutomaticUpdatesModal} noUpdatesAvalable={state.noUpdatesAvalable} + showUpgradeStatusModal={showUpgradeStatusModal} /> diff --git a/web/src/features/Dashboard/components/DashboardVersionCard.tsx b/web/src/features/Dashboard/components/DashboardVersionCard.tsx index 32f5f28d8a..12ac00a456 100644 --- a/web/src/features/Dashboard/components/DashboardVersionCard.tsx +++ b/web/src/features/Dashboard/components/DashboardVersionCard.tsx @@ -68,6 +68,7 @@ type Props = { uploadResuming: boolean; uploadSize: number; viewAirgapUploadError: () => void; + showUpgradeStatusModal: boolean; }; type State = { @@ -202,8 +203,8 @@ const DashboardVersionCard = (props: Props) => { }, [location.search]); useEffect(() => { - if (Utilities.isClusterUpgrading(selectedApp)) { - // if the cluster is upgrading, we don't want to show an error + if (props.showUpgradeStatusModal) { + // if an upgrade is in progress, we don't want to show an error return; } diff --git a/web/src/types/index.ts b/web/src/types/index.ts index a18f5ea640..6feb0ab0f9 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -79,7 +79,6 @@ type Cluster = { id: number; slug: string; state?: string; - isUpgrading?: boolean; }; export type Credentials = { diff --git a/web/src/utilities/utilities.js b/web/src/utilities/utilities.js index ffdb971a1a..02b63601b5 100644 --- a/web/src/utilities/utilities.js +++ b/web/src/utilities/utilities.js @@ -669,20 +669,6 @@ export const Utilities = { return app?.downstream?.cluster?.state; }, - isClusterUpgrading(app) { - return app?.downstream?.cluster?.isUpgrading; - }, - - shouldShowClusterUpgradeModal(apps) { - if (!apps || apps.length === 0) { - return false; - } - - // embedded cluster can only have one app - const app = apps[0]; - return this.isClusterUpgrading(app); - }, - // Converts string to titlecase i.e. 'hello' -> 'Hello' // @returns {String} toTitleCase(word) { diff --git a/web/src/utilities/utilities.test.js b/web/src/utilities/utilities.test.js index e5de573e4d..4938825543 100644 --- a/web/src/utilities/utilities.test.js +++ b/web/src/utilities/utilities.test.js @@ -15,39 +15,6 @@ describe("Utilities", () => { }); }); - describe("shouldShowClusterUpgradeModal", () => { - it("should return false if apps is null or empty", () => { - expect(Utilities.shouldShowClusterUpgradeModal(null)).toBe(false); - expect(Utilities.shouldShowClusterUpgradeModal([])).toBe(false); - }); - - it("should return false if the cluster is not upgrading", () => { - const apps = [ - { - downstream: { - cluster: { - isUpgrading: false, - }, - }, - }, - ]; - expect(Utilities.shouldShowClusterUpgradeModal(apps)).toBe(false); - }); - - it("should return true if the cluster is upgrading", () => { - const apps = [ - { - downstream: { - cluster: { - isUpgrading: true, - }, - }, - }, - ]; - expect(Utilities.shouldShowClusterUpgradeModal(apps)).toBe(true); - }); - }); - describe("isInitialAppInstall", () => { it("should return true if app is null", () => { expect(Utilities.isInitialAppInstall(null)).toBe(true);