diff --git a/kotsadm/pkg/apiserver/server.go b/kotsadm/pkg/apiserver/server.go index 10052b28e4..9405e3c27d 100644 --- a/kotsadm/pkg/apiserver/server.go +++ b/kotsadm/pkg/apiserver/server.go @@ -91,6 +91,9 @@ func Start() { // proxy for license/titled api r.Path("/license/v1/license").Methods("GET").HandlerFunc(handlers.NodeProxy(upstream)) + // Apps + r.Path("/api/v1/apps/app/{appSlug}").Methods("OPTIONS", "GET").HandlerFunc(handlers.GetApp) + // Airgap r.Path("/api/v1/app/airgap").Methods("OPTIONS", "POST", "PUT").HandlerFunc(handlers.UploadAirgapBundle) r.Path("/api/v1/app/airgap/status").Methods("OPTIONS", "GET").HandlerFunc(handlers.GetAirgapInstallStatus) diff --git a/kotsadm/pkg/app/app.go b/kotsadm/pkg/app/app.go index 58df0a43a2..c18b00c510 100644 --- a/kotsadm/pkg/app/app.go +++ b/kotsadm/pkg/app/app.go @@ -17,16 +17,26 @@ import ( ) type App struct { - ID string - Slug string - Name string - IsAirgap bool - CurrentSequence int64 - - // Additional fields will be added here as implementation is moved from node to go - RestoreInProgressName string - UpdateCheckerSpec string - IsGitOps bool + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + License string `json:"license"` + IsAirgap bool `json:"isAirgap"` + CurrentSequence int64 `json:"currentSequence"` + UpstreamURI string `json:"upstreamUri"` + IconURI string `json:"iconUri"` + UpdatedAt *time.Time `json:"createdAt"` + CreatedAt time.Time `json:"updatedAt"` + LastUpdateCheckAt string `json:"lastUpdateCheckAt"` + BundleCommand string `json:"bundleCommand"` + HasPreflight bool `json:"hasPreflight"` + IsConfigurable bool `json:"isConfigurable"` + SnapshotTTL string `json:"snapshotTtl"` + SnapshotSchedule string `json:"snapshotSchedule"` + RestoreInProgressName string `json:"restoreInProgressName"` + RestoreUndeployStatus string `json:"restoreUndeloyStatus"` + UpdateCheckerSpec string `json:"updateCheckerSpec"` + IsGitOps bool `json:"isGitOps"` } type RegistryInfo struct { @@ -42,27 +52,67 @@ func Get(id string) (*App, error) { zap.String("id", id)) db := persistence.MustGetPGSession() - query := `select id, slug, name, current_sequence, is_airgap, restore_in_progress_name, update_checker_spec from app where id = $1` + query := `select id, name, license, upstream_uri, icon_uri, created_at, updated_at, slug, current_sequence, last_update_check_at, is_airgap, snapshot_ttl_new, snapshot_schedule, restore_in_progress_name, restore_undeploy_status, update_checker_spec from app where id = $1` row := db.QueryRow(query, id) app := App{} + var licenseStr sql.NullString + var upstreamURI sql.NullString + var iconURI sql.NullString + var updatedAt sql.NullTime var currentSequence sql.NullInt64 + var lastUpdateCheckAt sql.NullString + var snapshotTTLNew sql.NullString + var snapshotSchedule sql.NullString var restoreInProgressName sql.NullString + var restoreUndeployStatus sql.NullString var updateCheckerSpec sql.NullString - if err := row.Scan(&app.ID, &app.Slug, &app.Name, ¤tSequence, &app.IsAirgap, &restoreInProgressName, &updateCheckerSpec); err != nil { + if err := row.Scan(&app.ID, &app.Name, &licenseStr, &upstreamURI, &iconURI, &app.CreatedAt, &updatedAt, &app.Slug, ¤tSequence, &lastUpdateCheckAt, &app.IsAirgap, &snapshotTTLNew, &snapshotSchedule, &restoreInProgressName, &restoreUndeployStatus, &updateCheckerSpec); err != nil { return nil, errors.Wrap(err, "failed to scan app") } + app.License = licenseStr.String + app.UpstreamURI = upstreamURI.String + app.IconURI = iconURI.String + app.LastUpdateCheckAt = lastUpdateCheckAt.String + app.SnapshotTTL = snapshotTTLNew.String + app.SnapshotSchedule = snapshotSchedule.String + app.RestoreInProgressName = restoreInProgressName.String + app.RestoreUndeployStatus = restoreUndeployStatus.String + app.UpdateCheckerSpec = updateCheckerSpec.String + + if updatedAt.Valid { + app.UpdatedAt = &updatedAt.Time + } + if currentSequence.Valid { app.CurrentSequence = currentSequence.Int64 } else { app.CurrentSequence = -1 } - app.RestoreInProgressName = restoreInProgressName.String - app.UpdateCheckerSpec = updateCheckerSpec.String + if app.CurrentSequence != -1 { + query = `select preflight_spec, config_spec from app_version where app_id = $1 AND sequence = $2` + row = db.QueryRow(query, id, app.CurrentSequence) + + var preflightSpec sql.NullString + var configSpec sql.NullString + + if err := row.Scan(&preflightSpec, &configSpec); err != nil { + return nil, errors.Wrap(err, "failed to scan app_version") + } + + if preflightSpec.Valid && preflightSpec.String != "" { + app.HasPreflight = true + } + if configSpec.Valid && configSpec.String != "" { + app.IsConfigurable = true + } + } + + app.BundleCommand = fmt.Sprintf("curl https://krew.sh/support-bundle | bash\nkubectl support-bundle API_ADDRESS/api/v1/troubleshoot/%s\n", app.Slug) isGitOps, err := IsGitOpsEnabled(id) if err != nil { @@ -82,6 +132,7 @@ func ListInstalled() ([]*App, error) { if err != nil { return nil, errors.Wrap(err, "failed to query db") } + defer rows.Close() apps := []*App{} for rows.Next() { diff --git a/kotsadm/pkg/downstream/downstream.go b/kotsadm/pkg/downstream/downstream.go index 3aa3719b3f..4301bd1f56 100644 --- a/kotsadm/pkg/downstream/downstream.go +++ b/kotsadm/pkg/downstream/downstream.go @@ -8,11 +8,17 @@ import ( "github.com/replicatedhq/kots/kotsadm/pkg/downstream/types" "github.com/replicatedhq/kots/kotsadm/pkg/logger" "github.com/replicatedhq/kots/kotsadm/pkg/persistence" + kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1" + "k8s.io/client-go/kubernetes/scheme" ) +type scannable interface { + Scan(dest ...interface{}) error +} + func ListDownstreamsForApp(appID string) ([]*types.Downstream, error) { db := persistence.MustGetPGSession() - query := `select cluster_id, downstream_name, current_sequence from app_downstream where app_id = $1` + query := `select c.id, c.slug, d.downstream_name, d.current_sequence from app_downstream d inner join cluster c on d.cluster_id = c.id where app_id = $1` rows, err := db.Query(query, appID) if err != nil { return nil, errors.Wrap(err, "failed to get downstreams") @@ -25,7 +31,7 @@ func ListDownstreamsForApp(appID string) ([]*types.Downstream, error) { CurrentSequence: -1, } var sequence sql.NullInt64 - if err := rows.Scan(&downstream.ClusterID, &downstream.Name, &sequence); err != nil { + if err := rows.Scan(&downstream.ClusterID, &downstream.ClusterSlug, &downstream.Name, &sequence); err != nil { return nil, errors.Wrap(err, "failed to scan downstream") } if sequence.Valid { @@ -38,26 +44,39 @@ func ListDownstreamsForApp(appID string) ([]*types.Downstream, error) { return downstreams, nil } -func GetParentSequence(appID string, clusterID string) (int64, error) { +func GetCurrentSequence(appID string, clusterID string) (int64, error) { db := persistence.MustGetPGSession() query := `select current_sequence from app_downstream where app_id = $1 and cluster_id = $2` row := db.QueryRow(query, appID, clusterID) var currentSequence sql.NullInt64 if err := row.Scan(¤tSequence); err != nil { - return 0, errors.Wrap(err, "failed to scan") + return -1, errors.Wrap(err, "failed to scan") } if !currentSequence.Valid { return -1, nil } - query = `select parent_sequence from app_downstream_version where app_id = $1 and cluster_id = $2 and sequence = $3` - row = db.QueryRow(query, appID, clusterID, currentSequence.Int64) + return currentSequence.Int64, nil +} + +func GetCurrentParentSequence(appID string, clusterID string) (int64, error) { + currentSequence, err := GetCurrentSequence(appID, clusterID) + if err != nil { + return -1, errors.Wrap(err, "failed to get current sequence") + } + if currentSequence == -1 { + return -1, nil + } + + db := persistence.MustGetPGSession() + query := `select parent_sequence from app_downstream_version where app_id = $1 and cluster_id = $2 and sequence = $3` + row := db.QueryRow(query, appID, clusterID, currentSequence) var parentSequence sql.NullInt64 if err := row.Scan(&parentSequence); err != nil { - return 0, errors.Wrap(err, "failed to scan") + return -1, errors.Wrap(err, "failed to scan") } if !parentSequence.Valid { @@ -137,6 +156,295 @@ func SetIgnorePreflightPermissionErrors(appID string, sequence int64) error { return nil } +func GetCurrentVersion(appID string, clusterID string) (*types.DownstreamVersion, error) { + currentSequence, err := GetCurrentSequence(appID, clusterID) + if err != nil { + return nil, errors.Wrap(err, "failed to get current sequence") + } + if currentSequence == -1 { + return nil, nil + } + + db := persistence.MustGetPGSession() + query := `SELECT + adv.created_at, + adv.version_label, + adv.status, + adv.sequence, + adv.parent_sequence, + adv.applied_at, + adv.source, + adv.diff_summary, + adv.diff_summary_error, + adv.preflight_result, + adv.preflight_result_created_at, + adv.git_commit_url, + adv.git_deployable, + ado.is_error, + av.kots_installation_spec + FROM + app_downstream_version AS adv + LEFT JOIN + app_version AS av + ON + adv.app_id = av.app_id AND adv.sequence = av.sequence + LEFT JOIN + app_downstream_output AS ado + ON + adv.app_id = ado.app_id AND adv.cluster_id = ado.cluster_id AND adv.sequence = ado.downstream_sequence + WHERE + adv.app_id = $1 AND + adv.cluster_id = $3 AND + adv.sequence = $2 + ORDER BY + adv.sequence DESC` + row := db.QueryRow(query, appID, currentSequence, clusterID) + + v, err := versionFromRow(appID, row) + if err != nil { + return nil, errors.Wrap(err, "failed to get version from row") + } + + return v, nil +} + +func GetPendingVersions(appID string, clusterID string) ([]types.DownstreamVersion, error) { + currentSequence, err := GetCurrentSequence(appID, clusterID) + if err != nil { + return nil, errors.Wrap(err, "failed to get current sequence") + } + + db := persistence.MustGetPGSession() + query := `SELECT + adv.created_at, + adv.version_label, + adv.status, + adv.sequence, + adv.parent_sequence, + adv.applied_at, + adv.source, + adv.diff_summary, + adv.diff_summary_error, + adv.preflight_result, + adv.preflight_result_created_at, + adv.git_commit_url, + adv.git_deployable, + ado.is_error, + av.kots_installation_spec + FROM + app_downstream_version AS adv + LEFT JOIN + app_version AS av + ON + adv.app_id = av.app_id AND adv.sequence = av.sequence + LEFT JOIN + app_downstream_output AS ado + ON + adv.app_id = ado.app_id AND adv.cluster_id = ado.cluster_id AND adv.sequence = ado.downstream_sequence + WHERE + adv.app_id = $1 AND + adv.cluster_id = $3 AND + adv.sequence > $2 + ORDER BY + adv.sequence DESC` + + rows, err := db.Query(query, appID, currentSequence, clusterID) + if err != nil { + return nil, errors.Wrap(err, "failed to query") + } + defer rows.Close() + + versions := []types.DownstreamVersion{} + for rows.Next() { + v, err := versionFromRow(appID, rows) + if err != nil { + return nil, errors.Wrap(err, "failed to get version from row") + } + if v != nil { + versions = append(versions, *v) + } + } + + return versions, nil +} + +func GetPastVersions(appID string, clusterID string) ([]types.DownstreamVersion, error) { + currentSequence, err := GetCurrentSequence(appID, clusterID) + if err != nil { + return nil, errors.Wrap(err, "failed to get current sequence") + } + if currentSequence == -1 { + return []types.DownstreamVersion{}, nil + } + + db := persistence.MustGetPGSession() + query := `SELECT + adv.created_at, + adv.version_label, + adv.status, + adv.sequence, + adv.parent_sequence, + adv.applied_at, + adv.source, + adv.diff_summary, + adv.diff_summary_error, + adv.preflight_result, + adv.preflight_result_created_at, + adv.git_commit_url, + adv.git_deployable, + ado.is_error, + av.kots_installation_spec + FROM + app_downstream_version AS adv + LEFT JOIN + app_version AS av + ON + adv.app_id = av.app_id AND adv.sequence = av.sequence + LEFT JOIN + app_downstream_output AS ado + ON + adv.app_id = ado.app_id AND adv.cluster_id = ado.cluster_id AND adv.sequence = ado.downstream_sequence + WHERE + adv.app_id = $1 AND + adv.cluster_id = $3 AND + adv.sequence < $2 + ORDER BY + adv.sequence DESC` + + rows, err := db.Query(query, appID, currentSequence, clusterID) + if err != nil { + return nil, errors.Wrap(err, "failed to query") + } + defer rows.Close() + + versions := []types.DownstreamVersion{} + for rows.Next() { + v, err := versionFromRow(appID, rows) + if err != nil { + return nil, errors.Wrap(err, "failed to get version from row") + } + if v != nil { + versions = append(versions, *v) + } + } + + return versions, nil +} + +func versionFromRow(appID string, row scannable) (*types.DownstreamVersion, error) { + v := &types.DownstreamVersion{} + + var createdOn sql.NullTime + var versionLabel sql.NullString + var status sql.NullString + var parentSequence sql.NullInt64 + var deployedAt sql.NullTime + var source sql.NullString + var diffSummary sql.NullString + var diffSummaryError sql.NullString + var preflightResult sql.NullString + var preflightResultCreatedAt sql.NullTime + var commitURL sql.NullString + var gitDeployable sql.NullBool + var hasError sql.NullBool + var kotsInstallationSpecStr sql.NullString + + if err := row.Scan( + &createdOn, + &versionLabel, + &status, + &v.Sequence, + &parentSequence, + &deployedAt, + &source, + &diffSummary, + &diffSummaryError, + &preflightResult, + &preflightResultCreatedAt, + &commitURL, + &gitDeployable, + &hasError, + &kotsInstallationSpecStr, + ); err != nil { + return nil, errors.Wrap(err, "failed to scan") + } + + if createdOn.Valid { + v.CreatedOn = &createdOn.Time + } + v.VersionLabel = versionLabel.String + v.Status = getStatus(status.String, hasError) + v.ParentSequence = parentSequence.Int64 + + if deployedAt.Valid { + v.DeployedAt = &deployedAt.Time + } + v.Source = source.String + v.DiffSummary = diffSummary.String + v.DiffSummaryError = diffSummaryError.String + v.PreflightResult = preflightResult.String + + if preflightResultCreatedAt.Valid { + v.PreflightResultCreatedAt = &preflightResultCreatedAt.Time + } + v.CommitURL = commitURL.String + v.GitDeployable = gitDeployable.Bool + + releaseNotes, err := getReleaseNotes(appID, v.ParentSequence) + if err != nil { + return nil, errors.Wrap(err, "failed to get release notes") + } + v.ReleaseNotes = releaseNotes + + if kotsInstallationSpecStr.Valid && kotsInstallationSpecStr.String != "" { + decode := scheme.Codecs.UniversalDeserializer().Decode + obj, _, err := decode([]byte(kotsInstallationSpecStr.String), nil, nil) + if err != nil { + return nil, errors.Wrap(err, "failed to decode installation spec yaml") + } + installationSpec := obj.(*kotsv1beta1.Installation) + + v.YamlErrors = installationSpec.Spec.YAMLErrors + } + + return v, nil +} + +func getReleaseNotes(appID string, parentSequence int64) (string, error) { + db := persistence.MustGetPGSession() + query := `SELECT release_notes FROM app_version WHERE app_id = $1 AND sequence = $2` + row := db.QueryRow(query, appID, parentSequence) + + var releaseNotes sql.NullString + if err := row.Scan(&releaseNotes); err != nil { + if err == sql.ErrNoRows { + return "", nil + } + return "", errors.Wrap(err, "failed to scan") + } + + return releaseNotes.String, nil +} + +func getStatus(status string, hasError sql.NullBool) string { + s := "unknown" + + // first check if operator has reported back. + // and if it hasn't, we should not show "deployed" to the user. + + if hasError.Valid && !hasError.Bool { + s = status + } else if hasError.Valid && hasError.Bool { + s = "failed" + } else if status == "deployed" { + s = "deploying" + } else if status != "" { + s = status + } + + return s +} + func GetDownstreamOutput(appID string, clusterID string, sequence int64) (*types.DownstreamOutput, error) { db := persistence.MustGetPGSession() query := `SELECT diff --git a/kotsadm/pkg/downstream/types/types.go b/kotsadm/pkg/downstream/types/types.go index f9af258423..c56adcfdd5 100644 --- a/kotsadm/pkg/downstream/types/types.go +++ b/kotsadm/pkg/downstream/types/types.go @@ -1,11 +1,36 @@ package types +import ( + "time" + + v1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1" +) + type Downstream struct { ClusterID string + ClusterSlug string Name string CurrentSequence int64 } +type DownstreamVersion struct { + VersionLabel string `json:"versionLabel"` + Status string `json:"status"` + CreatedOn *time.Time `json:"createdOn"` + ParentSequence int64 `json:"parentSequence"` + Sequence int64 `json:"sequence"` + ReleaseNotes string `json:"releaseNotes"` + DeployedAt *time.Time `json:"deployedAt"` + Source string `json:"source"` + PreflightResult string `json:"preflightResult,omitempty"` + PreflightResultCreatedAt *time.Time `json:"preflightResultCreatedAt,omitempty"` + DiffSummary string `json:"diffSummary,omitempty"` + DiffSummaryError string `json:"diffSummaryError,omitempty"` + CommitURL string `json:"commitUrl,omitempty"` + GitDeployable bool `json:"gitDeployable,omitempty"` + YamlErrors []v1beta1.InstallationYAMLError `json:"yamlErrors,omitempty"` +} + type DownstreamOutput struct { DryrunStdout string `json:"dryrunStdout"` DryrunStderr string `json:"dryrunStderr"` diff --git a/kotsadm/pkg/gitops/gitops.go b/kotsadm/pkg/gitops/gitops.go index 1722ac64bd..2141016ae1 100644 --- a/kotsadm/pkg/gitops/gitops.go +++ b/kotsadm/pkg/gitops/gitops.go @@ -31,16 +31,16 @@ import ( ) type GitOpsConfig struct { - Provider string `json:"provider"` - RepoURI string `json:"repoUri"` - Hostname string `json:"hostname"` - Path string `json:"path"` - Branch string `json:"branch"` - Format string `json:"format"` - Action string `json:"action"` - PublicKey string `json:"publicKey"` - PrivateKey string `json:"-"` - LastError string `json:"lastError"` + Provider string `json:"provider"` + RepoURI string `json:"repoUri"` + Hostname string `json:"hostname"` + Path string `json:"path"` + Branch string `json:"branch"` + Format string `json:"format"` + Action string `json:"action"` + PublicKey string `json:"publicKey"` + PrivateKey string `json:"-"` + IsConnected bool `json:"isConnected"` } func (g *GitOpsConfig) CommitURL(hash string) string { @@ -155,7 +155,10 @@ func GetDownstreamGitOps(appID string, clusterID string) (*GitOpsConfig, error) Path: configMapData["path"], Format: configMapData["format"], Action: configMapData["action"], - LastError: configMapData["lastError"], + } + + if lastError, ok := configMapData["lastError"]; ok && lastError == "" { + gitOpsConfig.IsConnected = true } return &gitOpsConfig, nil diff --git a/kotsadm/pkg/handlers/app.go b/kotsadm/pkg/handlers/app.go new file mode 100644 index 0000000000..9c11cad2dd --- /dev/null +++ b/kotsadm/pkg/handlers/app.go @@ -0,0 +1,247 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/gorilla/mux" + "github.com/replicatedhq/kots/kotsadm/pkg/app" + "github.com/replicatedhq/kots/kotsadm/pkg/downstream" + downstreamtypes "github.com/replicatedhq/kots/kotsadm/pkg/downstream/types" + "github.com/replicatedhq/kots/kotsadm/pkg/gitops" + "github.com/replicatedhq/kots/kotsadm/pkg/logger" + "github.com/replicatedhq/kots/kotsadm/pkg/version" + versiontypes "github.com/replicatedhq/kots/kotsadm/pkg/version/types" +) + +type GetAppResponse struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + IsAirgap bool `json:"isAirgap"` + CurrentSequence int64 `json:"currentSequence"` + UpstreamURI string `json:"upstreamUri"` + IconURI string `json:"iconUri"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt *time.Time `json:"updatedAt"` + LastUpdateCheckAt string `json:"lastUpdateCheckAt"` + BundleCommand string `json:"bundleCommand"` + HasPreflight bool `json:"hasPreflight"` + IsConfigurable bool `json:"isConfigurable"` + UpdateCheckerSpec string `json:"updateCheckerSpec"` + + IsGitOpsSupported bool `json:"isGitOpsSupported"` + AllowRollback bool `json:"allowRollback"` + AllowSnapshots bool `json:"allowSnapshots"` + LicenseType string `json:"licenseType"` + CurrentVersion *versiontypes.AppVersion `json:"currentVersion"` + + Downstreams []ResponseDownstream `json:"downstreams"` +} + +type ResponseDownstream struct { + Name string `json:"name"` + Links []versiontypes.RealizedLink `json:"links"` + CurrentVersion *downstreamtypes.DownstreamVersion `json:"currentVersion"` + PendingVersions []downstreamtypes.DownstreamVersion `json:"pendingVersions"` + PastVersions []downstreamtypes.DownstreamVersion `json:"pastVersions"` + GitOps ResponseGitOps `json:"gitops"` + Cluster ResponseCluster `json:"cluster"` +} + +type ResponseGitOps struct { + Enabled bool `json:"enabled"` + Provider string `json:"provider"` + Uri string `json:"uri"` + Hostname string `json:"hostname"` + Path string `json:"path"` + Branch string `json:"branch"` + Format string `json:"format"` + Action string `json:"action"` + DeployKey string `json:"deployKey"` + IsConnected bool `json:"isConnected"` +} + +type ResponseCluster struct { + ID string `json:"id"` + Slug string `json:"slug"` +} + +func GetApp(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", "content-type, origin, accept, authorization") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + if err := requireValidSession(w, r); err != nil { + logger.Error(err) + return + } + + appSlug := mux.Vars(r)["appSlug"] + a, err := app.GetFromSlug(appSlug) + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + isGitOpsSupported, err := version.IsGitOpsSupported(a.ID, a.CurrentSequence) + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + allowRollback, err := version.IsAllowRollback(a.ID, a.CurrentSequence) + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + licenseType, err := version.GetLicenseType(a.ID, a.CurrentSequence) + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + currentVersion, err := version.Get(a.ID, a.CurrentSequence) + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + downstreams, err := downstream.ListDownstreamsForApp(a.ID) + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + responseDownstreams := []ResponseDownstream{} + for _, d := range downstreams { + parentSequence, err := downstream.GetCurrentParentSequence(a.ID, d.ClusterID) + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + links, err := version.GetRealizedLinksFromAppSpec(a.ID, parentSequence) + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + currentVersion, err := downstream.GetCurrentVersion(a.ID, d.ClusterID) + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + pendingVersions, err := downstream.GetPendingVersions(a.ID, d.ClusterID) + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + pastVersions, err := downstream.GetPastVersions(a.ID, d.ClusterID) + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + downstreamGitOps, err := gitops.GetDownstreamGitOps(a.ID, d.ClusterID) + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + responseGitOps := ResponseGitOps{} + if downstreamGitOps != nil { + responseGitOps = ResponseGitOps{ + Enabled: true, + Provider: downstreamGitOps.Provider, + Uri: downstreamGitOps.RepoURI, + Hostname: downstreamGitOps.Hostname, + Path: downstreamGitOps.Path, + Branch: downstreamGitOps.Branch, + Format: downstreamGitOps.Format, + Action: downstreamGitOps.Action, + DeployKey: downstreamGitOps.PublicKey, + IsConnected: downstreamGitOps.IsConnected, + } + } + + cluster := ResponseCluster{ + ID: d.ClusterID, + Slug: d.ClusterSlug, + } + + responseDownstream := ResponseDownstream{ + Name: d.Name, + Links: links, + CurrentVersion: currentVersion, + PendingVersions: pendingVersions, + PastVersions: pastVersions, + GitOps: responseGitOps, + Cluster: cluster, + } + + responseDownstreams = append(responseDownstreams, responseDownstream) + } + + // check snapshots for the parent sequence of the deployed version + allowSnapshots := false + if len(downstreams) > 0 { + parentSequence, err := downstream.GetCurrentParentSequence(a.ID, downstreams[0].ClusterID) + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + s, err := version.IsAllowSnapshots(a.ID, parentSequence) + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + allowSnapshots = s + } + + getAppResponse := GetAppResponse{ + ID: a.ID, + Slug: a.Slug, + Name: a.Name, + IsAirgap: a.IsAirgap, + CurrentSequence: a.CurrentSequence, + UpstreamURI: a.UpstreamURI, + IconURI: a.IconURI, + CreatedAt: a.CreatedAt, + UpdatedAt: a.UpdatedAt, + LastUpdateCheckAt: a.LastUpdateCheckAt, + BundleCommand: a.BundleCommand, + HasPreflight: a.HasPreflight, + IsConfigurable: a.IsConfigurable, + UpdateCheckerSpec: a.UpdateCheckerSpec, + IsGitOpsSupported: isGitOpsSupported, + AllowRollback: allowRollback, + AllowSnapshots: allowSnapshots, + LicenseType: licenseType, + CurrentVersion: currentVersion, + Downstreams: responseDownstreams, + } + + JSON(w, http.StatusOK, getAppResponse) +} diff --git a/kotsadm/pkg/handlers/dashboard.go b/kotsadm/pkg/handlers/dashboard.go index 3e63aacdc7..d97ac4c70e 100644 --- a/kotsadm/pkg/handlers/dashboard.go +++ b/kotsadm/pkg/handlers/dashboard.go @@ -51,7 +51,7 @@ func GetAppDashboard(w http.ResponseWriter, r *http.Request) { return } - parentSequence, err := downstream.GetParentSequence(a.ID, clusterID) + parentSequence, err := downstream.GetCurrentParentSequence(a.ID, clusterID) if err != nil { logger.Error(err) w.WriteHeader(500) diff --git a/kotsadm/pkg/handlers/preflight.go b/kotsadm/pkg/handlers/preflight.go index 87cdb1fb54..98090eeacc 100644 --- a/kotsadm/pkg/handlers/preflight.go +++ b/kotsadm/pkg/handlers/preflight.go @@ -112,7 +112,7 @@ func StartPreflightChecks(w http.ResponseWriter, r *http.Request) { go func() { defer os.RemoveAll(archiveDir) - if err := preflight.Run(foundApp.ID, foundApp.CurrentSequence, archiveDir); err != nil { + if err := preflight.Run(foundApp.ID, int64(sequence), archiveDir); err != nil { logger.Error(err) return } diff --git a/kotsadm/pkg/supportbundle/supportbundle.go b/kotsadm/pkg/supportbundle/supportbundle.go index 2561626d2a..266117435a 100644 --- a/kotsadm/pkg/supportbundle/supportbundle.go +++ b/kotsadm/pkg/supportbundle/supportbundle.go @@ -26,6 +26,7 @@ func List(appID string) ([]*types.SupportBundle, error) { if err != nil { return nil, errors.Wrap(err, "failed to query") } + defer rows.Close() supportBundles := []*types.SupportBundle{} diff --git a/kotsadm/pkg/version/metrics.go b/kotsadm/pkg/version/metrics.go index 01a11e34ff..ecf744fd62 100644 --- a/kotsadm/pkg/version/metrics.go +++ b/kotsadm/pkg/version/metrics.go @@ -96,8 +96,8 @@ func GetMetricCharts(appID string, sequence int64) ([]MetricChart, error) { query := `select kots_app_spec from app_version where app_id = $1 and sequence = $2` row := db.QueryRow(query, appID, sequence) - var appSpecStr sql.NullString - if err := row.Scan(&appSpecStr); err != nil { + var kotsAppSpecStr sql.NullString + if err := row.Scan(&kotsAppSpecStr); err != nil { if err == sql.ErrNoRows { return []MetricChart{}, nil } @@ -105,11 +105,11 @@ func GetMetricCharts(appID string, sequence int64) ([]MetricChart, error) { } graphs := DefaultMetricGraphs - if appSpecStr.Valid { + if kotsAppSpecStr.Valid { decode := scheme.Codecs.UniversalDeserializer().Decode - obj, _, err := decode([]byte(appSpecStr.String), nil, nil) + obj, _, err := decode([]byte(kotsAppSpecStr.String), nil, nil) if err != nil { - return nil, errors.Wrap(err, "failed to decode application spec") + return nil, errors.Wrap(err, "failed to decode kots app spec") } a := obj.(*kotsv1beta1.Application) diff --git a/kotsadm/pkg/version/types/types.go b/kotsadm/pkg/version/types/types.go index 876998c164..47fa03df21 100644 --- a/kotsadm/pkg/version/types/types.go +++ b/kotsadm/pkg/version/types/types.go @@ -1,7 +1,20 @@ package types +import ( + "time" +) + type AppVersion struct { - Sequence int64 - UpdateCursor int - VersionLabel string + Sequence int64 `json:"sequence"` + UpdateCursor int `json:"updateCursor"` + VersionLabel string `json:"versionLabel"` + Status string `json:"status"` + CreatedOn *time.Time `json:"createdOn"` + ReleaseNotes string `json:"releaseNotes"` + DeployedAt *time.Time `json:"deployedAt"` +} + +type RealizedLink struct { + Title string `json:"title"` + Uri string `json:"uri"` } diff --git a/kotsadm/pkg/version/version.go b/kotsadm/pkg/version/version.go index c0db15c5ed..c19b610763 100644 --- a/kotsadm/pkg/version/version.go +++ b/kotsadm/pkg/version/version.go @@ -1,7 +1,11 @@ package version import ( + "context" + "database/sql" "encoding/json" + "fmt" + "os" "time" "github.com/pkg/errors" @@ -11,7 +15,16 @@ import ( "github.com/replicatedhq/kots/kotsadm/pkg/gitops" "github.com/replicatedhq/kots/kotsadm/pkg/kotsutil" "github.com/replicatedhq/kots/kotsadm/pkg/persistence" + registrytypes "github.com/replicatedhq/kots/kotsadm/pkg/registry/types" + "github.com/replicatedhq/kots/kotsadm/pkg/render" "github.com/replicatedhq/kots/kotsadm/pkg/version/types" + kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1" + velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/scheme" + applicationv1beta1 "sigs.k8s.io/application/api/v1beta1" + k8sconfig "sigs.k8s.io/controller-runtime/pkg/client/config" ) // GetNextAppSequence determines next available sequence for this app @@ -254,26 +267,71 @@ backup_spec = EXCLUDED.backup_spec` // return the list of versions available for an app func GetVersions(appID string) ([]types.AppVersion, error) { db := persistence.MustGetPGSession() - query := `select sequence, update_cursor, version_label from app_version where app_id = $1 order by update_cursor asc, sequence asc` + query := `select sequence from app_version where app_id = $1 order by update_cursor asc, sequence asc` rows, err := db.Query(query, appID) if err != nil { - return nil, errors.Wrap(err, "query app_version table") + return nil, errors.Wrap(err, "failed to query app_version table") } + defer rows.Close() versions := []types.AppVersion{} - for rows.Next() { - rowVersion := types.AppVersion{} - err = rows.Scan(&rowVersion.Sequence, &rowVersion.UpdateCursor, &rowVersion.VersionLabel) + var sequence int64 + if err := rows.Scan(&sequence); err != nil { + return nil, errors.Wrap(err, "failed to scan sequence from app_version table") + } + + v, err := Get(appID, sequence) if err != nil { - return nil, errors.Wrap(err, "scan row from app_version table") + return nil, errors.Wrap(err, "failed to get version") + } + if v != nil { + versions = append(versions, *v) } - versions = append(versions, rowVersion) } return versions, nil } +func Get(appID string, sequence int64) (*types.AppVersion, error) { + db := persistence.MustGetPGSession() + query := `select sequence, update_cursor, version_label, created_at, release_notes, status, applied_at from app_version where app_id = $1 and sequence = $2` + row := db.QueryRow(query, appID, sequence) + + var updateCursor sql.NullInt32 + var versionLabel sql.NullString + var createdOn sql.NullTime + var releaseNotes sql.NullString + var status sql.NullString + var deployedAt sql.NullTime + + v := types.AppVersion{} + if err := row.Scan(&v.Sequence, &updateCursor, &versionLabel, &createdOn, &releaseNotes, &status, &deployedAt); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, errors.Wrap(err, "failed to scan") + } + + if updateCursor.Valid { + v.UpdateCursor = int(updateCursor.Int32) + } else { + v.UpdateCursor = -1 + } + if createdOn.Valid { + v.CreatedOn = &createdOn.Time + } + if deployedAt.Valid { + v.DeployedAt = &deployedAt.Time + } + + v.VersionLabel = versionLabel.String + v.ReleaseNotes = releaseNotes.String + v.Status = status.String + + return &v, nil +} + // DeployVersion deploys the version for the given sequence func DeployVersion(appID string, sequence int64) error { db := persistence.MustGetPGSession() @@ -302,3 +360,227 @@ func DeployVersion(appID string, sequence int64) error { return nil } + +func IsGitOpsSupported(appID string, sequence int64) (bool, error) { + cfg, err := k8sconfig.GetConfig() + if err != nil { + return false, errors.Wrap(err, "failed to get cluster config") + } + + clientset, err := kubernetes.NewForConfig(cfg) + if err != nil { + return false, errors.Wrap(err, "failed to create kubernetes clientset") + } + + _, err = clientset.CoreV1().Secrets(os.Getenv("POD_NAMESPACE")).Get(context.TODO(), "kotsadm-gitops", metav1.GetOptions{}) + if err == nil { + // gitops secret exists -> gitops is supported + return true, nil + } + + db := persistence.MustGetPGSession() + query := `select kots_license from app_version where app_id = $1 and sequence = $2` + row := db.QueryRow(query, appID, sequence) + + var licenseStr sql.NullString + if err := row.Scan(&licenseStr); err != nil { + if err == sql.ErrNoRows { + return false, nil + } + return false, errors.Wrap(err, "failed to scan") + } + + decode := scheme.Codecs.UniversalDeserializer().Decode + obj, _, err := decode([]byte(licenseStr.String), nil, nil) + if err != nil { + return false, errors.Wrap(err, "failed to decode license yaml") + } + license := obj.(*kotsv1beta1.License) + + return license.Spec.IsGitOpsSupported, nil +} + +func IsAllowRollback(appID string, sequence int64) (bool, error) { + db := persistence.MustGetPGSession() + query := `select kots_app_spec from app_version where app_id = $1 and sequence = $2` + row := db.QueryRow(query, appID, sequence) + + var kotsAppSpecStr sql.NullString + if err := row.Scan(&kotsAppSpecStr); err != nil { + if err == sql.ErrNoRows { + return false, nil + } + return false, errors.Wrap(err, "failed to scan") + } + + decode := scheme.Codecs.UniversalDeserializer().Decode + obj, _, err := decode([]byte(kotsAppSpecStr.String), nil, nil) + if err != nil { + return false, errors.Wrap(err, "failed to decode kots app spec yaml") + } + kotsAppSpec := obj.(*kotsv1beta1.Application) + + return kotsAppSpec.Spec.AllowRollback, nil +} + +func IsAllowSnapshots(appID string, sequence int64) (bool, error) { + db := persistence.MustGetPGSession() + query := `select backup_spec from app_version where app_id = $1 and sequence = $2` + row := db.QueryRow(query, appID, sequence) + + var backupSpecStr sql.NullString + if err := row.Scan(&backupSpecStr); err != nil { + if err == sql.ErrNoRows { + return false, nil + } + return false, errors.Wrap(err, "failed to scan") + } + + if backupSpecStr.String == "" { + return false, nil + } + + archiveDir, err := GetAppVersionArchive(appID, sequence) + if err != nil { + return false, errors.Wrap(err, "failed to get app version archive") + } + + kotsKinds, err := kotsutil.LoadKotsKindsFromPath(archiveDir) + if err != nil { + return false, errors.Wrap(err, "failed to load kots kinds from path") + } + + registrySettings, err := getRegistrySettingsForApp(appID) + if err != nil { + return false, errors.Wrap(err, "failed to get registry settings for app") + } + + rendered, err := render.RenderFile(kotsKinds, registrySettings, []byte(backupSpecStr.String)) + if err != nil { + return false, errors.Wrap(err, "failed to render backup spec") + } + + decode := scheme.Codecs.UniversalDeserializer().Decode + obj, _, err := decode(rendered, nil, nil) + if err != nil { + return false, errors.Wrap(err, "failed to decode rendered backup spec yaml") + } + backupSpec := obj.(*velerov1.Backup) + + annotations := backupSpec.ObjectMeta.Annotations + if annotations == nil { + // Backup exists and there are no annotation overrides so snapshots are enabled + return true, nil + } + + if exclude, ok := annotations["kots.io/exclude"]; ok && exclude == "true" { + return false, nil + } + + if when, ok := annotations["kots.io/when"]; ok && when == "false" { + return false, nil + } + + return true, nil +} + +func GetLicenseType(appID string, sequence int64) (string, error) { + db := persistence.MustGetPGSession() + query := `select kots_license from app_version where app_id = $1 and sequence = $2` + row := db.QueryRow(query, appID, sequence) + + var licenseStr sql.NullString + if err := row.Scan(&licenseStr); err != nil { + if err == sql.ErrNoRows { + return "", nil + } + return "", errors.Wrap(err, "failed to scan") + } + + decode := scheme.Codecs.UniversalDeserializer().Decode + obj, _, err := decode([]byte(licenseStr.String), nil, nil) + if err != nil { + return "", errors.Wrap(err, "failed to decode license yaml") + } + license := obj.(*kotsv1beta1.License) + + return license.Spec.LicenseType, nil +} + +func GetRealizedLinksFromAppSpec(appID string, sequence int64) ([]types.RealizedLink, error) { + db := persistence.MustGetPGSession() + query := `select app_spec, kots_app_spec from app_version where app_id = $1 and sequence = $2` + row := db.QueryRow(query, appID, sequence) + + var appSpecStr sql.NullString + var kotsAppSpecStr sql.NullString + if err := row.Scan(&appSpecStr, &kotsAppSpecStr); err != nil { + if err == sql.ErrNoRows { + return []types.RealizedLink{}, nil + } + return nil, errors.Wrap(err, "failed to scan") + } + + if appSpecStr.String == "" { + return []types.RealizedLink{}, nil + } + + decode := scheme.Codecs.UniversalDeserializer().Decode + obj, _, err := decode([]byte(appSpecStr.String), nil, nil) + if err != nil { + return nil, errors.Wrap(err, "failed to decode app spec yaml") + } + appSpec := obj.(*applicationv1beta1.Application) + + obj, _, err = decode([]byte(kotsAppSpecStr.String), nil, nil) + if err != nil { + return nil, errors.Wrap(err, "failed to decode kots app spec yaml") + } + kotsAppSpec := obj.(*kotsv1beta1.Application) + + realizedLinks := []types.RealizedLink{} + for _, link := range appSpec.Spec.Descriptor.Links { + rewrittenURL := link.URL + for _, port := range kotsAppSpec.Spec.ApplicationPorts { + if port.ApplicationURL == link.URL { + rewrittenURL = fmt.Sprintf("http://localhost:%d", port.LocalPort) + } + } + realizedLink := types.RealizedLink{ + Title: link.Description, + Uri: rewrittenURL, + } + realizedLinks = append(realizedLinks, realizedLink) + } + + return realizedLinks, nil +} + +// this is a copy from registry. so many import cycles to unwind here, todo +func getRegistrySettingsForApp(appID string) (*registrytypes.RegistrySettings, error) { + db := persistence.MustGetPGSession() + query := `select registry_hostname, registry_username, registry_password_enc, namespace from app where id = $1` + row := db.QueryRow(query, appID) + + var registryHostname sql.NullString + var registryUsername sql.NullString + var registryPasswordEnc sql.NullString + var registryNamespace sql.NullString + + if err := row.Scan(®istryHostname, ®istryUsername, ®istryPasswordEnc, ®istryNamespace); err != nil { + return nil, errors.Wrap(err, "failed to scan registry") + } + + if !registryHostname.Valid { + return nil, nil + } + + registrySettings := registrytypes.RegistrySettings{ + Hostname: registryHostname.String, + Username: registryUsername.String, + PasswordEnc: registryPasswordEnc.String, + Namespace: registryNamespace.String, + } + + return ®istrySettings, nil +} diff --git a/kotsadm/web/src/components/UploadAirgapBundle.jsx b/kotsadm/web/src/components/UploadAirgapBundle.jsx index 3f503602c2..a260ffef84 100644 --- a/kotsadm/web/src/components/UploadAirgapBundle.jsx +++ b/kotsadm/web/src/components/UploadAirgapBundle.jsx @@ -12,7 +12,6 @@ import LicenseUploadProgress from "./LicenseUploadProgress"; import AirgapRegistrySettings from "./shared/AirgapRegistrySettings"; import { Utilities } from "../utilities/utilities"; import { getSupportBundleCommand } from "../queries/TroubleshootQueries"; -import { getKotsApp } from "../queries/AppsQueries"; import "../scss/components/troubleshoot/UploadSupportBundleModal.scss"; import "../scss/components/Login.scss"; @@ -323,31 +322,34 @@ class UploadAirgapBundle extends React.Component { await onUploadSuccess(); - const app = await this.getKotsApp(match.params.slug); + const app = await this.getApp(match.params.slug); - if (app.isConfigurable) { + if (app?.isConfigurable) { this.props.history.replace(`/${app.slug}/config`); - } else if (app.hasPreflight) { + } else if (app?.hasPreflight) { this.props.history.replace(`/preflight`); } else { this.props.history.replace(`/app/${app.slug}`); } } - getKotsApp = (slug) => { - return new Promise((resolve, reject) => { - this.props.client.query({ - query: getKotsApp, - variables: { - slug: slug, + getApp = async (slug) => { + try { + const res = await fetch(`${window.env.API_ENDPOINT}/apps/app/${slug}`, { + headers: { + "Authorization": Utilities.getToken(), + "Content-Type": "application/json", }, - fetchPolicy: "no-cache" - }).then(response => { - resolve(response.data.getKotsApp); - }).catch((error) => { - reject(error); + method: "GET", }); - }); + if (res.ok && res.status == 200) { + const app = await res.json(); + return app; + } + } catch(err) { + console.log(err); + } + return null; } toggleViewOnlineInstallErrorMessage = () => { diff --git a/kotsadm/web/src/components/apps/AppConfig.jsx b/kotsadm/web/src/components/apps/AppConfig.jsx index 5a9f13b4ed..2e7fd49d7f 100644 --- a/kotsadm/web/src/components/apps/AppConfig.jsx +++ b/kotsadm/web/src/components/apps/AppConfig.jsx @@ -9,7 +9,7 @@ import debounce from "lodash/debounce"; import map from "lodash/map"; import Modal from "react-modal"; import Loader from "../shared/Loader"; -import { getAppConfigGroups, getKotsApp, templateConfigGroups } from "../../queries/AppsQueries"; +import { getAppConfigGroups, templateConfigGroups } from "../../queries/AppsQueries"; import "../../scss/components/watches/WatchConfig.scss"; import { Utilities } from "../../utilities/utilities"; @@ -28,7 +28,8 @@ class AppConfig extends Component { savingConfig: false, changed: false, showNextStepModal: false, - savingConfigError: "" + savingConfigError: "", + app: null, } this.handleConfigChange = debounce(this.handleConfigChange, 250); @@ -36,22 +37,50 @@ class AppConfig extends Component { componentWillMount() { const { app, history } = this.props; - if (app && !app.isConfigurable) { // app not configurable - redirect + if (app && !app.isConfigurable) { + // app not configurable - redirect history.replace(`/app/${app.slug}`); } } + componentDidMount() { + if (!this.props.app) { + this.getApp(); + } + } + componentDidUpdate(lastProps) { const { getAppConfigGroups } = this.props.getAppConfigGroups; if (getAppConfigGroups && getAppConfigGroups !== lastProps.getAppConfigGroups.getAppConfigGroups) { const initialConfigGroups = JSON.parse(JSON.stringify(getAppConfigGroups)); // quick deep copy this.setState({ configGroups: getAppConfigGroups, initialConfigGroups }); } - if (this.props.getKotsApp) { - const { getKotsApp } = this.props.getKotsApp; - if (getKotsApp && !getKotsApp.isConfigurable) { // app not configurable - redirect - this.props.history.replace(`/app/${getKotsApp.slug}`); + if (this.state.app && !this.state.app.isConfigurable) { + // app not configurable - redirect + this.props.history.replace(`/app/${this.state.app.slug}`); + } + } + + getApp = async () => { + if (this.props.app) { + return; + } + + try { + const { slug } = this.props.match.params; + const res = await fetch(`${window.env.API_ENDPOINT}/apps/app/${slug}`, { + headers: { + "Authorization": Utilities.getToken(), + "Content-Type": "application/json", + }, + method: "GET", + }); + if (res.ok && res.status == 200) { + const app = await res.json(); + this.setState({ app }); } + } catch(err) { + console.log(err); } } @@ -90,7 +119,7 @@ class AppConfig extends Component { handleSave = async () => { this.setState({ savingConfig: true, savingConfigError: "" }); - const { fromLicenseFlow, history, getKotsApp, match } = this.props; + const { fromLicenseFlow, history, match } = this.props; const sequence = this.getSequence(); const slug = this.getSlug(); const createNewVersion = !fromLicenseFlow && match.params.sequence == undefined; @@ -126,7 +155,7 @@ class AppConfig extends Component { } if (fromLicenseFlow) { - const hasPreflight = getKotsApp?.getKotsApp?.hasPreflight; + const hasPreflight = this.state.app?.hasPreflight; if (hasPreflight) { history.replace("/preflight"); } else { @@ -209,9 +238,11 @@ class AppConfig extends Component { render() { const { configGroups, savingConfig, changed, showNextStepModal, savingConfigError } = this.state; - const { fromLicenseFlow, getKotsApp, match } = this.props; + const { fromLicenseFlow, match } = this.props; - if (!configGroups.length || getKotsApp?.loading) { + const app = this.props.app || this.state.app; + + if (!configGroups.length || !app) { return (
@@ -219,7 +250,6 @@ class AppConfig extends Component { ); } - const app = this.props.app || getKotsApp?.getKotsApp; const gitops = app?.downstreams?.length && app.downstreams[0]?.gitops; const isNewVersion = !fromLicenseFlow && match.params.sequence == undefined; @@ -313,17 +343,4 @@ export default withRouter(compose( } } }), - graphql(getKotsApp, { - name: "getKotsApp", - skip: ({ app }) => !!app, - options: ({ match }) => { - const slug = match.params.slug; - return { - variables: { - slug, - }, - fetchPolicy: "no-cache" - } - } - }), )(AppConfig)); diff --git a/kotsadm/web/src/components/apps/AppDetailPage.jsx b/kotsadm/web/src/components/apps/AppDetailPage.jsx index 44a707ce58..91482d4745 100644 --- a/kotsadm/web/src/components/apps/AppDetailPage.jsx +++ b/kotsadm/web/src/components/apps/AppDetailPage.jsx @@ -4,10 +4,9 @@ import { withRouter, Switch, Route } from "react-router-dom"; import { graphql, compose, withApollo } from "react-apollo"; import { Helmet } from "react-helmet"; import Modal from "react-modal"; -import has from "lodash/has"; import withTheme from "@src/components/context/withTheme"; -import { getKotsApp, listDownstreamsForApp } from "@src/queries/AppsQueries"; +import { listDownstreamsForApp } from "@src/queries/AppsQueries"; import { isVeleroInstalled } from "@src/queries/SnapshotQueries"; import { createKotsDownstream } from "../../mutations/AppsMutations"; import { KotsSidebarItem } from "@src/components/watches/WatchSidebarItem"; @@ -18,6 +17,7 @@ import CodeSnippet from "../shared/CodeSnippet"; import DownstreamTree from "../../components/tree/KotsApplicationTree"; import AppVersionHistory from "./AppVersionHistory"; import { isAwaitingResults, Utilities } from "../../utilities/utilities"; +import { Repeater } from "../../utilities/repeater"; import PreflightResultPage from "../PreflightResultPage"; import AppConfig from "./AppConfig"; import AppLicense from "./AppLicense"; @@ -45,47 +45,49 @@ class AppDetailPage extends Component { watchToEdit: {}, existingDeploymentClusters: [], displayDownloadCommandModal: false, - isBundleUploading: false + isBundleUploading: false, + app: null, + loadingApp: true, + getAppJob: new Repeater(), } } - static defaultProps = { - getKotsAppQuery: { - loading: true + componentDidUpdate(_, lastState) { + const { getThemeState, setThemeState, match, listApps, history } = this.props; + const { app, loadingApp } = this.state; + + // Used for a fresh reload + if (history.location.pathname === "/apps") { + this.checkForFirstApp(); + return; } - } - componentDidUpdate(lastProps) { - const { getThemeState, setThemeState, match, listApps, history } = this.props; - const slug = `${match.params.owner}/${match.params.slug}`; - const currentWatch = listApps?.find(w => w.slug === slug); + // Refetch app info when switching between apps + if (app && !loadingApp && match.params.slug != app.slug) { + this.getApp(); + return; + } - // Handle updating the app theme state when a watch changes. - if (currentWatch?.watchIcon) { + // Handle updating the theme state when switching apps. + const currentApp = listApps?.find(w => w.slug === match.params.slug); + if (currentApp?.iconUri) { const { navbarLogo, ...rest } = getThemeState(); - if (navbarLogo === null || navbarLogo !== currentWatch.watchIcon) { - + if (navbarLogo === null || navbarLogo !== currentApp.iconUri) { setThemeState({ ...rest, - navbarLogo: currentWatch.watchIcon + navbarLogo: currentApp.iconUri }); } } - // Used for a fresh reload - if (history.location.pathname === "/apps") { - this.checkForFirstApp(); - } - - // enforce initial app configuration (if exists) - const { getKotsAppQuery } = this.props; - if (getKotsAppQuery?.getKotsApp !== lastProps?.getKotsAppQuery?.getKotsApp && getKotsAppQuery?.getKotsApp) { - const app = getKotsAppQuery?.getKotsApp; + // Enforce initial app configuration (if exists) + if (app !== lastState.app && app) { const downstream = app.downstreams?.length && app.downstreams[0]; if (downstream?.pendingVersions?.length) { const firstVersion = downstream.pendingVersions.find(version => version?.sequence === 0); if (firstVersion?.status === "pending_config") { this.props.history.push(`/${app.slug}/config`); + return; } } } @@ -94,6 +96,7 @@ class AppDetailPage extends Component { componentWillUnmount() { clearInterval(this.interval); this.props.clearThemeState(); + this.state.getAppJob.stop(); } makeCurrentRelease = async (upstreamSlug, sequence) => { @@ -105,7 +108,7 @@ class AppDetailPage extends Component { }, method: "POST", }); - this.refetchGraphQLData(); + this.refetchData(); } catch(err) { console.log(err); } @@ -119,25 +122,41 @@ class AppDetailPage extends Component { this.setState({ isBundleUploading: isUploading }); } - createDownstreamForCluster = () => { - const { clusterParentSlug } = this.state; - localStorage.setItem("clusterRedirect", `/watch/${clusterParentSlug}/downstreams?add=1`); - this.props.history.push("/cluster/create"); - } + getApp = async (slug = this.props.match.params.slug) => { + if (!slug) { + return; + } - handleViewFiles = () => { - const { slug } = this.props.match.params; - const currentSequence = this.props.getKotsAppQuery?.getKotsApp?.currentSequence; - this.props.history.push(`/app/${slug}/tree/${currentSequence}`); + try { + this.setState({ loadingApp: true }); + + const res = await fetch(`${window.env.API_ENDPOINT}/apps/app/${slug}`, { + headers: { + "Authorization": Utilities.getToken(), + "Content-Type": "application/json", + }, + method: "GET", + }); + if (res.ok && res.status == 200) { + const app = await res.json(); + this.setState({ app, loadingApp: false }); + } else { + console.log("failed to get app, unexpected status code", res.status); + this.setState({ loadingApp: false }); + } + } catch(err) { + console.log(err); + this.setState({ loadingApp: false }); + } } /** - * Refetch all the GraphQL data for this component and all its children + * Refetch all the data for this component and all its children * * @return {undefined} */ - refetchGraphQLData = () => { - this.props.getKotsAppQuery.refetch(); + refetchData = () => { + this.getApp(); this.props.refetchListApps(); } @@ -154,6 +173,7 @@ class AppDetailPage extends Component { if (firstApp) { history.replace(`/app/${firstApp.slug}`); + this.getApp(firstApp.slug); } else { history.replace("/upload-license"); } @@ -163,21 +183,25 @@ class AppDetailPage extends Component { const { history } = this.props; if (history.location.pathname === "/apps") { - return this.checkForFirstApp(); + this.checkForFirstApp(); + return; } + + this.getApp(); } render() { const { match, - getKotsAppQuery, listApps, refetchListApps, rootDidInitialAppFetch, appName, isVeleroInstalled } = this.props; + const { + app, displayDownloadCommandModal, isBundleUploading } = this.state; @@ -188,25 +212,17 @@ class AppDetailPage extends Component {
); - const app = getKotsAppQuery?.getKotsApp; - - const refreshAppData = getKotsAppQuery.refetch; - - // if there is app, don't render a loader to avoid flickering - const loading = (getKotsAppQuery?.loading || !rootDidInitialAppFetch || isVeleroInstalled?.loading) && !app; - if (!rootDidInitialAppFetch) { return centeredLoader; } const downstream = app?.downstreams?.length && app.downstreams[0]; if (downstream?.currentVersion && isAwaitingResults([downstream.currentVersion])) { - getKotsAppQuery?.startPolling(2000); - } else if (has(getKotsAppQuery, "stopPolling")) { - getKotsAppQuery?.stopPolling(); + this.state.getAppJob.start(this.getApp, 2000); + } else { + this.state.getAppJob.stop(); } - return (
@@ -245,7 +261,7 @@ class AppDetailPage extends Component { /> )}>
- {loading + {!app ? centeredLoader : ( @@ -261,14 +277,13 @@ class AppDetailPage extends Component { app={app} cluster={app.downstreams?.length && app.downstreams[0]?.cluster} refetchListApps={refetchListApps} - refetchWatch={this.props.getKotsAppQuery?.refetch} - updateCallback={this.refetchGraphQLData} + updateCallback={this.refetchData} onActiveInitSession={this.props.onActiveInitSession} makeCurrentVersion={this.makeCurrentRelease} toggleIsBundleUploading={this.toggleIsBundleUploading} isBundleUploading={isBundleUploading} isVeleroInstalled={isVeleroInstalled?.isVeleroInstalled} - refreshAppData={refreshAppData} + refreshAppData={this.getApp} snapshotInProgressApps={this.props.snapshotInProgressApps} ping={this.props.ping} />} @@ -281,17 +296,17 @@ class AppDetailPage extends Component { app={app} match={this.props.match} makeCurrentVersion={this.makeCurrentRelease} - updateCallback={this.refetchGraphQLData} + updateCallback={this.refetchData} toggleIsBundleUploading={this.toggleIsBundleUploading} isBundleUploading={isBundleUploading} - refreshAppData={refreshAppData} + refreshAppData={this.getApp} /> } /> } /> } /> @@ -303,7 +318,7 @@ class AppDetailPage extends Component { } /> @@ -315,13 +330,13 @@ class AppDetailPage extends Component { this.props.getKotsAppQuery.refetch()} + refetch={this.getApp} /> } /> this.props.getKotsAppQuery.refetch()} + refetch={this.getApp} /> } /> @@ -376,29 +391,6 @@ export default compose( withApollo, withRouter, withTheme, - graphql(getKotsApp, { - name: "getKotsAppQuery", - skip: props => { - const { slug } = props.match.params; - - // Skip if no variables (user at "/watches" URL) - if (!slug) { - return true; - } - - return false; - - }, - options: props => { - const { slug } = props.match.params; - return { - fetchPolicy: "no-cache", - variables: { - slug: slug - } - } - } - }), graphql(listDownstreamsForApp, { name: "listDownstreamsForAppQuery", skip: props => { diff --git a/kotsadm/web/src/components/apps/AppVersionHistory.jsx b/kotsadm/web/src/components/apps/AppVersionHistory.jsx index 6c934854b3..b9f19e678b 100644 --- a/kotsadm/web/src/components/apps/AppVersionHistory.jsx +++ b/kotsadm/web/src/components/apps/AppVersionHistory.jsx @@ -143,7 +143,7 @@ class AppVersionHistory extends Component { renderYamlErrors = (yamlErrorsDetails, version) => { return ( -
+
{yamlErrorsDetails?.length} Invalid files this.toggleShowDetailsModal(yamlErrorsDetails, version.sequence)}> See details @@ -292,8 +292,8 @@ class AppVersionHistory extends Component {

- {app.currentVersion ? app.currentVersion.title : "---"} + {app.currentVersion ? app.currentVersion.versionLabel : "---"}

{app.currentVersion ? "Current upstream version" : "No deployments have been made"}

@@ -966,7 +966,7 @@ class AppVersionHistory extends Component {

Received: {moment(currentDownstreamVersion.createdOn).format("MM/DD/YY @ hh:mm a")}

-

Upstream: {currentDownstreamVersion.title}

+

Upstream: {currentDownstreamVersion.versionLabel}

Sequence: {this.renderVersionSequence(currentDownstreamVersion)}
@@ -1016,7 +1016,7 @@ class AppVersionHistory extends Component {

Received: {moment(version.createdOn).format("MM/DD/YY @ hh:mm a")}

-

Upstream: {version.title}

+

Upstream: {version.versionLabel || version.title}

Sequence: {this.renderVersionSequence(version)}
diff --git a/kotsadm/web/src/components/apps/Dashboard.jsx b/kotsadm/web/src/components/apps/Dashboard.jsx index 3980368521..e74cd1078b 100644 --- a/kotsadm/web/src/components/apps/Dashboard.jsx +++ b/kotsadm/web/src/components/apps/Dashboard.jsx @@ -2,7 +2,7 @@ import moment from "moment"; import React, { Component } from "react"; import Helmet from "react-helmet"; import { withRouter } from "react-router-dom"; -import { graphql, compose, withApollo } from "react-apollo"; +import { compose, withApollo } from "react-apollo"; import size from "lodash/size"; import get from "lodash/get"; import Loader from "../shared/Loader"; @@ -12,7 +12,7 @@ import UpdateCheckerModal from "@src/components/modals/UpdateCheckerModal"; import Modal from "react-modal"; import { Repeater } from "../../utilities/repeater"; import { Utilities } from "../../utilities/utilities"; -import { getAppLicense, getUpdateDownloadStatus } from "@src/queries/AppsQueries"; +import { getUpdateDownloadStatus } from "@src/queries/AppsQueries"; import { XYPlot, XAxis, YAxis, HorizontalGridLines, VerticalGridLines, LineSeries, DiscreteColorLegend, Crosshair } from "react-vis"; @@ -110,35 +110,39 @@ class Dashboard extends Component { componentDidUpdate(lastProps) { const { app } = this.props; - if (app !== lastProps.app && app) { this.setWatchState(app) } + } - if (this.props.getAppLicense !== lastProps.getAppLicense && this.props.getAppLicense) { - if (this.props.getAppLicense?.getAppLicense === null) { + getAppLicense = async (app) => { + await fetch(`${window.env.API_ENDPOINT}/app/${app.slug}/license`, { + method: "GET", + headers: { + "Authorization": Utilities.getToken(), + "Content-Type": "application/json", + } + }).then(async (res) => { + const body = await res.json(); + if (body === null) { this.setState({ appLicense: {} }); } else { - const { getAppLicense } = this.props.getAppLicense; - if (getAppLicense) { - this.setState({ appLicense: getAppLicense }); - } + this.setState({ appLicense: body }); } - } + }).catch((err) => { + console.log(err) + }); } componentDidMount() { const { app } = this.props; - const { getAppLicense } = this.props.getAppLicense; this.state.updateChecker.start(this.updateStatus, 1000); this.state.getAppDashboardJob.start(this.getAppDashboard, 2000); if (app) { this.setWatchState(app); - } - if (getAppLicense) { - this.setState({ appLicense: getAppLicense }); + this.getAppLicense(app); } } @@ -222,8 +226,8 @@ class Dashboard extends Component { }); if (res.data.getUpdateDownloadStatus.status !== "running" && !this.props.isBundleUploading) { - this.state.updateChecker.stop(); + this.setState({ checkingForUpdates: false, checkingUpdateMessage: res.data.getUpdateDownloadStatus?.currentMessage, @@ -233,11 +237,9 @@ class Dashboard extends Component { if (this.props.updateCallback) { this.props.updateCallback(); } - // this.props.data.refetch(); } resolve(); - }).catch((err) => { console.log("failed to get rewrite status", err); reject(); @@ -558,7 +560,7 @@ class Dashboard extends Component { app={app} /> { - return { - variables: { - appId: app.id - }, - fetchPolicy: "no-cache", - errorPolicy: "ignore" - }; - } - }), -)(Dashboard); +export default compose(withApollo, withRouter)(Dashboard); diff --git a/kotsadm/web/src/queries/AppsQueries.js b/kotsadm/web/src/queries/AppsQueries.js index b83cdb19c0..9983b5efbf 100644 --- a/kotsadm/web/src/queries/AppsQueries.js +++ b/kotsadm/web/src/queries/AppsQueries.js @@ -390,23 +390,6 @@ export const getKotsDownstreamOutput = gql` } `; -export const getAppLicense = gql` - query getAppLicense($appId: String!) { - getAppLicense(appId: $appId) { - id - expiresAt - channelName - licenseSequence - licenseType - entitlements { - title - value - label - } - } - } -`; - export const templateConfigGroups = gql` query templateConfigGroups($slug: String!, $sequence: Int!, $configGroups: [KotsConfigGroupInput]!) { templateConfigGroups(slug: $slug, sequence: $sequence, configGroups: $configGroups) { diff --git a/kotsadm/web/src/scss/components/apps/AppVersionHistory.scss b/kotsadm/web/src/scss/components/apps/AppVersionHistory.scss index d527857147..bba1a05b50 100644 --- a/kotsadm/web/src/scss/components/apps/AppVersionHistory.scss +++ b/kotsadm/web/src/scss/components/apps/AppVersionHistory.scss @@ -22,7 +22,6 @@ $cell-width: 140px; .DiffSummary { font-size: 12px; - margin-top: 5px; .files { color: #9B9B9B;