Просмотр исходного кода

Merge branch 'master' of github.com:porter-dev/porter into nico/expanded-chart-overhaul

jnfrati 4 лет назад
Родитель
Сommit
a9de966f9c

+ 16 - 0
api/client/api.go

@@ -24,6 +24,8 @@ type Client struct {
 	Cookie         *http.Cookie
 	CookieFilePath string
 	Token          string
+
+	cfToken string
 }
 
 // NewClient constructs a new client based on a set of options
@@ -45,6 +47,11 @@ func NewClient(baseURL string, cookieFileName string) *Client {
 		client.Cookie = cookie
 	}
 
+	// look for a cloudflare access token specifically for Porter
+	if cfToken := os.Getenv("PORTER_CF_ACCESS_TOKEN"); cfToken != "" {
+		client.cfToken = cfToken
+	}
+
 	return client
 }
 
@@ -57,6 +64,11 @@ func NewClientWithToken(baseURL, token string) *Client {
 		},
 	}
 
+	// look for a cloudflare access token specifically for Porter
+	if cfToken := os.Getenv("PORTER_CF_ACCESS_TOKEN"); cfToken != "" {
+		client.cfToken = cfToken
+	}
+
 	return client
 }
 
@@ -191,6 +203,10 @@ func (c *Client) sendRequest(req *http.Request, v interface{}, useCookie bool) (
 		req.AddCookie(c.Cookie)
 	}
 
+	if c.cfToken != "" {
+		req.Header.Set("cf-access-token", c.cfToken)
+	}
+
 	res, err := c.HTTPClient.Do(req)
 
 	if err != nil {

+ 22 - 5
api/server/handlers/namespace/clone_env_group.go

@@ -2,6 +2,7 @@ package namespace
 
 import (
 	"net/http"
+	"strings"
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -46,21 +47,37 @@ func (c *CloneEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	envGroup, err := envgroup.GetEnvGroup(agent, request.Name, namespace, request.Version)
+	cm, _, err := agent.GetLatestVersionedConfigMap(request.Name, namespace)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
+	secret, _, err := agent.GetLatestVersionedSecret(request.Name, namespace)
+
 	if request.CloneName == "" {
 		request.CloneName = request.Name
 	}
 
+	vars := make(map[string]string)
+	secretVars := make(map[string]string)
+
+	for key, val := range cm.Data {
+		if !strings.Contains(val, "PORTERSECRET") {
+			vars[key] = val
+		}
+	}
+
+	for key, val := range secret.Data {
+		secretVars[key] = string(val)
+	}
+
 	configMap, err := envgroup.CreateEnvGroup(agent, types.ConfigMapInput{
-		Name:      request.CloneName,
-		Namespace: request.Namespace,
-		Variables: envGroup.Variables,
+		Name:            request.CloneName,
+		Namespace:       request.Namespace,
+		Variables:       vars,
+		SecretVariables: secretVars,
 	})
 
 	if err != nil {
@@ -68,7 +85,7 @@ func (c *CloneEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	envGroup, err = envgroup.ToEnvGroup(configMap)
+	envGroup, err := envgroup.ToEnvGroup(configMap)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 2 - 1
cli/cmd/apply.go

@@ -173,6 +173,7 @@ type ApplicationConfig struct {
 
 	Build struct {
 		ForceBuild bool
+		ForcePush  bool
 		Method     string
 		Context    string
 		Dockerfile string
@@ -506,7 +507,7 @@ func (d *Driver) updateApplication(resource *models.Resource, client *api.Client
 			return nil, err
 		}
 
-		err = updateAgent.Push()
+		err = updateAgent.Push(appConf.Build.ForcePush)
 
 		if err != nil {
 			return nil, err

+ 16 - 8
cli/cmd/deploy.go

@@ -206,6 +206,7 @@ var dockerfile string
 var method string
 var stream bool
 var buildFlagsEnv []string
+var forcePush bool
 
 func init() {
 	buildFlagsEnv = []string{}
@@ -288,6 +289,20 @@ func init() {
 		"stream update logs to porter dashboard",
 	)
 
+	updateCmd.PersistentFlags().BoolVar(
+		&forceBuild,
+		"force-build",
+		false,
+		"set this to force build an image (images tagged with \"latest\" have this set by default)",
+	)
+
+	updateCmd.PersistentFlags().BoolVar(
+		&forcePush,
+		"force-push",
+		false,
+		"set this to force push an image (images tagged with \"latest\" have this set by default)",
+	)
+
 	updateCmd.AddCommand(updateGetEnvCmd)
 
 	updateGetEnvCmd.PersistentFlags().StringVar(
@@ -297,13 +312,6 @@ func init() {
 		"file destination for .env files",
 	)
 
-	updateCmd.PersistentFlags().BoolVar(
-		&forceBuild,
-		"force-build",
-		false,
-		"set this to force build an image",
-	)
-
 	updateCmd.AddCommand(updateBuildCmd)
 	updateCmd.AddCommand(updatePushCmd)
 	updateCmd.AddCommand(updateConfigCmd)
@@ -523,7 +531,7 @@ func updatePushWithAgent(updateAgent *deploy.DeployAgent) error {
 		})
 	}
 
-	if err := updateAgent.Push(); err != nil {
+	if err := updateAgent.Push(forcePush); err != nil {
 		if stream {
 			updateAgent.StreamEvent(types.SubEvent{
 				EventID: "push",

+ 3 - 7
cli/cmd/deploy/create.go

@@ -273,15 +273,11 @@ func (c *CreateAgent) CreateFromDocker(
 		return "", err
 	}
 
-	imageExists, err := agent.CheckIfImageExists(fmt.Sprintf("%s:%s", imageURL, imageTag))
+	imageExists := agent.CheckIfImageExists(imageURL, imageTag)
 
-	if err != nil {
-		return "", err
-	}
-
-	if imageExists && imageTag != "default" && !forceBuild {
+	if imageExists && imageTag != "latest" && !forceBuild {
 		fmt.Printf("%s:%s already exists in the registry, so skipping build\n", imageURL, imageTag)
-	} else { // image does not exist or has tag default so we (re)build one
+	} else { // image does not exist or has tag "latest" so we (re)build one
 		env, err := GetEnvForRelease(c.Client, mergedValues, opts.ProjectID, opts.ClusterID, opts.Namespace)
 
 		if err != nil {

+ 10 - 9
cli/cmd/deploy/deploy.go

@@ -140,6 +140,8 @@ func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*Deplo
 	err = coalesceEnvGroups(deployAgent.client, deployAgent.opts.ProjectID, deployAgent.opts.ClusterID,
 		deployAgent.opts.Namespace, deployAgent.opts.EnvGroups, deployAgent.release.Config)
 
+	deployAgent.imageExists = deployAgent.agent.CheckIfImageExists(deployAgent.imageRepo, deployAgent.tag)
+
 	return deployAgent, err
 }
 
@@ -228,14 +230,6 @@ func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig, forceBuild b
 		d.tag = currentTag
 	}
 
-	imageExists, err := d.agent.CheckIfImageExists(fmt.Sprintf("%s:%s", d.imageRepo, d.tag))
-
-	if err != nil {
-		return err
-	}
-
-	d.imageExists = imageExists
-
 	// we do not want to re-build an image
 	// FIXME: what if overrideBuildConfig == nil but the image stays the same?
 	if overrideBuildConfig == nil && d.imageExists && d.tag != "latest" && !forceBuild {
@@ -245,6 +239,8 @@ func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig, forceBuild b
 
 	// if build is not local, fetch remote source
 	var basePath string
+	var err error
+
 	buildCtx := d.opts.LocalPath
 
 	if !d.opts.Local {
@@ -323,7 +319,12 @@ func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig, forceBuild b
 }
 
 // Push pushes a local image to the remote repository linked in the release
-func (d *DeployAgent) Push() error {
+func (d *DeployAgent) Push(forcePush bool) error {
+	if d.imageExists && !forcePush && d.tag != "latest" {
+		fmt.Printf("%s:%s has been pushed already, so skipping push\n", d.imageRepo, d.tag)
+		return nil
+	}
+
 	return d.agent.PushImage(fmt.Sprintf("%s:%s", d.imageRepo, d.tag))
 }
 

+ 122 - 5
cli/cmd/docker/agent.go

@@ -6,10 +6,12 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"net/http"
 	"os"
 	"strings"
 	"time"
 
+	"github.com/digitalocean/godo"
 	"github.com/docker/distribution/reference"
 	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/api/types/container"
@@ -158,24 +160,123 @@ var PullImageErrNotFound = fmt.Errorf("Requested image not found")
 
 var PullImageErrUnauthorized = fmt.Errorf("Could not pull image: unauthorized")
 
+func getRegistryRepositoryPair(imageRepo string) ([]string, error) {
+	named, err := reference.ParseNamed(imageRepo)
+
+	if err != nil {
+		return nil, err
+	}
+
+	path := reference.Path(named)
+
+	return strings.SplitN(path, "/", 2), nil
+}
+
 // CheckIfImageExists checks if the image exists in the registry
-func (a *Agent) CheckIfImageExists(image string) (bool, error) {
+func (a *Agent) CheckIfImageExists(imageRepo, imageTag string) bool {
+	registryToken, err := a.getContainerRegistryToken(imageRepo)
+
+	if err != nil {
+		return false
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
+	defer cancel()
+
+	if strings.Contains(imageRepo, "gcr.io") {
+		gcrRegRepo, err := getRegistryRepositoryPair(imageRepo)
+
+		if err != nil {
+			return false
+		}
+
+		named, err := reference.ParseNamed(imageRepo)
+
+		if err != nil {
+			return false
+		}
+
+		req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf(
+			"https://%s/v2/%s/%s/tags/list", reference.Domain(named), gcrRegRepo[0], gcrRegRepo[1],
+		), nil)
+
+		if err != nil {
+			return false
+		}
+
+		req.Header.Add("Content-Type", "application/json")
+		req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", registryToken))
+
+		resp, err := http.DefaultClient.Do(req)
+
+		if err != nil {
+			return false
+		}
+
+		defer resp.Body.Close()
+
+		var tags struct {
+			Tags []string `json:"tags,omitempty"`
+		}
+
+		err = json.NewDecoder(resp.Body).Decode(&tags)
+
+		if err != nil {
+			return false
+		}
+
+		for _, tag := range tags.Tags {
+			if tag == imageTag {
+				return true
+			}
+		}
+
+		return false
+	} else if strings.Contains(imageRepo, "registry.digitalocean.com") {
+		doRegRepo, err := getRegistryRepositoryPair(imageRepo)
+
+		if err != nil {
+			return false
+		}
+
+		doClient := godo.NewFromToken(registryToken)
+
+		manifests, _, err := doClient.Registry.ListRepositoryManifests(
+			ctx, doRegRepo[0], doRegRepo[1], &godo.ListOptions{},
+		)
+
+		if err != nil {
+			return false
+		}
+
+		for _, manifest := range manifests {
+			for _, tag := range manifest.Tags {
+				if tag == imageTag {
+					return true
+				}
+			}
+		}
+
+		return false
+	}
+
+	image := imageRepo + ":" + imageTag
 	encodedRegistryAuth, err := a.getEncodedRegistryAuth(image)
 
 	if err != nil {
-		return false, err
+		return false
 	}
 
 	_, err = a.client.DistributionInspect(context.Background(), image, encodedRegistryAuth)
 
 	if err == nil {
-		return true, nil
+		return true
 	} else if strings.Contains(err.Error(), "image not found") ||
 		strings.Contains(err.Error(), "does not exist in the registry") {
-		return false, nil
+		return false
 	}
 
-	return false, err
+	return false
 }
 
 // PullImage pulls an image specified by the image string
@@ -251,6 +352,22 @@ func (a *Agent) getPullOptions(image string) (types.ImagePullOptions, error) {
 	}, nil
 }
 
+func (a *Agent) getContainerRegistryToken(image string) (string, error) {
+	serverURL, err := GetServerURLFromTag(image)
+
+	if err != nil {
+		return "", err
+	}
+
+	_, secret, err := a.authGetter.GetCredentials(serverURL)
+
+	if err != nil {
+		return "", err
+	}
+
+	return secret, nil
+}
+
 func (a *Agent) getEncodedRegistryAuth(image string) (string, error) {
 	// get using server url
 	serverURL, err := GetServerURLFromTag(image)

+ 9 - 10
cli/cmd/job.go

@@ -22,17 +22,17 @@ var batchImageUpdateCmd = &cobra.Command{
 	Use:   "update-images",
 	Short: "Updates the image tag of all jobs in a namespace which use a specific image.",
 	Long: fmt.Sprintf(`
-%s 
+%s
 
 Updates the image tag of all jobs in a namespace which use a specific image. Note that for all
 jobs with version <= v0.4.0, this will trigger a new run of a manual job. However, for versions
->= v0.5.0, this will not create a new run of the job. 
+>= v0.5.0, this will not create a new run of the job.
 
 Example commands:
 
   %s
 
-This command is namespace-scoped and uses the default namespace. To specify a different namespace, 
+This command is namespace-scoped and uses the default namespace. To specify a different namespace,
 use the --namespace flag:
 
   %s
@@ -54,16 +54,16 @@ var waitCmd = &cobra.Command{
 	Use:   "wait",
 	Short: "Waits for a job to complete.",
 	Long: fmt.Sprintf(`
-%s 
+%s
 
 Waits for a job with a given name and namespace to complete a run. If the job completes successfully,
-this command exits with exit code 0. Otherwise, this command exits with exit code 1. 
+this command exits with exit code 0. Otherwise, this command exits with exit code 1.
 
 Example commands:
 
   %s
 
-This command is namespace-scoped and uses the default namespace. To specify a different namespace, 
+This command is namespace-scoped and uses the default namespace. To specify a different namespace,
 use the --namespace flag:
 
   %s
@@ -166,10 +166,10 @@ func waitForJob(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 		return pausedErr
 	}
 
-	// if no job exists with the given revision, wait up to 5 minutes
-	timeWait := time.Now().Add(5 * time.Minute)
+	// if no job exists with the given revision, wait up to 30 minutes
+	timeWait := time.Now().Add(30 * time.Minute)
 
-	for timeNow := time.Now(); timeNow.Before(timeWait); {
+	for time.Now().Before(timeWait) {
 		// get the jobs for that job chart
 		jobs, err := client.GetJobs(context.Background(), config.Project, config.Cluster, namespace, name)
 
@@ -196,7 +196,6 @@ func waitForJob(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 
 		// otherwise, return no error
 		time.Sleep(10 * time.Second)
-		continue
 	}
 
 	return fmt.Errorf("timed out waiting for job")

+ 6 - 0
cli/cmd/login/server.go

@@ -6,6 +6,7 @@ import (
 	"net"
 	"net/http"
 	"net/url"
+	"os"
 	"strings"
 	"time"
 
@@ -99,6 +100,11 @@ func ExchangeToken(host, code string) (string, error) {
 	req.Header.Set("Content-Type", "application/json; charset=utf-8")
 	req.Header.Set("Accept", "application/json; charset=utf-8")
 
+	// look for a cloudflare access token specifically for Porter
+	if cfToken := os.Getenv("PORTER_CF_ACCESS_TOKEN"); cfToken != "" {
+		req.Header.Set("cf-access-token", cfToken)
+	}
+
 	client := &http.Client{
 		Timeout: time.Minute,
 	}

+ 35 - 0
dashboard/src/assets/command-line-icon.tsx

@@ -0,0 +1,35 @@
+import React, { SVGProps } from "react";
+import styled from "styled-components";
+
+function CommandLineIcon(props: SVGProps<SVGElement>) {
+  return (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      x="0"
+      y="0"
+      version="1.1"
+      viewBox="0 0 24 24"
+      xmlSpace="preserve"
+      className={props.className}
+      onClick={props.onClick}
+    >
+      <linearGradient
+        x1="825.344"
+        x2="825.344"
+        y1="-528.502"
+        y2="-529.502"
+        gradientUnits="userSpaceOnUse"
+      >
+        <stop offset="0" stopColor="#656565"></stop>
+        <stop offset="0.618" stopColor="#1b1b1b"></stop>
+        <stop offset="0.629" stopColor="#545454"></stop>
+        <stop offset="0.983" stopColor="#3e3e3e"></stop>
+      </linearGradient>
+      <path d="M3.2 17.3L2 15.9c-.2-.2-.2-.6.1-.7l5.4-4.5c.3-.2.3-.6 0-.8L2 5.4c-.2-.2-.2-.5 0-.8l1.2-1.5c.2-.1.5-.2.7 0l7.6 6.3c.5.4.5 1.3 0 1.7l-7.6 6.3c-.2.2-.5.2-.7-.1zM21.6 21H9.4c-.3 0-.6-.2-.6-.5v-1.9c0-.3.2-.5.6-.5h12.2c.3 0 .6.2.6.5v1.9c-.1.3-.3.5-.6.5z"></path>
+    </svg>
+  );
+}
+
+export default CommandLineIcon;
+
+const SVG = styled.svg``;

+ 70 - 42
dashboard/src/components/porter-form/field-components/KeyValueArray.tsx

@@ -485,52 +485,80 @@ export const getFinalVariablesForKeyValueArray: GetFinalVariablesFunction = (
     };
   }
 
-  let obj = {
-    normal: {},
-  } as any;
-  const rg = /(?:^|[^\\])(\\n)/g;
-  const fixNewlines = (s: string) => {
-    while (rg.test(s)) {
-      s = s.replace(rg, (str) => {
-        if (str.length == 2) return "\n";
-        if (str[0] != "\\") return str[0] + "\n";
-        return "\\n";
-      });
-    }
-    return s;
-  };
-  const isNumber = (s: string) => {
-    return !isNaN(!s ? NaN : Number(String(s).trim()));
-  };
-  state.values.forEach((entry: any, i: number) => {
-    if (isNumber(entry.value)) {
-      obj.normal[entry.key] = entry.value;
-    } else {
-      obj.normal[entry.key] = fixNewlines(entry.value);
+  if (props.variable.includes("env")) {
+    let obj = {
+      normal: {},
+    } as any;
+    const rg = /(?:^|[^\\])(\\n)/g;
+    const fixNewlines = (s: string) => {
+      while (rg.test(s)) {
+        s = s.replace(rg, (str) => {
+          if (str.length == 2) return "\n";
+          if (str[0] != "\\") return str[0] + "\n";
+          return "\\n";
+        });
+      }
+      return s;
+    };
+    const isNumber = (s: string) => {
+      return !isNaN(!s ? NaN : Number(String(s).trim()));
+    };
+    state.values.forEach((entry: any, i: number) => {
+      if (isNumber(entry.value)) {
+        obj.normal[entry.key] = entry.value;
+      } else {
+        obj.normal[entry.key] = fixNewlines(entry.value);
+      }
+    });
+
+    if (state.synced_env_groups?.length) {
+      obj.synced = state.synced_env_groups.map((envGroup) => ({
+        name: envGroup?.name,
+        version: envGroup?.version,
+        keys: Object.entries(envGroup?.variables || {}).map(([key, val]) => ({
+          name: key,
+          secret: val.includes("PORTERSECRET"),
+        })),
+      }));
     }
-  });
-
-  if (state.synced_env_groups?.length) {
-    obj.synced = state.synced_env_groups.map((envGroup) => ({
-      name: envGroup?.name,
-      version: envGroup?.version,
-      keys: Object.entries(envGroup?.variables || {}).map(([key, val]) => ({
-        name: key,
-        secret: val.includes("PORTERSECRET"),
-      })),
-    }));
-  }
 
-  const variableContent = props.variable.split(".");
-  let variable = props.variable;
+    const variableContent = props.variable.split(".");
+    let variable = props.variable;
 
-  if (variable.includes("normal")) {
-    variable = `${variableContent[0]}.${variableContent[1]}`;
-  }
+    if (variable.includes("normal")) {
+      variable = `${variableContent[0]}.${variableContent[1]}`;
+    }
 
-  return {
-    [variable]: obj,
-  };
+    return {
+      [variable]: obj,
+    };
+  } else {
+    let obj = {} as any;
+    const rg = /(?:^|[^\\])(\\n)/g;
+    const fixNewlines = (s: string) => {
+      while (rg.test(s)) {
+        s = s.replace(rg, (str) => {
+          if (str.length == 2) return "\n";
+          if (str[0] != "\\") return str[0] + "\n";
+          return "\\n";
+        });
+      }
+      return s;
+    };
+    const isNumber = (s: string) => {
+      return !isNaN(!s ? NaN : Number(String(s).trim()));
+    };
+    state.values.forEach((entry: any, i: number) => {
+      if (isNumber(entry.value)) {
+        obj[entry.key] = entry.value;
+      } else {
+        obj[entry.key] = fixNewlines(entry.value);
+      }
+    });
+    return {
+      [props.variable]: obj,
+    };
+  }
 };
 
 export default KeyValueArray;

+ 104 - 28
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -236,43 +236,114 @@ export const ExpandedEnvGroupFC = ({
         setTimeout(() => setButtonStatus(""), 1000);
       }
     } else {
-      const configMapSecretVariables = fillWithDeletedVariables(
-        originalEnvVars.filter((variable) => {
-          return variable.value.includes("PORTERSECRET");
-        }),
-        variables.filter((variable) => {
-          return variable.value.includes("PORTERSECRET") || variable.hidden;
-        })
-      ).reduce(
-        (acc, variable) => ({
-          ...acc,
-          [variable.key]: variable.value,
-        }),
-        {}
+      // SEPARATE THE TWO KINDS OF VARIABLES
+      let secret = variables.filter(
+        (variable) =>
+          variable.hidden && !variable.value.includes("PORTERSECRET")
       );
 
-      const configMapVariables = fillWithDeletedVariables(
-        originalEnvVars,
-        variables.filter(
-          (variable) =>
-            !variable.hidden || !variable.value?.includes("PORTERSECRET")
-        )
-      ).reduce(
-        (acc, variable) => ({
-          ...acc,
-          [variable.key]: variable.value,
-        }),
-        {}
+      let normal = variables.filter(
+        (variable) =>
+          !variable.hidden && !variable.value.includes("PORTERSECRET")
       );
 
+      // Filter variables that weren't updated
+      normal = normal.reduce((acc, variable) => {
+        const originalVar = originalEnvVars.find(
+          (orgVar) => orgVar.key === variable.key
+        );
+
+        // Remove variables that weren't updated
+        if (variable.value === originalVar?.value) {
+          return acc;
+        }
+
+        // add the variable that's going to be updated
+        return [...acc, variable];
+      }, []);
+
+      secret = secret.reduce((acc, variable) => {
+        const originalVar = originalEnvVars.find(
+          (orgVar) => orgVar.key === variable.key
+        );
+
+        // Remove variables that weren't updated
+        if (variable.value === originalVar?.value) {
+          return acc;
+        }
+
+        // add the variable that's going to be updated
+        return [...acc, variable];
+      }, []);
+
+      // Check through the original env vars to see if there's a missing variable, if it is, then means it was removed
+      const removedNormal = originalEnvVars.reduce((acc, orgVar) => {
+        if (orgVar.value.includes("PORTERSECRET")) {
+          return acc;
+        }
+
+        const variableFound = variables.find(
+          (variable) => orgVar.key === variable.key
+        );
+        if (variableFound) {
+          return acc;
+        }
+        return [
+          ...acc,
+          {
+            key: orgVar.key,
+            value: null,
+          },
+        ];
+      }, []);
+
+      const removedSecret = originalEnvVars.reduce((acc, orgVar) => {
+        if (!orgVar.value.includes("PORTERSECRET")) {
+          return acc;
+        }
+
+        const variableFound = variables.find(
+          (variable) => orgVar.key === variable.key
+        );
+        if (variableFound) {
+          return acc;
+        }
+        return [
+          ...acc,
+          {
+            key: orgVar.key,
+            value: null,
+          },
+        ];
+      }, []);
+
+      normal = [...normal, ...removedNormal];
+      secret = [...secret, ...removedSecret];
+
+      const normalObject = normal.reduce((acc, val) => {
+        return {
+          ...acc,
+          [val.key]: val.value,
+        };
+      }, {});
+
+      const secretObject = secret.reduce((acc, val) => {
+        return {
+          ...acc,
+          [val.key]: val.value,
+        };
+      }, {});
+
+      console.log({ normalObject, secretObject });
+
       try {
         const updatedEnvGroup = await api
           .updateConfigMap(
             "<token>",
             {
               name,
-              variables: configMapVariables,
-              secret_variables: configMapSecretVariables,
+              variables: normalObject,
+              secret_variables: secretObject,
             },
             {
               id: currentProject.id,
@@ -430,7 +501,12 @@ const EnvGroupSettings = ({
   const [isAuthorized] = useAuth();
 
   const canDelete = useMemo(() => {
-    return envGroup?.applications.length === 0;
+    // add a case for when applications is null - in this case this is a deprecated env group version
+    if (!envGroup?.applications) {
+      return true;
+    }
+
+    return envGroup?.applications?.length === 0;
   }, [envGroup]);
 
   return (

+ 17 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -41,6 +41,7 @@ import { NewWebsocketOptions, useWebsockets } from "shared/hooks/useWebsockets";
 import JobList from "./jobs/JobList";
 import SaveButton from "components/SaveButton";
 import useAuth from "shared/auth/useAuth";
+import JobMetricsSection from "./metrics/JobMetricsSection";
 
 type PropsType = WithAuthProps &
   RouteComponentProps & {
@@ -70,6 +71,7 @@ type StateType = {
   expandedJobRun: any;
   pods: any;
   forceRefreshRevisions: boolean;
+  showConnectionModal: boolean;
 };
 
 class ExpandedJobChart extends Component<PropsType, StateType> {
@@ -93,6 +95,7 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
     expandedJobRun: null as any,
     pods: null as any,
     forceRefreshRevisions: false,
+    showConnectionModal: false,
   };
 
   getPods = (job: any, callback?: () => void) => {
@@ -663,6 +666,7 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
               latestChartVersion={Number(
                 this.state.currentChart.latest_version
               )}
+              chartName={this.state.currentChart?.name}
             />
           </TabWrapper>
         );
@@ -1029,7 +1033,6 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
     let { currentChart } = this.state;
     let chart = currentChart;
     let run = this.state.expandedJobRun;
-
     return (
       <StyledExpandedChart>
         <HeaderWrapper>
@@ -1068,13 +1071,17 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
                 label: "Logs",
                 value: "logs",
               },
+              {
+                label: "Metrics",
+                value: "metrics",
+              },
               {
                 label: "Config",
                 value: "config",
               },
             ]}
           >
-            {this.state.currentTab === "logs" ? (
+            {this.state.currentTab === "logs" && (
               <JobLogsWrapper>
                 <Logs
                   selectedPod={this.state.pods[0]}
@@ -1082,9 +1089,16 @@ class ExpandedJobChart extends Component<PropsType, StateType> {
                   rawText={true}
                 />
               </JobLogsWrapper>
-            ) : (
+            )}
+            {this.state.currentTab === "config" && (
               <>{this.renderConfigSection(run)}</>
             )}
+            {this.state.currentTab === "metrics" && (
+              <JobMetricsSection
+                jobChart={this.state.currentChart}
+                jobRun={run}
+              />
+            )}
           </TabRegion>
         </BodyWrapper>
       </StyledExpandedChart>

+ 52 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/ConnectToJobInstructionsModal.tsx

@@ -0,0 +1,52 @@
+import Modal from "main/home/modals/Modal";
+import React from "react";
+import { ChartType } from "shared/types";
+import styled from "styled-components";
+
+const ConnectToJobInstructionsModal: React.FC<{
+  show: boolean;
+  onClose: () => void;
+  chartName: string;
+}> = ({ show, chartName, onClose }) => {
+  if (!show) {
+    return null;
+  }
+
+  return (
+    <Modal
+      onRequestClose={() => onClose()}
+      width="700px"
+      height="300px"
+      title="Shell Access Instructions"
+    >
+      To get shell access to this job run, make sure you have the Porter CLI
+      installed (installation instructions&nbsp;
+      <a href={"https://docs.porter.run/cli/installation"} target="_blank">
+        here
+      </a>
+      ).
+      <br />
+      <br />
+      Run the following line of code, and make sure to change the command to
+      something your container can run:
+      <Code>porter run {chartName || "[APP-NAME]"} -- [COMMAND]</Code>
+      Note that this will create a copy of the most recent job run for this
+      template.
+    </Modal>
+  );
+};
+
+export default ConnectToJobInstructionsModal;
+
+const Code = styled.div`
+  background: #181b21;
+  padding: 10px 15px;
+  border: 1px solid #ffffff44;
+  border-radius: 5px;
+  margin: 10px 0px 15px;
+  color: #ffffff;
+  font-size: 13px;
+  user-select: text;
+  line-height: 1em;
+  font-family: monospace;
+`;

+ 4 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx

@@ -9,6 +9,8 @@ import plus from "assets/plus.svg";
 import closeRounded from "assets/close-rounded.png";
 import KeyValueArray from "components/form-components/KeyValueArray";
 import DynamicLink from "components/DynamicLink";
+import CommandLineIcon from "assets/command-line-icon";
+import ConnectToJobInstructionsModal from "./ConnectToJobInstructionsModal";
 
 type PropsType = {
   job: any;
@@ -26,6 +28,7 @@ type StateType = {
   expanded: boolean;
   configIsExpanded: boolean;
   pods: any[];
+  showConnectionModal: boolean;
 };
 
 export default class JobResource extends Component<PropsType, StateType> {
@@ -33,6 +36,7 @@ export default class JobResource extends Component<PropsType, StateType> {
     expanded: false,
     configIsExpanded: false,
     pods: [] as any[],
+    showConnectionModal: false,
   };
 
   expandJob = (event: MouseEvent) => {

+ 59 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/TempJobList.tsx

@@ -4,6 +4,8 @@ import styled from "styled-components";
 import { PorterFormContext } from "components/porter-form/PorterFormContextProvider";
 import JobList from "./JobList";
 import SaveButton from "components/SaveButton";
+import CommandLineIcon from "assets/command-line-icon";
+import ConnectToJobInstructionsModal from "./ConnectToJobInstructionsModal";
 
 interface Props {
   isAuthorized: any;
@@ -16,6 +18,7 @@ interface Props {
   latestChartVersion: number;
   isDeployedFromGithub: boolean;
   repositoryUrl?: string;
+  chartName: string;
 }
 
 /**
@@ -24,6 +27,7 @@ interface Props {
  */
 const TempJobList: React.FC<Props> = (props) => {
   const { getSubmitValues } = useContext(PorterFormContext);
+  const [showConnectionModal, setShowConnectionModal] = useState(false);
   const [searchInput, setSearchInput] = useState("");
 
   let saveButton = (
@@ -40,6 +44,15 @@ const TempJobList: React.FC<Props> = (props) => {
       >
         <i className="material-icons">play_arrow</i> Run Job
       </SaveButton>
+      <CLIModalIconWrapper
+        onClick={(e) => {
+          e.preventDefault();
+          setShowConnectionModal(true);
+        }}
+      >
+        <CLIModalIcon />
+        Shell Access
+      </CLIModalIconWrapper>
     </ButtonWrapper>
   );
 
@@ -59,6 +72,11 @@ const TempJobList: React.FC<Props> = (props) => {
         currentChartVersion={props.currentChartVersion}
         latestChartVersion={props.latestChartVersion}
       />
+      <ConnectToJobInstructionsModal
+        show={showConnectionModal}
+        onClose={() => setShowConnectionModal(false)}
+        chartName={props.chartName}
+      />
     </>
   );
 };
@@ -66,5 +84,46 @@ const TempJobList: React.FC<Props> = (props) => {
 export default TempJobList;
 
 const ButtonWrapper = styled.div`
+  display: flex;
   margin: 5px 0 35px;
+  justify-content: space-between;
+`;
+
+const CLIModalIconWrapper = styled.div`
+  height: 35px;
+  font-size: 13px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 6px 20px 6px 10px;
+  text-align: left;
+  border: 1px solid #ffffff55;
+  border-radius: 8px;
+  background: #ffffff11;
+  color: #ffffffdd;
+  cursor: pointer;
+
+  :hover {
+    cursor: pointer;
+    background: #ffffff22;
+    > path {
+      fill: #ffffff77;
+    }
+  }
+
+  > path {
+    fill: #ffffff99;
+  }
+`;
+
+const CLIModalIcon = styled(CommandLineIcon)`
+  width: 32px;
+  height: 32px;
+  padding: 8px;
+
+  > path {
+    fill: #ffffff99;
+  }
 `;

+ 604 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/JobMetricsSection.tsx

@@ -0,0 +1,604 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+import ParentSize from "@visx/responsive/lib/components/ParentSize";
+
+import settings from "assets/settings.svg";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { ChartTypeWithExtendedConfig, StorageType } from "shared/types";
+
+import TabSelector from "components/TabSelector";
+import Loading from "components/Loading";
+import SelectRow from "components/form-components/SelectRow";
+import AreaChart from "./AreaChart";
+import { MetricNormalizer } from "./MetricNormalizer";
+import { AvailableMetrics, NormalizedMetricsData } from "./types";
+import CheckboxRow from "components/form-components/CheckboxRow";
+
+type PropsType = {
+  jobChart: ChartTypeWithExtendedConfig;
+  jobRun: any;
+};
+
+export const resolutions: { [range: string]: string } = {
+  "1H": "1s",
+  "6H": "15s",
+  "1D": "15s",
+  "1M": "5h",
+};
+
+export const secondsBeforeNow: { [range: string]: number } = {
+  "1H": 60 * 60,
+  "6H": 60 * 60 * 6,
+  "1D": 60 * 60 * 24,
+  "1M": 60 * 60 * 24 * 30,
+};
+
+const JobMetricsSection: React.FunctionComponent<PropsType> = ({
+  jobChart: currentChart,
+  jobRun,
+}) => {
+  const [pods, setPods] = useState([]);
+  const [selectedPod, setSelectedPod] = useState("");
+  const [controllerOptions, setControllerOptions] = useState([]);
+  const [selectedController, setSelectedController] = useState(null);
+  const [ingressOptions, setIngressOptions] = useState([]);
+  const [selectedIngress, setSelectedIngress] = useState(null);
+  const [selectedRange, setSelectedRange] = useState("1H");
+  const [selectedMetric, setSelectedMetric] = useState("cpu");
+  const [selectedMetricLabel, setSelectedMetricLabel] = useState(
+    "CPU Utilization (vCPUs)"
+  );
+  const [dropdownExpanded, setDropdownExpanded] = useState(false);
+  const [data, setData] = useState<NormalizedMetricsData[]>([]);
+  const [showMetricsSettings, setShowMetricsSettings] = useState(false);
+  const [metricsOptions, setMetricsOptions] = useState([
+    { value: "cpu", label: "CPU Utilization (vCPUs)" },
+    { value: "memory", label: "RAM Utilization (Mi)" },
+  ]);
+  const [isLoading, setIsLoading] = useState(0);
+  const [hpaData, setHpaData] = useState([]);
+  const [hpaEnabled, setHpaEnabled] = useState(
+    currentChart?.config?.autoscaling?.enabled
+  );
+
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+
+  useEffect(() => {
+    setIsLoading((prev) => prev + 1);
+
+    api
+      .getChartControllers(
+        "<token>",
+        {},
+        {
+          id: currentProject.id,
+          name: currentChart.name,
+          namespace: currentChart.namespace,
+          cluster_id: currentCluster.id,
+          revision: currentChart.version,
+        }
+      )
+      .then((res) => {
+        const controllerOptions = res.data.map((controller: any) => {
+          let name = controller?.metadata?.name;
+          return { value: controller, label: name };
+        });
+
+        setControllerOptions(controllerOptions);
+        setSelectedController(controllerOptions[0]?.value);
+      })
+      .catch((err) => {
+        setCurrentError(JSON.stringify(err));
+        setControllerOptions([]);
+      })
+      .finally(() => {
+        setIsLoading((prev) => prev - 1);
+      });
+  }, [currentChart, currentCluster, currentProject]);
+
+  useEffect(() => {
+    getPods();
+  }, [selectedController]);
+
+  const getPods = () => {
+    const jobName = jobRun?.metadata?.name;
+    const selector = `job-name=${jobName}`;
+
+    setIsLoading((prev) => prev + 1);
+
+    api
+      .getMatchingPods(
+        "<token>",
+        {
+          namespace: selectedController?.metadata?.namespace,
+          selectors: [selector],
+        },
+        {
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      )
+      .then((res) => {
+        const pods = res?.data?.map((pod: any) => {
+          let name = pod?.metadata?.name;
+          return { value: name, label: name };
+        });
+
+        setPods(pods);
+        setSelectedPod(pods[0].value);
+
+        getMetrics();
+      })
+      .catch((err) => {
+        setCurrentError(JSON.stringify(err));
+        return;
+      })
+      .finally(() => {
+        setIsLoading((prev) => prev - 1);
+      });
+  };
+
+  const getAutoscalingThreshold = async (
+    metricType: "cpu_hpa_threshold" | "memory_hpa_threshold",
+    shouldsum: boolean,
+    namespace: string,
+    start: number,
+    end: number
+  ) => {
+    setIsLoading((prev) => prev + 1);
+    setHpaData([]);
+    try {
+      const res = await api.getMetrics(
+        "<token>",
+        {
+          metric: metricType,
+          shouldsum: shouldsum,
+          kind: selectedController?.kind,
+          name: selectedController?.metadata.name,
+          namespace: namespace,
+          startrange: start,
+          endrange: end,
+          resolution: resolutions[selectedRange],
+          pods: [],
+        },
+        {
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      );
+
+      if (!Array.isArray(res.data) || !res.data[0]?.results) {
+        return;
+      }
+      const autoscalingMetrics = new MetricNormalizer(res.data, metricType);
+      setHpaData(autoscalingMetrics.getParsedData());
+      return;
+    } catch (error) {
+      console.error(error);
+    } finally {
+      setIsLoading((prev) => prev - 1);
+    }
+  };
+
+  const getMetrics = async () => {
+    if (pods?.length == 0) {
+      return;
+    }
+    try {
+      let namespace = currentChart.namespace;
+
+      const start = Math.round(
+        new Date(jobRun?.status?.startTime).getTime() / 1000
+      );
+
+      let end = Math.round(
+        new Date(jobRun?.status?.completionTime).getTime() / 1000
+      );
+
+      if (!jobRun?.status?.completionTime) {
+        end = Math.round(new Date().getTime() / 1000);
+      }
+
+      let podNames = [selectedPod] as string[];
+
+      setIsLoading((prev) => prev + 1);
+      setData([]);
+
+      const res = await api.getMetrics(
+        "<token>",
+        {
+          metric: selectedMetric,
+          shouldsum: false,
+          kind: selectedController?.kind,
+          name: selectedController?.metadata.name,
+          namespace: namespace,
+          startrange: start,
+          endrange: end,
+          resolution: resolutions[selectedRange],
+          pods: podNames,
+        },
+        {
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      );
+
+      const metrics = new MetricNormalizer(
+        res.data,
+        selectedMetric as AvailableMetrics
+      );
+
+      // transform the metrics to expected form
+      setData(metrics.getParsedData());
+    } catch (error) {
+      setCurrentError(JSON.stringify(error));
+    } finally {
+      setIsLoading((prev) => prev - 1);
+    }
+  };
+
+  useEffect(() => {
+    if (selectedMetric && selectedRange && selectedPod && selectedController) {
+      getMetrics();
+    }
+  }, [
+    selectedMetric,
+    selectedRange,
+    selectedPod,
+    selectedController,
+    selectedIngress,
+  ]);
+
+  const renderMetricsSettings = () => {
+    if (showMetricsSettings && true) {
+      if (selectedMetric == "nginx:errors") {
+        return (
+          <>
+            <DropdownOverlay onClick={() => setShowMetricsSettings(false)} />
+            <DropdownAlt dropdownWidth="330px" dropdownMaxHeight="300px">
+              <Label>Additional Settings</Label>
+              <SelectRow
+                label="Target Ingress"
+                value={selectedIngress}
+                setActiveValue={(x: any) => setSelectedIngress(x)}
+                options={ingressOptions}
+                width="100%"
+              />
+            </DropdownAlt>
+          </>
+        );
+      }
+
+      return (
+        <>
+          <DropdownOverlay onClick={() => setShowMetricsSettings(false)} />
+          <DropdownAlt dropdownWidth="330px" dropdownMaxHeight="300px">
+            <Label>Additional Settings</Label>
+            <SelectRow
+              label="Target Controller"
+              value={selectedController}
+              setActiveValue={(x: any) => setSelectedController(x)}
+              options={controllerOptions}
+              width="100%"
+            />
+            <SelectRow
+              label="Target Pod"
+              value={selectedPod}
+              setActiveValue={(x: any) => setSelectedPod(x)}
+              options={pods}
+              width="100%"
+            />
+          </DropdownAlt>
+        </>
+      );
+    }
+  };
+
+  const renderDropdown = () => {
+    if (dropdownExpanded) {
+      return (
+        <>
+          <DropdownOverlay onClick={() => setDropdownExpanded(false)} />
+          <Dropdown
+            dropdownWidth="230px"
+            dropdownMaxHeight="200px"
+            onClick={() => setDropdownExpanded(false)}
+          >
+            {renderOptionList()}
+          </Dropdown>
+        </>
+      );
+    }
+  };
+
+  const renderOptionList = () => {
+    return metricsOptions.map(
+      (option: { value: string; label: string }, i: number) => {
+        return (
+          <Option
+            key={i}
+            selected={option.value === selectedMetric}
+            onClick={() => {
+              setSelectedMetric(option.value);
+              setSelectedMetricLabel(option.label);
+            }}
+            lastItem={i === metricsOptions.length - 1}
+          >
+            {option.label}
+          </Option>
+        );
+      }
+    );
+  };
+
+  const hasJobRunnedForMoreThan5m = () => {
+    const firstDate = new Date(jobRun.status.startTime);
+    const secondDate = jobRun?.status?.completionTime
+      ? new Date(jobRun?.status?.completionTime)
+      : new Date();
+    const _5M_IN_MILISECONDS = 60000;
+    return secondDate.getTime() - firstDate.getTime() > _5M_IN_MILISECONDS;
+  };
+
+  return (
+    <StyledMetricsSection>
+      <MetricsHeader>
+        <Flex>
+          <MetricSelector
+            onClick={() => setDropdownExpanded(!dropdownExpanded)}
+          >
+            <MetricsLabel>{selectedMetricLabel}</MetricsLabel>
+            <i className="material-icons">arrow_drop_down</i>
+            {renderDropdown()}
+          </MetricSelector>
+
+          <Highlight color={"#7d7d81"} onClick={getMetrics}>
+            <i className="material-icons">autorenew</i>
+          </Highlight>
+        </Flex>
+      </MetricsHeader>
+      {isLoading > 0 && <Loading />}
+      {data.length === 0 && isLoading === 0 && (
+        <>
+          {selectedMetric === "cpu" && hasJobRunnedForMoreThan5m() ? (
+            <Message>
+              No data available yet.
+              <Highlight color={"#8590ff"} onClick={getMetrics}>
+                <i className="material-icons">autorenew</i>
+                Refresh
+              </Highlight>
+            </Message>
+          ) : (
+            <Message>
+              <Highlight color={"#8590ff"} disableHover>
+                CPU data is not available for jobs that ran for less than 5
+                minutes.
+              </Highlight>
+            </Message>
+          )}
+        </>
+      )}
+      {data.length > 0 && isLoading === 0 && (
+        <>
+          {currentChart?.config?.autoscaling?.enabled &&
+            ["cpu", "memory"].includes(selectedMetric) && (
+              <CheckboxRow
+                toggle={() => setHpaEnabled((prev: any) => !prev)}
+                checked={hpaEnabled}
+                label="Show Autoscaling Threshold"
+              />
+            )}
+          <ParentSize>
+            {({ width, height }) => (
+              <AreaChart
+                dataKey={selectedMetricLabel}
+                data={data}
+                hpaData={hpaData}
+                hpaEnabled={
+                  hpaEnabled && ["cpu", "memory"].includes(selectedMetric)
+                }
+                width={width}
+                height={height - 10}
+                resolution={selectedRange}
+                margin={{ top: 40, right: -40, bottom: 0, left: 50 }}
+              />
+            )}
+          </ParentSize>
+        </>
+      )}
+    </StyledMetricsSection>
+  );
+};
+
+export default JobMetricsSection;
+
+const Highlight = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-left: 8px;
+  color: ${(props: { color: string; disableHover?: boolean }) => props.color};
+  cursor: ${(props) => (props.disableHover ? "unset" : "pointer")};
+
+  > i {
+    font-size: 20px;
+    margin-right: 3px;
+  }
+`;
+
+const Label = styled.div`
+  font-weight: bold;
+`;
+
+const Relative = styled.div`
+  position: relative;
+`;
+
+const Message = styled.div`
+  display: flex;
+  height: 100%;
+  width: calc(100% - 150px);
+  align-items: center;
+  justify-content: center;
+  margin-left: 75px;
+  text-align: center;
+  color: #ffffff44;
+  font-size: 13px;
+`;
+
+const IconWrapper = styled.div`
+  display: flex;
+  position: relative;
+  align-items: center;
+  justify-content: center;
+  margin-top: 2px;
+  border-radius: 30px;
+  height: 25px;
+  width: 25px;
+  margin-left: 8px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
+const SettingsIcon = styled.img`
+  opacity: 0.4;
+  width: 20px;
+  height: 20px;
+  margin-left: -1px;
+  margin-bottom: -2px;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const MetricsHeader = styled.div`
+  width: 100%;
+  display: flex;
+  align-items: center;
+  overflow: visible;
+  justify-content: space-between;
+`;
+
+const DropdownOverlay = styled.div`
+  position: fixed;
+  width: 100%;
+  height: 100%;
+  z-index: 10;
+  left: 0px;
+  top: 0px;
+  cursor: default;
+`;
+
+const Option = styled.div`
+  width: 100%;
+  border-top: 1px solid #00000000;
+  border-bottom: 1px solid
+    ${(props: { selected: boolean; lastItem: boolean }) =>
+      props.lastItem ? "#ffffff00" : "#ffffff15"};
+  height: 37px;
+  font-size: 13px;
+  padding-top: 9px;
+  align-items: center;
+  padding-left: 15px;
+  cursor: pointer;
+  padding-right: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  background: ${(props: { selected: boolean; lastItem: boolean }) =>
+    props.selected ? "#ffffff11" : ""};
+
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
+const Dropdown = styled.div`
+  position: absolute;
+  left: 0;
+  top: calc(100% + 10px);
+  background: #26282f;
+  width: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
+    props.dropdownWidth};
+  max-height: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
+    props.dropdownMaxHeight || "300px"};
+  border-radius: 3px;
+  z-index: 999;
+  overflow-y: auto;
+  margin-bottom: 20px;
+  box-shadow: 0px 4px 10px 0px #00000088;
+`;
+
+const DropdownAlt = styled(Dropdown)`
+  padding: 20px 20px 7px;
+  overflow: visible;
+`;
+
+const RangeWrapper = styled.div`
+  float: right;
+  font-weight: bold;
+  width: 156px;
+  margin-top: -8px;
+`;
+
+const MetricSelector = styled.div`
+  font-size: 13px;
+  font-weight: 500;
+  position: relative;
+  color: #ffffff;
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  border-radius: 5px;
+  :hover {
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > i {
+    border-radius: 20px;
+    font-size: 20px;
+    margin-left: 10px;
+  }
+`;
+
+const MetricsLabel = styled.div`
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  max-width: 200px;
+`;
+
+const StyledMetricsSection = styled.div`
+  width: 100%;
+  min-height: 400px;
+  height: 50vh;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  font-size: 13px;
+  border-radius: 8px;
+  border: 1px solid #ffffff33;
+  padding: 18px 22px;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;

+ 1 - 3
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -295,9 +295,7 @@ const AWSFormSectionFC: React.FC<PropsType> = (props) => {
       hosting: "aws",
     });
 
-    window.open(
-      "https://docs.getporter.dev/docs/getting-started-with-porter-on-aws"
-    );
+    window.open("https://docs.porter.run/getting-started/provisioning-on-aws");
   };
 
   return (

+ 1 - 1
dashboard/src/main/home/provisioner/GCPFormSection.tsx

@@ -279,7 +279,7 @@ const GCPFormSectionFC: React.FC<PropsType> = (props) => {
       hosting: "gcp",
     });
 
-    window.open("https://docs.getporter.dev/docs/getting-started-on-gcp");
+    window.open("https://docs.porter.run/getting-started/provisioning-on-gcp");
   };
 
   return (

+ 1 - 1
go.mod

@@ -11,7 +11,7 @@ require (
 	github.com/buildpacks/pack v0.19.0
 	github.com/cli/cli v1.11.0
 	github.com/dgrijalva/jwt-go v3.2.0+incompatible
-	github.com/digitalocean/godo v1.56.0
+	github.com/digitalocean/godo v1.75.0
 	github.com/docker/cli v20.10.11+incompatible
 	github.com/docker/distribution v2.7.1+incompatible
 	github.com/docker/docker v20.10.12+incompatible

+ 3 - 0
go.sum

@@ -431,6 +431,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cu
 github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
 github.com/digitalocean/godo v1.56.0 h1:wXqWJyywrDO3YO2T4Kh8TwbCPOa+OI2vC8qh0/Ngmjk=
 github.com/digitalocean/godo v1.56.0/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2xcz76ulEJRU=
+github.com/digitalocean/godo v1.75.0 h1:UijUv60I095CqJqGKdjY2RTPnnIa4iFddmq+1wfyS4Y=
+github.com/digitalocean/godo v1.75.0/go.mod h1:GBmu8MkjZmNARE7IXRPmkbbnocNN8+uBm0xbEVw2LCs=
 github.com/distribution/distribution/v3 v3.0.0-20211118083504-a29a3c99a684 h1:DBZ2sN7CK6dgvHVpQsQj4sRMCbWTmd17l+5SUCjnQSY=
 github.com/distribution/distribution/v3 v3.0.0-20211118083504-a29a3c99a684/go.mod h1:UfCu3YXJJCI+IdnqGgYP82dk2+Joxmv+mUTVBES6wac=
 github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
@@ -1644,6 +1646,7 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b
 golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
 golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
 golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=

+ 55 - 1
internal/helm/agent.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"strconv"
 	"strings"
+	"time"
 
 	"github.com/pkg/errors"
 	"github.com/porter-dev/porter/internal/helm/loader"
@@ -12,6 +13,7 @@ import (
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/chart"
 	"helm.sh/helm/v3/pkg/release"
+	"helm.sh/helm/v3/pkg/storage/driver"
 	corev1 "k8s.io/api/core/v1"
 	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/helm/pkg/chartutil"
@@ -207,7 +209,59 @@ func (a *Agent) UpgradeReleaseByValues(
 	res, err := cmd.Run(conf.Name, ch, conf.Values)
 
 	if err != nil {
-		return nil, fmt.Errorf("Upgrade failed: %v", err)
+		// refer: https://github.com/helm/helm/blob/release-3.8/pkg/action/action.go#L62
+		// issue tracker: https://github.com/helm/helm/issues/4558
+		if err.Error() == "another operation (install/upgrade/rollback) is in progress" {
+			secretList, err := a.K8sAgent.Clientset.CoreV1().Secrets(rel.Namespace).List(
+				context.Background(),
+				v1.ListOptions{
+					LabelSelector: fmt.Sprintf("owner=helm,status in (pending-install, pending-upgrade, pending-rollback),name=%s", rel.Name),
+				},
+			)
+
+			if err != nil {
+				return nil, fmt.Errorf("Upgrade failed: %w", err)
+			}
+
+			if len(secretList.Items) > 0 {
+				mostRecentSecret := secretList.Items[0]
+
+				for i := 1; i < len(secretList.Items); i += 1 {
+					oldVersion, _ := strconv.Atoi(mostRecentSecret.Labels["version"])
+					newVersion, _ := strconv.Atoi(secretList.Items[i].Labels["version"])
+
+					if oldVersion < newVersion {
+						mostRecentSecret = secretList.Items[i]
+					}
+				}
+
+				if time.Since(mostRecentSecret.CreationTimestamp.Time) >= time.Minute {
+					helmSecrets := driver.NewSecrets(a.K8sAgent.Clientset.CoreV1().Secrets(rel.Namespace))
+
+					rel.Info.Status = release.StatusFailed
+
+					err = helmSecrets.Update(mostRecentSecret.GetName(), rel)
+
+					if err != nil {
+						return nil, fmt.Errorf("Upgrade failed: %w", err)
+					}
+
+					// retry upgrade
+					res, err = cmd.Run(conf.Name, ch, conf.Values)
+
+					if err != nil {
+						return nil, fmt.Errorf("Upgrade failed: %w", err)
+					}
+
+					return res, nil
+				} else {
+					// ask the user to wait for about a minute before retrying for the above fix to kick in
+					return nil, fmt.Errorf("another operation (install/upgrade/rollback) is in progress. If this error persists, please wait for 60 seconds to force an upgrade")
+				}
+			}
+		}
+
+		return nil, fmt.Errorf("Upgrade failed: %w", err)
 	}
 
 	return res, nil

+ 2 - 5
internal/kubernetes/envgroup/create.go

@@ -52,11 +52,8 @@ func ConvertV1ToV2EnvGroup(agent *kubernetes.Agent, name, namespace string) (*v1
 		return nil, err
 	}
 
-	// delete the old configmap and secret
-	if err := agent.DeleteLinkedSecret(name, namespace); err != nil {
-		return nil, err
-	}
-
+	// delete the old configmap
+	// note: we keep the old secret to ensure existing secret references are kept intact
 	if err := agent.DeleteConfigMap(name, namespace); err != nil {
 		return nil, err
 	}