Explorar o código

Merge branch 'master' of github.com:porter-dev/porter into 0.7.0-hpa-metrics

jnfrati %!s(int64=4) %!d(string=hai) anos
pai
achega
1ed24d99b8

+ 10 - 5
cli/cmd/api/api.go

@@ -144,11 +144,11 @@ type TokenProjectID struct {
 	ProjectID uint `json:"project_id"`
 	ProjectID uint `json:"project_id"`
 }
 }
 
 
-func GetProjectIDFromToken(token string) (uint, error) {
+func GetProjectIDFromToken(token string) (uint, bool, error) {
 	var encoded string
 	var encoded string
 
 
 	if tokenSplit := strings.Split(token, "."); len(tokenSplit) != 3 {
 	if tokenSplit := strings.Split(token, "."); len(tokenSplit) != 3 {
-		return 0, fmt.Errorf("invalid jwt token format")
+		return 0, false, fmt.Errorf("invalid jwt token format")
 	} else {
 	} else {
 		encoded = tokenSplit[1]
 		encoded = tokenSplit[1]
 	}
 	}
@@ -156,7 +156,7 @@ func GetProjectIDFromToken(token string) (uint, error) {
 	decodedBytes, err := base64.RawStdEncoding.DecodeString(encoded)
 	decodedBytes, err := base64.RawStdEncoding.DecodeString(encoded)
 
 
 	if err != nil {
 	if err != nil {
-		return 0, fmt.Errorf("could not decode jwt token from base64: %v", err)
+		return 0, false, fmt.Errorf("could not decode jwt token from base64: %v", err)
 	}
 	}
 
 
 	res := &TokenProjectID{}
 	res := &TokenProjectID{}
@@ -164,8 +164,13 @@ func GetProjectIDFromToken(token string) (uint, error) {
 	err = json.Unmarshal(decodedBytes, res)
 	err = json.Unmarshal(decodedBytes, res)
 
 
 	if err != nil {
 	if err != nil {
-		return 0, fmt.Errorf("could not get token project id: %v", err)
+		return 0, false, fmt.Errorf("could not get token project id: %v", err)
 	}
 	}
 
 
-	return res.ProjectID, nil
+	// if the project ID is 0, this is a token signed for a user, not a specific project
+	if res.ProjectID == 0 {
+		return 0, false, nil
+	}
+
+	return res.ProjectID, true, nil
 }
 }

+ 59 - 52
cli/cmd/auth.go

@@ -56,7 +56,6 @@ var logoutCmd = &cobra.Command{
 	},
 	},
 }
 }
 
 
-var token string = ""
 var manual bool = false
 var manual bool = false
 
 
 func init() {
 func init() {
@@ -80,18 +79,40 @@ func login() error {
 	user, _ := client.AuthCheck(context.Background())
 	user, _ := client.AuthCheck(context.Background())
 
 
 	if user != nil {
 	if user != nil {
+		// set the token if the user calls login with the --token flag or the PORTER_TOKEN env
 		if config.Token != "" {
 		if config.Token != "" {
-			// set the token if the user calls login with the --token flag
 			config.SetToken(config.Token)
 			config.SetToken(config.Token)
 			color.New(color.FgGreen).Println("Successfully logged in!")
 			color.New(color.FgGreen).Println("Successfully logged in!")
 
 
-			projID, err := api.GetProjectIDFromToken(config.Token)
+			projID, exists, err := api.GetProjectIDFromToken(config.Token)
 
 
 			if err != nil {
 			if err != nil {
 				return err
 				return err
 			}
 			}
 
 
-			config.SetProject(projID)
+			// if project ID does not exist for the token, this is a user-issued CLI token, so the project
+			// ID should be queried
+			if !exists {
+				err = setProjectForUser(client, user.ID)
+
+				if err != nil {
+					return err
+				}
+			} else {
+				// if the project ID does exist for the token, this is a project-issued token, and
+				// the project should be set automatically
+				err = config.SetProject(projID)
+
+				if err != nil {
+					return err
+				}
+
+				err = setProjectCluster(client, projID)
+
+				if err != nil {
+					return err
+				}
+			}
 		} else {
 		} else {
 			color.Yellow("You are already logged in. If you'd like to log out, run \"porter auth logout\".")
 			color.Yellow("You are already logged in. If you'd like to log out, run \"porter auth logout\".")
 		}
 		}
@@ -104,70 +125,50 @@ func login() error {
 		return loginManual()
 		return loginManual()
 	}
 	}
 
 
-	// check for a token
-	var err error
-
-	if token == "" {
-		token, err = loginBrowser.Login(config.Host)
+	// log the user in
+	token, err := loginBrowser.Login(config.Host)
 
 
-		if err != nil {
-			return err
-		}
-
-		// set the token in config
-		err = config.SetToken(token)
-
-		if err != nil {
-			return err
-		}
-
-		client := api.NewClientWithToken(config.Host+"/api", token)
+	if err != nil {
+		return err
+	}
 
 
-		user, err := client.AuthCheck(context.Background())
+	// set the token in config
+	err = config.SetToken(token)
 
 
-		if user == nil {
-			color.Red("Invalid token.")
-			return err
-		}
+	if err != nil {
+		return err
+	}
 
 
-		color.New(color.FgGreen).Println("Successfully logged in!")
+	client = api.NewClientWithToken(config.Host+"/api", token)
 
 
-		// get a list of projects, and set the current project
-		projects, err := client.ListUserProjects(context.Background(), user.ID)
+	user, err = client.AuthCheck(context.Background())
 
 
-		if err != nil {
-			return err
-		}
-
-		if len(projects) > 0 {
-			config.SetProject(projects[0].ID)
-		}
-	} else {
-		// set the token in config
-		err = config.SetToken(token)
+	if user == nil {
+		color.Red("Invalid token.")
+		return err
+	}
 
 
-		if err != nil {
-			return err
-		}
+	color.New(color.FgGreen).Println("Successfully logged in!")
 
 
-		client := api.NewClientWithToken(config.Host+"/api", token)
+	return setProjectForUser(client, user.ID)
+}
 
 
-		user, err := client.AuthCheck(context.Background())
+func setProjectForUser(client *api.Client, userID uint) error {
+	// get a list of projects, and set the current project
+	projects, err := client.ListUserProjects(context.Background(), userID)
 
 
-		if user == nil {
-			color.Red("Invalid token.")
-			return err
-		}
+	if err != nil {
+		return err
+	}
 
 
-		color.New(color.FgGreen).Println("Successfully logged in!")
+	if len(projects) > 0 {
+		config.SetProject(projects[0].ID)
 
 
-		projID, err := api.GetProjectIDFromToken(token)
+		err = setProjectCluster(client, projects[0].ID)
 
 
 		if err != nil {
 		if err != nil {
 			return err
 			return err
 		}
 		}
-
-		config.SetProject(projID)
 	}
 	}
 
 
 	return nil
 	return nil
@@ -215,6 +216,12 @@ func loginManual() error {
 
 
 	if len(projects) > 0 {
 	if len(projects) > 0 {
 		config.SetProject(projects[0].ID)
 		config.SetProject(projects[0].ID)
+
+		err = setProjectCluster(client, projects[0].ID)
+
+		if err != nil {
+			return err
+		}
 	}
 	}
 
 
 	return nil
 	return nil

+ 19 - 3
cli/cmd/deploy/deploy.go

@@ -91,10 +91,10 @@ func NewDeployAgent(client *api.Client, app string, opts *DeployOpts) (*DeployAg
 			// is docker
 			// is docker
 			if release.GitActionConfig.DockerfilePath != "" {
 			if release.GitActionConfig.DockerfilePath != "" {
 				deployAgent.opts.Method = DeployBuildTypeDocker
 				deployAgent.opts.Method = DeployBuildTypeDocker
+			} else {
+				// otherwise build type is pack
+				deployAgent.opts.Method = DeployBuildTypePack
 			}
 			}
-
-			// otherwise build type is pack
-			deployAgent.opts.Method = DeployBuildTypePack
 		} else {
 		} else {
 			// if the git action config does not exist, we use docker by default
 			// if the git action config does not exist, we use docker by default
 			deployAgent.opts.Method = DeployBuildTypeDocker
 			deployAgent.opts.Method = DeployBuildTypeDocker
@@ -279,6 +279,22 @@ func (d *DeployAgent) UpdateImageAndValues(overrideValues map[string]interface{}
 	// overwrite the tag based on a new image
 	// overwrite the tag based on a new image
 	currImageSection := mergedValues["image"].(map[string]interface{})
 	currImageSection := mergedValues["image"].(map[string]interface{})
 
 
+	// if the current image section is hello-porter, the image must be overriden
+	if currImageSection["repository"] == "public.ecr.aws/o1j4x7p4/hello-porter" ||
+		currImageSection["repository"] == "public.ecr.aws/o1j4x7p4/hello-porter-job" {
+		newImage, err := d.getReleaseImage()
+
+		if err != nil {
+			return fmt.Errorf("could not overwrite hello-porter image: %s", err.Error())
+		}
+
+		currImageSection["repository"] = newImage
+
+		// set to latest just to be safe -- this will be overriden if "d.tag" is set in
+		// the agent
+		currImageSection["tag"] = "latest"
+	}
+
 	if d.tag != "" && currImageSection["tag"] != d.tag {
 	if d.tag != "" && currImageSection["tag"] != d.tag {
 		currImageSection["tag"] = d.tag
 		currImageSection["tag"] = d.tag
 	}
 	}

+ 8 - 2
cli/cmd/login/server.go

@@ -19,9 +19,15 @@ func redirect(
 		w.Header().Set("Content-Type", "text/html; charset=utf-8")
 		w.Header().Set("Content-Type", "text/html; charset=utf-8")
 		fmt.Fprint(w, successScreen)
 		fmt.Fprint(w, successScreen)
 
 
-		queryParams, _ := url.ParseQuery(r.URL.RawQuery)
+		queryParams, err := url.ParseQuery(r.URL.RawQuery)
 
 
-		codechan <- queryParams["code"][0]
+		if err != nil {
+			return
+		}
+
+		if codeParam, exists := queryParams["code"]; exists && len(codeParam) > 0 {
+			codechan <- queryParams["code"][0]
+		}
 	}
 	}
 }
 }
 
 

+ 14 - 0
cli/cmd/project.go

@@ -140,3 +140,17 @@ func deleteProject(_ *api.AuthCheckResponse, client *api.Client, args []string)
 
 
 	return nil
 	return nil
 }
 }
+
+func setProjectCluster(client *api.Client, projectID uint) error {
+	clusters, err := client.ListProjectClusters(context.Background(), projectID)
+
+	if err != nil {
+		return err
+	}
+
+	if len(clusters) > 0 {
+		config.SetCluster(clusters[0].ID)
+	}
+
+	return nil
+}

+ 1 - 1
dashboard/src/components/Table.tsx

@@ -55,7 +55,7 @@ const Table: React.FC<TableProps> = ({
       columns: columnsData,
       columns: columnsData,
       data,
       data,
     },
     },
-    useGlobalFilter,
+    useGlobalFilter
   );
   );
 
 
   const renderRows = () => {
   const renderRows = () => {

+ 63 - 62
dashboard/src/components/repo-selector/RepoList.tsx

@@ -51,74 +51,75 @@ const RepoList: React.FC<Props> = ({
       .catch(() => {
       .catch(() => {
         setAccessError(true);
         setAccessError(true);
         setAccessLoading(false);
         setAccessLoading(false);
-      });
-
-    // load git repo ids, and then repo names from that
-    // this only happens once during the lifecycle
-    new Promise((resolve, reject) => {
-      if (!userId && userId !== 0) {
-        api
-          .getGitRepos("<token>", {}, { project_id: currentProject.id })
-          .then(async (res) => {
-            resolve(res.data);
-          })
-          .catch(() => {
-            resolve([]);
-          });
-      } else {
-        reject(null);
-      }
-    })
-      .then((ids: number[]) => {
-        Promise.all(
-          ids.map((id) => {
-            return new Promise((resolve, reject) => {
-              api
-                .getGitRepoList(
-                  "<token>",
-                  {},
-                  { project_id: currentProject.id, git_repo_id: id }
-                )
-                .then((res) => {
-                  resolve(res.data);
-                })
-                .catch((err) => {
-                  reject(err);
+      })
+      .finally(() => {
+        // load git repo ids, and then repo names from that
+        // this only happens once during the lifecycle
+        new Promise((resolve, reject) => {
+          if (!userId && userId !== 0) {
+            api
+              .getGitRepos("<token>", {}, { project_id: currentProject.id })
+              .then(async (res) => {
+                resolve(res.data);
+              })
+              .catch(() => {
+                resolve([]);
+              });
+          } else {
+            reject(null);
+          }
+        })
+          .then((ids: number[]) => {
+            Promise.all(
+              ids.map((id) => {
+                return new Promise((resolve, reject) => {
+                  api
+                    .getGitRepoList(
+                      "<token>",
+                      {},
+                      { project_id: currentProject.id, git_repo_id: id }
+                    )
+                    .then((res) => {
+                      resolve(res.data);
+                    })
+                    .catch((err) => {
+                      reject(err);
+                    });
                 });
                 });
-            });
-          })
-        )
-          .then((repos: RepoType[][]) => {
-            const names = new Set();
-            // note: would be better to use .flat() here but you need es2019 for
-            setRepos(
-              repos
-                .map((arr, idx) =>
-                  arr.map((el) => {
-                    el.GHRepoID = ids[idx];
-                    return el;
-                  })
-                )
-                .reduce((acc, val) => acc.concat(val), [])
-                .reduce((acc, val) => {
-                  if (!names.has(val.FullName)) {
-                    names.add(val.FullName);
-                    return acc.concat(val);
-                  } else {
-                    return acc;
-                  }
-                }, [])
-            );
-            setRepoLoading(false);
+              })
+            )
+              .then((repos: RepoType[][]) => {
+                const names = new Set();
+                // note: would be better to use .flat() here but you need es2019 for
+                setRepos(
+                  repos
+                    .map((arr, idx) =>
+                      arr.map((el) => {
+                        el.GHRepoID = ids[idx];
+                        return el;
+                      })
+                    )
+                    .reduce((acc, val) => acc.concat(val), [])
+                    .reduce((acc, val) => {
+                      if (!names.has(val.FullName)) {
+                        names.add(val.FullName);
+                        return acc.concat(val);
+                      } else {
+                        return acc;
+                      }
+                    }, [])
+                );
+                setRepoLoading(false);
+              })
+              .catch((_) => {
+                setRepoLoading(false);
+                setRepoError(true);
+              });
           })
           })
           .catch((_) => {
           .catch((_) => {
             setRepoLoading(false);
             setRepoLoading(false);
             setRepoError(true);
             setRepoError(true);
           });
           });
-      })
-      .catch((_) => {
-        setRepoLoading(false);
-        setRepoError(true);
       });
       });
   }, []);
   }, []);
 
 

+ 109 - 99
dashboard/src/components/values-form/FormWrapper.tsx

@@ -76,102 +76,113 @@ export default class FormWrapper extends Component<PropsType, StateType> {
           value: this.context.currentCluster.service == "doks",
           value: this.context.currentCluster.service == "doks",
         },
         },
       };
       };
-      if (tabs) {
-        tabs.forEach((tab: any, i: number) => {
-          // Exclude value if omitFromLaunch is set
-          let omit =
-            tab.settings?.omitFromLaunch && this.props.externalValues?.isLaunch;
-          if (tab?.name && tab.label && !omit) {
-            // If a tab is valid, extract state
-            tab.sections?.forEach((section: Section, i: number) => {
-              section?.contents?.forEach((item: FormElement, i: number) => {
-                if (item === null || item === undefined) {
-                  return;
-                }
-
-                if (
-                  item.type === "variable" &&
-                  item.variable &&
-                  item.settings?.default
-                ) {
-                  metaState[item.variable] = { value: item.settings.default };
-                  return;
-                }
-
-                // If no name is assigned use values.yaml variable as identifier
-                let key = item.name || item.variable;
-
-                let def =
-                  item.settings &&
-                  item.settings.unit &&
-                  !item.settings.omitUnitFromValue
-                    ? `${item.settings.default}${item.settings.unit}`
-                    : item.settings?.default;
-                def = (item.value && item.value[0]) || def;
-
-                if (item.type === "checkbox") {
-                  def = item.value && item.value[0];
-                }
-
-                // Handle add to list of required fields
-                if (item.required && key) {
-                  requiredFields.push(key);
-                }
-
-                let value: any = def;
-                switch (item.type) {
-                  case "checkbox":
-                    value = def || false;
-                    break;
-                  case "string-input":
-                    value = def || "";
-                    break;
-                  case "string-input-password":
-                    value = def || item.settings.default;
-                  case "array-input":
-                    value = def || [];
-                    break;
-                  case "env-key-value-array":
-                    value = def || {};
-                    break;
-                  case "key-value-array":
-                    value = def || {};
-                    break;
-                  case "number-input":
-                    value = def?.toString() ? def : "";
-                    break;
-                  case "select":
-                    value = def || item.settings.options[0].value;
-                    break;
-                  case "provider-select":
-                    let providerMap: any = {
-                      gke: "gcp",
-                      eks: "aws",
-                      doks: "do",
-                    };
-                    def = providerMap[this.context.currentCluster.service];
-                    value = def || "aws";
-                    break;
-                  case "base-64":
-                    value = def || "";
-                  case "base-64-password":
-                    value = def || "";
-                  default:
-                }
-                if (value !== null && value !== undefined) {
-                  metaState[key] = { value };
-                }
-              });
+      tabs?.forEach((tab: any, i: number) => {
+        // Exclude value if omitFromLaunch is set
+        let omit =
+          tab.settings?.omitFromLaunch && this.props.externalValues?.isLaunch;
+        if (tab?.name && tab.label && !omit) {
+          // If a tab is valid, extract state
+          tab.sections?.forEach((section: Section, i: number) => {
+            section?.contents?.forEach((item: FormElement, i: number) => {
+              if (item === null || item === undefined) {
+                return;
+              }
+
+              if (
+                item.type === "variable" &&
+                item.variable &&
+                item.settings?.default
+              ) {
+                metaState[item.variable] = { value: item.settings.default };
+                return;
+              }
+
+              // If no name is assigned use values.yaml variable as identifier
+              let key = item.name || item.variable;
+
+              let def =
+                item.settings &&
+                item.settings.unit &&
+                !item.settings.omitUnitFromValue
+                  ? `${item.settings.default}${item.settings.unit}`
+                  : item.settings?.default;
+              def = (item.value && item.value[0]) || def;
+
+              if (item.type === "checkbox") {
+                def = item.value && item.value[0];
+              }
+
+              // Handle add to list of required fields
+              if (item.required && key) {
+                requiredFields.push(key);
+              }
+
+              let value: any = def;
+              switch (item.type) {
+                case "checkbox":
+                  value = def || false;
+                  break;
+                case "string-input":
+                  value = def || "";
+                  break;
+                case "string-input-password":
+                  value = def || item.settings.default;
+                case "array-input":
+                  value = def || [];
+                  break;
+                case "env-key-value-array":
+                  value = def || {};
+                  break;
+                case "key-value-array":
+                  value = def || {};
+                  break;
+                case "number-input":
+                  value = def?.toString() ? def : "";
+                  break;
+                case "select":
+                  value = def || item.settings.options[0].value;
+                  break;
+                case "provider-select":
+                  let providerMap: any = {
+                    gke: "gcp",
+                    eks: "aws",
+                    doks: "do",
+                  };
+                  def = providerMap[this.context.currentCluster.service];
+                  value = def || "aws";
+                  break;
+                case "base-64":
+                  value = def || "";
+                case "base-64-password":
+                  value = def || "";
+                default:
+              }
+              if (value !== null && value !== undefined) {
+                metaState[key] = { value };
+              }
             });
             });
-            if (!this.props.tabOptionsOnly) {
-              tabOptions.push({ value: tab.name, label: tab.label });
-            }
+          });
+          if (!this.props.tabOptionsOnly) {
+            tabOptions.push({ value: tab.name, label: tab.label });
           }
           }
-        });
-      }
+        }
+      });
+
       if (this.props.tabOptions?.length > 0) {
       if (this.props.tabOptions?.length > 0) {
-        tabOptions = tabOptions.concat(this.props.tabOptions);
+        let prependTabs = [] as { value: string; label: string }[];
+        let appendTabs = [] as { value: string; label: string }[];
+        this.props.tabOptions.forEach(
+          (tab: { value: string; label: string }) => {
+            if (tab.value === "status" || tab.value === "metrics") {
+              prependTabs.push(tab);
+            } else {
+              appendTabs.push(tab);
+            }
+          }
+        );
+        tabOptions = prependTabs.concat(tabOptions.concat(appendTabs));
       }
       }
+
       if (tabOptions.length > 0) {
       if (tabOptions.length > 0) {
         this.setState(
         this.setState(
           {
           {
@@ -193,13 +204,12 @@ export default class FormWrapper extends Component<PropsType, StateType> {
       // Handle change only to external tabs (e.g. DevOps mode toggle)
       // Handle change only to external tabs (e.g. DevOps mode toggle)
       let tabOptions = [] as { value: string; label: string }[];
       let tabOptions = [] as { value: string; label: string }[];
       let tabs = this.props.formData?.tabs;
       let tabs = this.props.formData?.tabs;
-      if (tabs) {
-        tabs.forEach((tab: any, i: number) => {
-          if (tab?.name && tab.label) {
-            tabOptions.push({ value: tab.name, label: tab.label });
-          }
-        });
-      }
+      tabs?.forEach((tab: any, i: number) => {
+        if (tab?.name && tab.label) {
+          tabOptions.push({ value: tab.name, label: tab.label });
+        }
+      });
+
       if (this.props.tabOptions?.length > 0) {
       if (this.props.tabOptions?.length > 0) {
         let prependTabs = [] as { value: string; label: string }[];
         let prependTabs = [] as { value: string; label: string }[];
         let appendTabs = [] as { value: string; label: string }[];
         let appendTabs = [] as { value: string; label: string }[];

+ 4 - 4
dashboard/src/main/auth/VerifyEmail.tsx

@@ -39,10 +39,10 @@ export default class VerifyEmail extends Component<PropsType, StateType> {
           <StatusText>A verification email should have been sent to</StatusText>
           <StatusText>A verification email should have been sent to</StatusText>
           <Email>{this.context.user?.email}</Email>
           <Email>{this.context.user?.email}</Email>
         </InputWrapper>
         </InputWrapper>
-        <StatusText>
-          Didn't get it?
-        </StatusText>
-        <Button onClick={this.handleSendEmail}>Resend Verification Email</Button>
+        <StatusText>Didn't get it?</StatusText>
+        <Button onClick={this.handleSendEmail}>
+          Resend Verification Email
+        </Button>
       </div>
       </div>
     );
     );
 
 

+ 282 - 282
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -1,282 +1,282 @@
-import React, { useContext, useEffect, useState } from "react";
-import styled from "styled-components";
-
-import { Context } from "shared/Context";
-import api from "shared/api";
-import { ChartType, StorageType, ClusterType } from "shared/types";
-import { PorterUrl } from "shared/routing";
-
-import Chart from "./Chart";
-import Loading from "components/Loading";
-import { useWebsockets } from "shared/hooks/useWebsockets";
-
-type Props = {
-  currentCluster: ClusterType;
-  namespace: string;
-  // TODO Convert to enum
-  sortType: string;
-  currentView: PorterUrl;
-};
-
-const ChartList: React.FunctionComponent<Props> = ({
-  namespace,
-  sortType,
-  currentView,
-}) => {
-  const {
-    newWebsocket,
-    openWebsocket,
-    closeWebsocket,
-    closeAllWebsockets,
-  } = useWebsockets();
-  const [charts, setCharts] = useState<ChartType[]>([]);
-  const [controllers, setControllers] = useState<
-    Record<string, Record<string, any>>
-  >({});
-  const [releases, setReleases] = useState<Record<string, any>>({});
-  const [isLoading, setIsLoading] = useState(false);
-  const [isError, setIsError] = useState(false);
-
-  const context = useContext(Context);
-
-  const updateCharts = async () => {
-    try {
-      const { currentCluster, currentProject } = context;
-      setIsLoading(true);
-      const res = await api.getCharts(
-        "<token>",
-        {
-          namespace: namespace,
-          cluster_id: currentCluster.id,
-          storage: StorageType.Secret,
-          limit: 50,
-          skip: 0,
-          byDate: false,
-          statusFilter: [
-            "deployed",
-            "uninstalled",
-            "pending",
-            "pending-install",
-            "pending-upgrade",
-            "pending-rollback",
-            "superseded",
-            "failed",
-          ],
-        },
-        { id: currentProject.id }
-      );
-      const charts = res.data || [];
-
-      // filter charts based on the current view
-      const filteredCharts = charts.filter((chart: ChartType) => {
-        return (
-          (currentView == "jobs" && chart.chart.metadata.name == "job") ||
-          ((currentView == "applications" ||
-            currentView == "cluster-dashboard") &&
-            chart.chart.metadata.name != "job")
-        );
-      });
-
-      let sortedCharts = filteredCharts;
-
-      if (sortType == "Newest") {
-        sortedCharts.sort((a: any, b: any) =>
-          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
-            ? -1
-            : 1
-        );
-      } else if (sortType == "Oldest") {
-        sortedCharts.sort((a: any, b: any) =>
-          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
-            ? 1
-            : -1
-        );
-      } else if (sortType == "Alphabetical") {
-        sortedCharts.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
-      }
-
-      setIsError(false);
-      return sortedCharts;
-    } catch (error) {
-      console.log(error);
-      context.setCurrentError(JSON.stringify(error));
-      setIsError(true);
-    }
-  };
-
-  const setupHelmReleasesWebsocket = () => {
-    const apiPath = `/api/projects/${context.currentProject.id}/k8s/helm_releases?cluster_id=${context.currentCluster.id}`;
-
-    const wsConfig = {
-      onopen: () => {
-        console.log("connected to chart live updates websocket");
-      },
-      onmessage: (evt: MessageEvent) => {
-        let event = JSON.parse(evt.data);
-        const object = event.Object;
-        setReleases((oldReleases) => {
-          const currentRelease = oldReleases[object?.name];
-          const currentReleaseVersion = Number(currentRelease?.version);
-          const newReleaseVersion = Number(object?.version);
-          if (currentReleaseVersion > newReleaseVersion) {
-            return {
-              ...oldReleases,
-            };
-          }
-
-          return {
-            ...oldReleases,
-            [object.name]: object,
-          };
-        });
-      },
-
-      onclose: () => {
-        console.log("closing chart live updates websocket");
-      },
-
-      onerror: (err: ErrorEvent) => {
-        console.log(err);
-        closeWebsocket("helm_releases");
-      },
-    };
-
-    newWebsocket("helm_releases", apiPath, wsConfig);
-    openWebsocket("helm_releases");
-  };
-
-  const setupWebsocket = (kind: string) => {
-    let { currentCluster, currentProject } = context;
-    const apiPath = `/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
-
-    const wsConfig = {
-      onopen: () => {
-        console.log("connected to websocket");
-      },
-      onmessage: (evt: MessageEvent) => {
-        let event = JSON.parse(evt.data);
-        let object = event.Object;
-        object.metadata.kind = event.Kind;
-
-        setControllers((oldControllers) => ({
-          ...oldControllers,
-          [object.metadata.uid]: object,
-        }));
-      },
-      onclose: () => {
-        console.log("closing websocket");
-      },
-      onerror: (err: ErrorEvent) => {
-        console.log(err);
-        closeWebsocket(kind);
-      },
-    };
-
-    newWebsocket(kind, apiPath, wsConfig);
-
-    openWebsocket(kind);
-  };
-
-  const setControllerWebsockets = (controllers: any[]) => {
-    controllers.map((kind: string) => {
-      return setupWebsocket(kind);
-    });
-  };
-
-  // Setup basic websockets on start
-  useEffect(() => {
-    setControllerWebsockets([
-      "deployment",
-      "statefulset",
-      "daemonset",
-      "replicaset",
-    ]);
-    setupHelmReleasesWebsocket();
-
-    return () => {
-      closeAllWebsockets();
-    };
-  }, []);
-
-  useEffect(() => {
-    let isSubscribed = true;
-
-    if (namespace || namespace === "") {
-      updateCharts().then((charts) => {
-        if (isSubscribed) {
-          setCharts(charts);
-          setIsLoading(false);
-        }
-      });
-    }
-    return () => (isSubscribed = false);
-  }, [namespace, currentView]);
-
-  const renderChartList = () => {
-    if (isLoading || (!namespace && namespace !== "")) {
-      return (
-        <LoadingWrapper>
-          <Loading />
-        </LoadingWrapper>
-      );
-    } else if (isError) {
-      return (
-        <Placeholder>
-          <i className="material-icons">error</i> Error connecting to cluster.
-        </Placeholder>
-      );
-    } else if (charts.length === 0) {
-      return (
-        <Placeholder>
-          <i className="material-icons">category</i> No
-          {currentView === "jobs" ? ` jobs` : ` charts`} found in this
-          namespace.
-        </Placeholder>
-      );
-    }
-
-    return charts.map((chart: ChartType, i: number) => {
-      return (
-        <Chart
-          key={`${chart.namespace}-${chart.name}`}
-          chart={chart}
-          controllers={controllers || {}}
-          release={releases[chart.name] || {}}
-        />
-      );
-    });
-  };
-
-  return <StyledChartList>{renderChartList()}</StyledChartList>;
-};
-
-export default ChartList;
-
-const Placeholder = styled.div`
-  width: 100%;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  color: #ffffff44;
-  background: #26282f;
-  border-radius: 5px;
-  height: 320px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #ffffff44;
-  font-size: 13px;
-
-  > i {
-    font-size: 16px;
-    margin-right: 12px;
-  }
-`;
-
-const LoadingWrapper = styled.div`
-  padding-top: 100px;
-`;
-
-const StyledChartList = styled.div`
-  padding-bottom: 85px;
-`;
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { ChartType, StorageType, ClusterType } from "shared/types";
+import { PorterUrl } from "shared/routing";
+
+import Chart from "./Chart";
+import Loading from "components/Loading";
+import { useWebsockets } from "shared/hooks/useWebsockets";
+
+type Props = {
+  currentCluster: ClusterType;
+  namespace: string;
+  // TODO Convert to enum
+  sortType: string;
+  currentView: PorterUrl;
+};
+
+const ChartList: React.FunctionComponent<Props> = ({
+  namespace,
+  sortType,
+  currentView,
+}) => {
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeWebsocket,
+    closeAllWebsockets,
+  } = useWebsockets();
+  const [charts, setCharts] = useState<ChartType[]>([]);
+  const [controllers, setControllers] = useState<
+    Record<string, Record<string, any>>
+  >({});
+  const [releases, setReleases] = useState<Record<string, any>>({});
+  const [isLoading, setIsLoading] = useState(false);
+  const [isError, setIsError] = useState(false);
+
+  const context = useContext(Context);
+
+  const updateCharts = async () => {
+    try {
+      const { currentCluster, currentProject } = context;
+      setIsLoading(true);
+      const res = await api.getCharts(
+        "<token>",
+        {
+          namespace: namespace,
+          cluster_id: currentCluster.id,
+          storage: StorageType.Secret,
+          limit: 50,
+          skip: 0,
+          byDate: false,
+          statusFilter: [
+            "deployed",
+            "uninstalled",
+            "pending",
+            "pending-install",
+            "pending-upgrade",
+            "pending-rollback",
+            "superseded",
+            "failed",
+          ],
+        },
+        { id: currentProject.id }
+      );
+      const charts = res.data || [];
+
+      // filter charts based on the current view
+      const filteredCharts = charts.filter((chart: ChartType) => {
+        return (
+          (currentView == "jobs" && chart.chart.metadata.name == "job") ||
+          ((currentView == "applications" ||
+            currentView == "cluster-dashboard") &&
+            chart.chart.metadata.name != "job")
+        );
+      });
+
+      let sortedCharts = filteredCharts;
+
+      if (sortType == "Newest") {
+        sortedCharts.sort((a: any, b: any) =>
+          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
+            ? -1
+            : 1
+        );
+      } else if (sortType == "Oldest") {
+        sortedCharts.sort((a: any, b: any) =>
+          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
+            ? 1
+            : -1
+        );
+      } else if (sortType == "Alphabetical") {
+        sortedCharts.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
+      }
+
+      setIsError(false);
+      return sortedCharts;
+    } catch (error) {
+      console.log(error);
+      context.setCurrentError(JSON.stringify(error));
+      setIsError(true);
+    }
+  };
+
+  const setupHelmReleasesWebsocket = () => {
+    const apiPath = `/api/projects/${context.currentProject.id}/k8s/helm_releases?cluster_id=${context.currentCluster.id}`;
+
+    const wsConfig = {
+      onopen: () => {
+        console.log("connected to chart live updates websocket");
+      },
+      onmessage: (evt: MessageEvent) => {
+        let event = JSON.parse(evt.data);
+        const object = event.Object;
+        setReleases((oldReleases) => {
+          const currentRelease = oldReleases[object?.name];
+          const currentReleaseVersion = Number(currentRelease?.version);
+          const newReleaseVersion = Number(object?.version);
+          if (currentReleaseVersion > newReleaseVersion) {
+            return {
+              ...oldReleases,
+            };
+          }
+
+          return {
+            ...oldReleases,
+            [object.name]: object,
+          };
+        });
+      },
+
+      onclose: () => {
+        console.log("closing chart live updates websocket");
+      },
+
+      onerror: (err: ErrorEvent) => {
+        console.log(err);
+        closeWebsocket("helm_releases");
+      },
+    };
+
+    newWebsocket("helm_releases", apiPath, wsConfig);
+    openWebsocket("helm_releases");
+  };
+
+  const setupWebsocket = (kind: string) => {
+    let { currentCluster, currentProject } = context;
+    const apiPath = `/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
+
+    const wsConfig = {
+      onopen: () => {
+        console.log("connected to websocket");
+      },
+      onmessage: (evt: MessageEvent) => {
+        let event = JSON.parse(evt.data);
+        let object = event.Object;
+        object.metadata.kind = event.Kind;
+
+        setControllers((oldControllers) => ({
+          ...oldControllers,
+          [object.metadata.uid]: object,
+        }));
+      },
+      onclose: () => {
+        console.log("closing websocket");
+      },
+      onerror: (err: ErrorEvent) => {
+        console.log(err);
+        closeWebsocket(kind);
+      },
+    };
+
+    newWebsocket(kind, apiPath, wsConfig);
+
+    openWebsocket(kind);
+  };
+
+  const setControllerWebsockets = (controllers: any[]) => {
+    controllers.map((kind: string) => {
+      return setupWebsocket(kind);
+    });
+  };
+
+  // Setup basic websockets on start
+  useEffect(() => {
+    setControllerWebsockets([
+      "deployment",
+      "statefulset",
+      "daemonset",
+      "replicaset",
+    ]);
+    setupHelmReleasesWebsocket();
+
+    return () => {
+      closeAllWebsockets();
+    };
+  }, []);
+
+  useEffect(() => {
+    let isSubscribed = true;
+
+    if (namespace || namespace === "") {
+      updateCharts().then((charts) => {
+        if (isSubscribed) {
+          setCharts(charts);
+          setIsLoading(false);
+        }
+      });
+    }
+    return () => (isSubscribed = false);
+  }, [namespace, currentView]);
+
+  const renderChartList = () => {
+    if (isLoading || (!namespace && namespace !== "")) {
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
+    } else if (isError) {
+      return (
+        <Placeholder>
+          <i className="material-icons">error</i> Error connecting to cluster.
+        </Placeholder>
+      );
+    } else if (charts.length === 0) {
+      return (
+        <Placeholder>
+          <i className="material-icons">category</i> No
+          {currentView === "jobs" ? ` jobs` : ` charts`} found in this
+          namespace.
+        </Placeholder>
+      );
+    }
+
+    return charts.map((chart: ChartType, i: number) => {
+      return (
+        <Chart
+          key={`${chart.namespace}-${chart.name}`}
+          chart={chart}
+          controllers={controllers || {}}
+          release={releases[chart.name] || {}}
+        />
+      );
+    });
+  };
+
+  return <StyledChartList>{renderChartList()}</StyledChartList>;
+};
+
+export default ChartList;
+
+const Placeholder = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: #ffffff44;
+  background: #26282f;
+  border-radius: 5px;
+  height: 320px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  font-size: 13px;
+
+  > i {
+    font-size: 16px;
+    margin-right: 12px;
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding-top: 100px;
+`;
+
+const StyledChartList = styled.div`
+  padding-bottom: 85px;
+`;

+ 17 - 8
dashboard/src/main/home/cluster-dashboard/dashboard/node-view/NodeUsage.tsx

@@ -41,22 +41,31 @@ const NodeUsage: React.FunctionComponent<NodeUsageProps> = ({ node }) => {
             <Bolded>CPU:</Bolded>{" "}
             <Bolded>CPU:</Bolded>{" "}
             {!node?.cpu_reqs && !node?.allocatable_cpu
             {!node?.cpu_reqs && !node?.allocatable_cpu
               ? "Loading..."
               ? "Loading..."
-              : `${percentFormatter(node?.fraction_cpu_reqs)} (${node?.cpu_reqs}/${
-                  node?.allocatable_cpu
-                }m)`}
+              : `${percentFormatter(node?.fraction_cpu_reqs)} (${
+                  node?.cpu_reqs
+                }/${node?.allocatable_cpu}m)`}
           </span>
           </span>
           <Buffer />
           <Buffer />
           <span>
           <span>
             <Bolded>RAM:</Bolded>{" "}
             <Bolded>RAM:</Bolded>{" "}
             {!node?.memory_reqs && !node?.allocatable_memory
             {!node?.memory_reqs && !node?.allocatable_memory
               ? "Loading..."
               ? "Loading..."
-              : `${percentFormatter(node?.fraction_memory_reqs)} (${formatMemoryUnitToMi(
+              : `${percentFormatter(
+                  node?.fraction_memory_reqs
+                )} (${formatMemoryUnitToMi(
                   node?.memory_reqs
                   node?.memory_reqs
-                )}/${formatMemoryUnitToMi(
-                  node?.allocatable_memory
-                )})`}
+                )}/${formatMemoryUnitToMi(node?.allocatable_memory)})`}
           </span>
           </span>
-          <I onClick={() => window.open("https://kubernetes.io/docs/tasks/administer-cluster/reserve-compute-resources/#node-allocatable")} className="material-icons">help_outline</I>
+          <I
+            onClick={() =>
+              window.open(
+                "https://kubernetes.io/docs/tasks/administer-cluster/reserve-compute-resources/#node-allocatable"
+              )
+            }
+            className="material-icons"
+          >
+            help_outline
+          </I>
         </UsageWrapper>
         </UsageWrapper>
       </Wrapper>
       </Wrapper>
     </NodeUsageWrapper>
     </NodeUsageWrapper>

+ 30 - 16
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -59,6 +59,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
     props.currentChart
     props.currentChart
   );
   );
   const [showRevisions, setShowRevisions] = useState<boolean>(false);
   const [showRevisions, setShowRevisions] = useState<boolean>(false);
+  const [loading, setLoading] = useState<boolean>(false);
   const [components, setComponents] = useState<ResourceType[]>([]);
   const [components, setComponents] = useState<ResourceType[]>([]);
   const [isPreview, setIsPreview] = useState<boolean>(false);
   const [isPreview, setIsPreview] = useState<boolean>(false);
   const [devOpsMode, setDevOpsMode] = useState<boolean>(
   const [devOpsMode, setDevOpsMode] = useState<boolean>(
@@ -170,21 +171,27 @@ const ExpandedChart: React.FC<Props> = (props) => {
     const wsConfig = {
     const wsConfig = {
       onmessage(evt: MessageEvent) {
       onmessage(evt: MessageEvent) {
         const event = JSON.parse(evt.data);
         const event = JSON.parse(evt.data);
-
-        if (event.event_type == "UPDATE") {
-          let object = event.Object;
-          object.metadata.kind = event.Kind;
-
-          setControllers((oldControllers) => {
-            if (oldControllers[object.metadata.uid]) {
-              return oldControllers;
-            }
-            return {
-              ...oldControllers,
-              [object.metadata.uid]: object,
-            };
-          });
-        }
+        let object = event.Object;
+        object.metadata.kind = event.Kind;
+
+        setControllers((oldControllers) => {
+          switch (event.event_type) {
+            case "DELETE":
+              delete oldControllers[object.metadata.uid];
+            case "UPDATE":
+              if (
+                oldControllers &&
+                oldControllers[object.metadata.uid]?.status?.conditions ==
+                  object.status?.conditions
+              ) {
+                return oldControllers;
+              }
+              return {
+                ...oldControllers,
+                [object.metadata.uid]: object,
+              };
+          }
+        });
       },
       },
       onerror() {
       onerror() {
         closeWebsocket(kind);
         closeWebsocket(kind);
@@ -195,6 +202,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
   };
   };
 
 
   const updateComponents = async (currentChart: ChartType) => {
   const updateComponents = async (currentChart: ChartType) => {
+    setLoading(true);
     try {
     try {
       const res = await api.getChartComponents(
       const res = await api.getChartComponents(
         "<token>",
         "<token>",
@@ -210,8 +218,10 @@ const ExpandedChart: React.FC<Props> = (props) => {
         }
         }
       );
       );
       setComponents(res.data.Objects);
       setComponents(res.data.Objects);
+      setLoading(false);
     } catch (error) {
     } catch (error) {
       console.log(error);
       console.log(error);
+      setLoading(false);
     }
     }
   };
   };
 
 
@@ -523,7 +533,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
       return c.Kind === "Service";
       return c.Kind === "Service";
     });
     });
 
 
-    if (!service?.Name || !service?.Namespace) {
+    if (loading) {
       return (
       return (
         <Url>
         <Url>
           <Bolded>Loading...</Bolded>
           <Bolded>Loading...</Bolded>
@@ -531,6 +541,10 @@ const ExpandedChart: React.FC<Props> = (props) => {
       );
       );
     }
     }
 
 
+    if (!service?.Name || !service?.Namespace) {
+      return;
+    }
+
     return (
     return (
       <Url>
       <Url>
         <Bolded>Internal URI:</Bolded>
         <Bolded>Internal URI:</Bolded>

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx

@@ -460,7 +460,6 @@ const MetricsSection: React.FunctionComponent<PropsType> = ({
           </Highlight>
           </Highlight>
         </Message>
         </Message>
       )}
       )}
-
       {data.length > 0 && isLoading === 0 && (
       {data.length > 0 && isLoading === 0 && (
         <>
         <>
           {currentChart?.config?.autoscaling?.enabled &&
           {currentChart?.config?.autoscaling?.enabled &&

+ 2 - 4
dashboard/src/shared/hardcodedNameDict.tsx

@@ -28,12 +28,10 @@ const hardcodedIcons: { [key: string]: string } = {
     "https://pbs.twimg.com/profile_images/961380992727465985/4unoiuHt.jpg",
     "https://pbs.twimg.com/profile_images/961380992727465985/4unoiuHt.jpg",
   mongodb:
   mongodb:
     "https://bitnami.com/assets/stacks/mongodb/img/mongodb-stack-220x234.png",
     "https://bitnami.com/assets/stacks/mongodb/img/mongodb-stack-220x234.png",
-  datadog:
-    "https://datadog-live.imgix.net/img/dd_logo_70x75.png",
+  datadog: "https://datadog-live.imgix.net/img/dd_logo_70x75.png",
   wallarm:
   wallarm:
     "https://assets.website-files.com/5fe3434623c64c793987363d/6006cb97f71f76f8a5e85a32_Frame%201923.png",
     "https://assets.website-files.com/5fe3434623c64c793987363d/6006cb97f71f76f8a5e85a32_Frame%201923.png",
-  agones:
-    "https://avatars.githubusercontent.com/u/36940055?v=4",
+  agones: "https://avatars.githubusercontent.com/u/36940055?v=4",
   mysql: "https://www.mysql.com/common/logos/logo-mysql-170x115.png",
   mysql: "https://www.mysql.com/common/logos/logo-mysql-170x115.png",
   postgresql:
   postgresql:
     "https://bitnami.com/assets/stacks/postgresql/img/postgresql-stack-110x117.png",
     "https://bitnami.com/assets/stacks/postgresql/img/postgresql-stack-110x117.png",

+ 67 - 0
docs/guides/update-instance-type-eks.md

@@ -0,0 +1,67 @@
+# Guide: Updating Instance Type on AWS EKS
+![image](https://user-images.githubusercontent.com/22849518/127391733-c081642f-dc58-4113-b55e-e12aa4477ec7.png)
+
+## Motivation
+There are multiple scenarios where it makes sense to update the instance type of some or all nodes on a Kubernetes cluster. You may want to use larger machines to grant more computing resources to individual pods or attach GPUs to certain nodes to support ML workloads.
+
+Regardless, changing the instance type of an EKS node group follows the same general process. In this guide, we'll walk through a basic example and highlight a few things to look out for when updating an existing cluster. 
+
+## Prerequisites
+This guide assumes you have an EKS cluster with autoscaling enabled. The node group for your cluster should also be self-managed, meaning you can view and update the EC2 instances and auto scaling group(s) attached to your cluster. Note that if you provisioned your cluster through [Porter](https://github.com/porter-dev/porter) this is automatically the case.
+
+## Step 1: Create a new launch configuration
+In this example, we'll upgrade a group of EKS worker nodes from t3.medium to t3.xlarge instances. To start, we can see from the Porter dashboard (or [AWS console](https://console.aws.amazon.com/ec2#Instances:)) that our EKS cluster has three t3.medium instances for user workloads and two t2.medium instances for Kubernetes system components:
+
+![image](https://user-images.githubusercontent.com/22849518/127392594-74d50e08-9394-4e86-b091-23cad6fbd60c.png)
+
+Note that you might have one (or more than two) node groups depending on how you initially configured your cluster. If you created an EKS cluster using Porter, your cluster will also have two node groups by default.
+
+The first step is to update the launch template/configuration of our auto scaling group. When the EKS cluster scales out, the auto scaling group uses a launch config to decide what kind of instance to add and how it should be configured. We can view the current launch config of our auto scaling group under **EC2 -> Auto Scaling groups**:
+
+![image](https://user-images.githubusercontent.com/22849518/127392659-3b973671-c900-44c9-8942-4f5e6945e0b3.png)
+
+We'll select the launch config of the worker group with three instances and proceed by selecting **Actions -> Copy launch configuration**:
+
+![image](https://user-images.githubusercontent.com/22849518/127392681-7af18061-6537-4f19-b05d-b9ea6934044a.png)
+
+From this view, we can name our new launch config and specify an updated instance type. For this example I've opted to use t3.xlarge instances for the updated auto scaling group:
+
+![image](https://user-images.githubusercontent.com/22849518/127392701-48adcad9-0e40-4ce0-b531-808350f5f2eb.png)
+
+Next, we need to specify a key pair for accessing instances generated by our launch config (for example through SSH). After choosing an existing key pair (or creating a new one if needed), select **Create launch configuration**:
+
+![image](https://user-images.githubusercontent.com/22849518/127392735-89f12e56-a76d-4824-a825-e473ebde478a.png)
+
+We now have an updated launch config that we can use for spinning up new instances through an autoscaling group.
+
+## Step 2: Update the autoscaling group and refresh existing instances
+Now that we've created a new launch configuration, we need to attach it to our existing autoscaling group. Returning to **EC2 -> Auto Scaling groups**, find the auto scaling group to update and select **Edit**. From the edit screen, we can select our new launch config and choose **Update**:
+
+![image](https://user-images.githubusercontent.com/22849518/127392827-21a7ae1a-c218-423f-b0e4-41b1692a44e1.png)
+
+At this point, when new worker nodes are added to our cluster, they will use the updated launch config. Unfortunately, we're not finished just yet since the existing nodes still haven't been upgraded.
+
+[EC2 instance refresh](https://aws.amazon.com/blogs/compute/introducing-instance-refresh-for-ec2-auto-scaling/) allows us to trigger a rolling update across an auto scaling group while ensuring a minimum number of instances remain available throughout the update process. After selecting the same auto scaling group from before, navigate to the **Instance refresh** tab, and choose **Start instance refresh**:
+
+![image](https://user-images.githubusercontent.com/22849518/127392938-3942f29b-5486-4b4d-ba54-4215340a91af.png)
+
+Here, we're given the option to set the minimum healthy percentage (how much capacity should remain available during the refresh) as well as the warmup time for new instances:
+
+![image](https://user-images.githubusercontent.com/22849518/127392949-a29fb2d1-daed-4463-a6b1-9b14a2fabeda.png)
+
+Note that at least one instance will be refreshed at a time. As the instances are being refreshed, Kubernetes will automatically reschedule workloads to the new nodes. For auto scaling groups with only one instance, it may be desirable to temporarily [increase the minimum limit](https://docs.aws.amazon.com/autoscaling/ec2/userguide/asg-capacity-limits.html) to ensure that workloads can be immediately scheduled. 
+
+**Important note:** if you need to avoid service downtime, you will have to ensure that multiple replicas of each application are running across more than one node. Even if your application has multiple replicas, Kubernetes doesn't prevent all replicas from being collocated on the same node by default. This means that if all replicas for your service are running on a single node, the service will become temporarily unavailable when the node is spun down. For more details on tolerating node failure, you can configure pod anti-affinity following [this example](https://kubernetes.io/docs/tutorials/stateful-application/zookeeper/#tolerating-node-failure). If your Kubernetes version is 1.19 or later, you can also use [pod topology spread constraints](https://kubernetes.io/docs/concepts/workloads/pods/pod-topology-spread-constraints/) to ensure high availability across nodes.
+
+Once you are ready to trigger the instance refresh, choose **Start**. Bear in mind that instance refresh can take 5-10 minutes per node, and the total time to complete the refresh also depends on the size of the cluster. 
+
+## Step 3: Verify instance update and existing workloads
+After the instance refresh is completed, we can check our cluster to verify that the worker group has updated:
+
+![image](https://user-images.githubusercontent.com/22849518/127393233-f832bf90-01bf-4cea-b939-9b0aa38bc5cc.png)
+
+As we can see, the three worker nodes are now running as t3.xlarge instances. Finally, to confirm that our workloads were successfully rescheduled, we can check the status of our existing applications from the Porter dashboard:
+
+![image](https://user-images.githubusercontent.com/22849518/127393251-2cf34a75-a3d1-484a-a9ce-dba38a5090dd.png)
+
+It looks like everything is running successfully on our updated nodes! If you have multiple auto scaling groups that you would like to update, you can simply repeat this process as needed.

+ 32 - 1
docs/reference/cli.md

@@ -19,6 +19,21 @@ chmod +x ./porter
 sudo mv ./porter /usr/local/bin/porter
 sudo mv ./porter /usr/local/bin/porter
 ```
 ```
 
 
+To download a specific version of the CLI:
+
+```sh
+{
+# NOTE: replace this line with the version
+version=v0.6.1
+name=porter-$version.zip
+curl -L https://github.com/porter-dev/porter/releases/download/${version}/porter_${version}_Darwin_x86_64.zip --output $name
+unzip -a $name
+rm $name
+chmod +x ./porter
+sudo mv ./porter /usr/local/bin/porter
+}
+```
+
 ## Linux
 ## Linux
 
 
 Run the following command to grab the latest binary:
 Run the following command to grab the latest binary:
@@ -40,6 +55,22 @@ chmod +x ./porter
 sudo mv ./porter /usr/local/bin/porter
 sudo mv ./porter /usr/local/bin/porter
 ```
 ```
 
 
+
+To download a specific version of the CLI:
+
+```sh
+{
+# NOTE: replace this line with the version
+version=v0.6.1
+name=porter-$version.zip
+curl -L https://github.com/porter-dev/porter/releases/download/${version}/porter_${version}_Linux_x86_64.zip --output $name
+unzip -a $name
+rm $name
+chmod +x ./porter
+sudo mv ./porter /usr/local/bin/porter
+}
+```
+
 ## Windows
 ## Windows
 
 
 Go [here](https://github.com/porter-dev/porter/releases/latest/download/porter_0.1.0-beta.1_Windows_x86_64.zip) to download the Windows executable and add the binary to your `PATH`.
 Go [here](https://github.com/porter-dev/porter/releases/latest/download/porter_0.1.0-beta.1_Windows_x86_64.zip) to download the Windows executable and add the binary to your `PATH`.
@@ -118,4 +149,4 @@ Here's a reference table for the CLI documentation:
 | `porter config set-project [PROJECT_ID]` | Sets the current project in config. |
 | `porter config set-project [PROJECT_ID]` | Sets the current project in config. |
 | `porter connect [INTEGRATION]` | Connects Porter with the given infrastructure. Accepts `kubeconfig` and `ecr` as arguments. |
 | `porter connect [INTEGRATION]` | Connects Porter with the given infrastructure. Accepts `kubeconfig` and `ecr` as arguments. |
 | `porter docker configure` | Grants the `docker` CLI access to a provisioned image registry. |
 | `porter docker configure` | Grants the `docker` CLI access to a provisioned image registry. |
-| `porter run [RELEASE] -- [COMMAND] [args...]` | Executes a command on a remote container, specified by the release name. |
+| `porter run [RELEASE] -- [COMMAND] [args...]` | Executes a command on a remote container, specified by the release name. |

+ 2 - 2
go.mod

@@ -7,7 +7,7 @@ require (
 	github.com/AlecAivazis/survey/v2 v2.2.9
 	github.com/AlecAivazis/survey/v2 v2.2.9
 	github.com/DATA-DOG/go-sqlmock v1.5.0
 	github.com/DATA-DOG/go-sqlmock v1.5.0
 	github.com/aws/aws-sdk-go v1.35.4
 	github.com/aws/aws-sdk-go v1.35.4
-	github.com/bradleyfalzon/ghinstallation v1.1.1 // indirect
+	github.com/bradleyfalzon/ghinstallation v1.1.1
 	github.com/buildpacks/pack v0.19.0
 	github.com/buildpacks/pack v0.19.0
 	github.com/cli/cli v1.11.0
 	github.com/cli/cli v1.11.0
 	github.com/dgrijalva/jwt-go v3.2.0+incompatible
 	github.com/dgrijalva/jwt-go v3.2.0+incompatible
@@ -28,7 +28,7 @@ require (
 	github.com/google/go-github/v29 v29.0.3 // indirect
 	github.com/google/go-github/v29 v29.0.3 // indirect
 	github.com/google/go-github/v33 v33.0.0
 	github.com/google/go-github/v33 v33.0.0
 	github.com/google/go-querystring v1.1.0 // indirect
 	github.com/google/go-querystring v1.1.0 // indirect
-  github.com/gorilla/mux v1.8.0 // indirect
+	github.com/gorilla/mux v1.8.0 // indirect
 	github.com/gorilla/schema v1.2.0
 	github.com/gorilla/schema v1.2.0
 	github.com/gorilla/securecookie v1.1.1
 	github.com/gorilla/securecookie v1.1.1
 	github.com/gorilla/sessions v1.2.1
 	github.com/gorilla/sessions v1.2.1

+ 11 - 15
internal/integrations/ci/actions/actions.go

@@ -4,6 +4,8 @@ import (
 	"context"
 	"context"
 	"encoding/base64"
 	"encoding/base64"
 	"fmt"
 	"fmt"
+	"net/http"
+
 	"github.com/bradleyfalzon/ghinstallation"
 	"github.com/bradleyfalzon/ghinstallation"
 	"github.com/google/go-github/v33/github"
 	"github.com/google/go-github/v33/github"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
@@ -11,7 +13,6 @@ import (
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository"
 	"golang.org/x/crypto/nacl/box"
 	"golang.org/x/crypto/nacl/box"
 	"golang.org/x/oauth2"
 	"golang.org/x/oauth2"
-	"net/http"
 
 
 	"strings"
 	"strings"
 
 
@@ -34,6 +35,7 @@ type GithubActions struct {
 	PorterToken  string
 	PorterToken  string
 	BuildEnv     map[string]string
 	BuildEnv     map[string]string
 	ProjectID    uint
 	ProjectID    uint
+	ClusterID    uint
 	ReleaseName  string
 	ReleaseName  string
 
 
 	GitBranch      string
 	GitBranch      string
@@ -71,7 +73,7 @@ func (g *GithubActions) Setup() (string, error) {
 		return "", err
 		return "", err
 	}
 	}
 
 
-	// create a new secret with a porter token
+	// create new secrets porter token, project id, and cluster id
 	err = g.createGithubSecret(client, g.getPorterTokenSecretName(), g.PorterToken)
 	err = g.createGithubSecret(client, g.getPorterTokenSecretName(), g.PorterToken)
 
 
 	if err != nil {
 	if err != nil {
@@ -132,10 +134,12 @@ func (g *GithubActions) Cleanup() error {
 }
 }
 
 
 type GithubActionYAMLStep struct {
 type GithubActionYAMLStep struct {
-	Name string `yaml:"name,omitempty"`
-	ID   string `yaml:"id,omitempty"`
-	Uses string `yaml:"uses,omitempty"`
-	Run  string `yaml:"run,omitempty"`
+	Name    string            `yaml:"name,omitempty"`
+	ID      string            `yaml:"id,omitempty"`
+	Timeout uint64            `yaml:"timeout-minutes,omitempty"`
+	Uses    string            `yaml:"uses,omitempty"`
+	Run     string            `yaml:"run,omitempty"`
+	Env     map[string]string `yaml:"env,omitempty"`
 }
 }
 
 
 type GithubActionYAMLOnPushBranches struct {
 type GithubActionYAMLOnPushBranches struct {
@@ -163,17 +167,9 @@ func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
 	gaSteps := []GithubActionYAMLStep{
 	gaSteps := []GithubActionYAMLStep{
 		getCheckoutCodeStep(),
 		getCheckoutCodeStep(),
 		getDownloadPorterStep(),
 		getDownloadPorterStep(),
-		getConfigurePorterStep(g.ServerURL, g.getPorterTokenSecretName()),
+		getConfigurePorterStep(g.ServerURL, g.getPorterTokenSecretName(), g.ProjectID, g.ClusterID, g.ReleaseName),
 	}
 	}
 
 
-	if g.DockerFilePath == "" {
-		gaSteps = append(gaSteps, getBuildPackPushStep(g.getBuildEnvSecretName(), g.FolderPath, g.ImageRepoURL))
-	} else {
-		gaSteps = append(gaSteps, getDockerBuildPushStep(g.getBuildEnvSecretName(), g.DockerFilePath, g.ImageRepoURL))
-	}
-
-	gaSteps = append(gaSteps, deployPorterWebhookStep(g.ServerURL, g.getWebhookSecretName()))
-
 	branch := g.GitBranch
 	branch := g.GitBranch
 
 
 	if branch == "" {
 	if branch == "" {

+ 13 - 57
internal/integrations/ci/actions/steps.go

@@ -2,7 +2,6 @@ package actions
 
 
 import (
 import (
 	"fmt"
 	"fmt"
-	"path/filepath"
 )
 )
 
 
 func getCheckoutCodeStep() GithubActionYAMLStep {
 func getCheckoutCodeStep() GithubActionYAMLStep {
@@ -12,8 +11,7 @@ func getCheckoutCodeStep() GithubActionYAMLStep {
 	}
 	}
 }
 }
 
 
-const download string = `
-name=$(curl -s https://api.github.com/repos/porter-dev/porter/releases/latest | grep "browser_download_url.*/porter_.*_Linux_x86_64\.zip" | cut -d ":" -f 2,3 | tr -d \")
+const download string = `name=$(curl -s https://api.github.com/repos/porter-dev/porter/releases/latest | grep "browser_download_url.*/porter_.*_Linux_x86_64\.zip" | cut -d ":" -f 2,3 | tr -d \")
 name=$(basename $name)
 name=$(basename $name)
 curl -L https://github.com/porter-dev/porter/releases/latest/download/$name --output $name
 curl -L https://github.com/porter-dev/porter/releases/latest/download/$name --output $name
 unzip -a $name
 unzip -a $name
@@ -30,61 +28,19 @@ func getDownloadPorterStep() GithubActionYAMLStep {
 	}
 	}
 }
 }
 
 
-const configure string = `
-sudo porter config set-host %s
-sudo porter auth login --token ${{secrets.%s}}
-sudo porter docker configure
-`
-
-func getConfigurePorterStep(serverURL, porterTokenSecretName string) GithubActionYAMLStep {
-	return GithubActionYAMLStep{
-		Name: "Configure Porter",
-		ID:   "configure_porter",
-		Run:  fmt.Sprintf(configure, serverURL, porterTokenSecretName),
-	}
-}
-
-const dockerBuildPush string = `
-export $(echo "${{secrets.%s}}" | xargs)
-echo "${{secrets.%s}}" > ./env_porter
-sudo docker build %s $(cat ./env_porter | awk 'NF' | sed 's@^@--build-arg @g' | paste -s -d " " -) --file %s -t %s:$(git rev-parse --short HEAD)
-sudo docker push %s:$(git rev-parse --short HEAD)
-`
-
-func getDockerBuildPushStep(envSecretName, dockerFilePath, repoURL string) GithubActionYAMLStep {
-	return GithubActionYAMLStep{
-		Name: "Docker build, push",
-		ID:   "docker_build_push",
-		Run:  fmt.Sprintf(dockerBuildPush, envSecretName, envSecretName, filepath.Dir(dockerFilePath), dockerFilePath, repoURL, repoURL),
-	}
-}
-
-const buildPackPush string = `
-export $(echo "${{secrets.%s}}" | xargs)
-echo "${{secrets.%s}}" > ./env_porter
-sudo add-apt-repository ppa:cncf-buildpacks/pack-cli
-sudo apt-get update
-sudo apt-get install pack-cli
-sudo pack build %s:$(git rev-parse --short HEAD) --path %s --builder heroku/buildpacks:18 --env-file ./env_porter
-sudo docker push %s:$(git rev-parse --short HEAD)
-`
-
-func getBuildPackPushStep(envSecretName, folderPath, repoURL string) GithubActionYAMLStep {
-	return GithubActionYAMLStep{
-		Name: "Docker build, push",
-		ID:   "docker_build_push",
-		Run:  fmt.Sprintf(buildPackPush, envSecretName, envSecretName, repoURL, folderPath, repoURL),
-	}
-}
-
-const deployPorter string = `
-curl -X POST "%s/api/webhooks/deploy/${{secrets.%s}}?commit=$(git rev-parse --short HEAD)"
-`
+const configure string = `porter update --app %s`
 
 
-func deployPorterWebhookStep(serverURL, webhookTokenSecretName string) GithubActionYAMLStep {
+func getConfigurePorterStep(serverURL, porterTokenSecretName string, projectID uint, clusterID uint, appName string) GithubActionYAMLStep {
 	return GithubActionYAMLStep{
 	return GithubActionYAMLStep{
-		Name: "Deploy on Porter",
-		ID:   "deploy_porter",
-		Run:  fmt.Sprintf(deployPorter, serverURL, webhookTokenSecretName),
+		Name:    "Update Porter App",
+		ID:      "update_porter",
+		Run:     fmt.Sprintf(configure, appName),
+		Timeout: 20,
+		Env: map[string]string{
+			"PORTER_TOKEN":   fmt.Sprintf("${{ secrets.%s }}", porterTokenSecretName),
+			"PORTER_HOST":    serverURL,
+			"PORTER_PROJECT": fmt.Sprintf("%d", projectID),
+			"PORTER_CLUSTER": fmt.Sprintf("%d", clusterID),
+		},
 	}
 	}
 }
 }

+ 0 - 2
internal/oauth/config.go

@@ -4,7 +4,6 @@ import (
 	"context"
 	"context"
 	"crypto/rand"
 	"crypto/rand"
 	"encoding/base64"
 	"encoding/base64"
-	"fmt"
 	"github.com/porter-dev/porter/internal/models/integrations"
 	"github.com/porter-dev/porter/internal/models/integrations"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository"
 	"time"
 	"time"
@@ -148,7 +147,6 @@ func GetAccessToken(
 	}
 	}
 
 
 	if token.AccessToken != string(prevToken.AccessToken) {
 	if token.AccessToken != string(prevToken.AccessToken) {
-		fmt.Println("access happening...")
 		err := updateToken([]byte(token.AccessToken), []byte(token.RefreshToken), token.Expiry)
 		err := updateToken([]byte(token.AccessToken), []byte(token.RefreshToken), token.Expiry)
 
 
 		if err != nil {
 		if err != nil {

+ 1 - 0
server/api/deploy_handler.go

@@ -388,6 +388,7 @@ func (app *App) HandleUninstallTemplate(w http.ResponseWriter, r *http.Request)
 					FolderPath:             gitAction.FolderPath,
 					FolderPath:             gitAction.FolderPath,
 					ImageRepoURL:           gitAction.ImageRepoURI,
 					ImageRepoURL:           gitAction.ImageRepoURI,
 					BuildEnv:               cEnv.Container.Env.Normal,
 					BuildEnv:               cEnv.Container.Env.Normal,
+					ClusterID:              release.ClusterID,
 				}
 				}
 
 
 				err = gaRunner.Cleanup()
 				err = gaRunner.Cleanup()

+ 1 - 0
server/api/git_action_handler.go

@@ -173,6 +173,7 @@ func (app *App) createGitActionFromForm(
 		ImageRepoURL:           gitAction.ImageRepoURI,
 		ImageRepoURL:           gitAction.ImageRepoURI,
 		PorterToken:            encoded,
 		PorterToken:            encoded,
 		BuildEnv:               form.BuildEnv,
 		BuildEnv:               form.BuildEnv,
+		ClusterID:              release.ClusterID,
 	}
 	}
 
 
 	_, err = gaRunner.Setup()
 	_, err = gaRunner.Setup()

+ 0 - 31
server/api/git_repo_handler.go

@@ -509,34 +509,3 @@ func (app *App) githubAppClientFromRequest(r *http.Request) (*github.Client, err
 
 
 	return github.NewClient(&http.Client{Transport: itr}), nil
 	return github.NewClient(&http.Client{Transport: itr}), nil
 }
 }
-
-// finds the github token given the git repo id and the project id
-func (app *App) githubTokenFromRequest(
-	r *http.Request,
-) (*oauth2.Token, error) {
-	grID, err := strconv.ParseUint(chi.URLParam(r, "git_repo_id"), 0, 64)
-
-	if err != nil || grID == 0 {
-		return nil, fmt.Errorf("could not read git repo id")
-	}
-
-	// query for the git repo
-	gr, err := app.Repo.GitRepo.ReadGitRepo(uint(grID))
-
-	if err != nil {
-		return nil, err
-	}
-
-	// get the oauth integration
-	oauthInt, err := app.Repo.OAuthIntegration.ReadOAuthIntegration(gr.OAuthIntegrationID)
-
-	if err != nil {
-		return nil, err
-	}
-
-	return &oauth2.Token{
-		AccessToken:  string(oauthInt.AccessToken),
-		RefreshToken: string(oauthInt.RefreshToken),
-		TokenType:    "Bearer",
-	}, nil
-}

+ 14 - 1
server/api/integration_handler.go

@@ -591,7 +591,20 @@ func (app *App) getGithubAppOauthTokenFromRequest(r *http.Request) (*oauth2.Toke
 		oauth.MakeUpdateGithubAppOauthIntegrationFunction(oauthInt, *app.Repo))
 		oauth.MakeUpdateGithubAppOauthIntegrationFunction(oauthInt, *app.Repo))
 
 
 	if err != nil {
 	if err != nil {
-		return nil, err
+		// try again, in case the token got updated
+		oauthInt2, err := app.Repo.GithubAppOAuthIntegration.ReadGithubAppOauthIntegration(user.GithubAppIntegrationID)
+
+		if err != nil {
+			return nil, err
+		}
+
+		if oauthInt2.Expiry == oauthInt.Expiry {
+			return nil, err
+		} else {
+			oauthInt.AccessToken = oauthInt2.AccessToken
+			oauthInt.RefreshToken = oauthInt2.RefreshToken
+			oauthInt.Expiry = oauthInt2.Expiry
+		}
 	}
 	}
 
 
 	return &oauth2.Token{
 	return &oauth2.Token{

+ 2 - 0
server/api/release_handler.go

@@ -1057,6 +1057,7 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 					FolderPath:             gitAction.FolderPath,
 					FolderPath:             gitAction.FolderPath,
 					ImageRepoURL:           gitAction.ImageRepoURI,
 					ImageRepoURL:           gitAction.ImageRepoURI,
 					BuildEnv:               cEnv.Container.Env.Normal,
 					BuildEnv:               cEnv.Container.Env.Normal,
+					ClusterID:              release.ClusterID,
 				}
 				}
 
 
 				err = gaRunner.CreateEnvSecret()
 				err = gaRunner.CreateEnvSecret()
@@ -1444,6 +1445,7 @@ func (app *App) HandleRollbackRelease(w http.ResponseWriter, r *http.Request) {
 					FolderPath:             gitAction.FolderPath,
 					FolderPath:             gitAction.FolderPath,
 					ImageRepoURL:           gitAction.ImageRepoURI,
 					ImageRepoURL:           gitAction.ImageRepoURI,
 					BuildEnv:               cEnv.Container.Env.Normal,
 					BuildEnv:               cEnv.Container.Env.Normal,
+					ClusterID:              release.ClusterID,
 				}
 				}
 
 
 				err = gaRunner.CreateEnvSecret()
 				err = gaRunner.CreateEnvSecret()