2
0
Эх сурвалжийг харах

Merge pull request #503 from porter-dev/master

Sprint 1 merge to staging
abelanger5 5 жил өмнө
parent
commit
34c2582b39
54 өөрчлөгдсөн 3106 нэмэгдсэн , 144 устгасан
  1. 1 1
      README.md
  2. 79 0
      cli/cmd/api/k8s.go
  3. 188 0
      cli/cmd/run.go
  4. 24 0
      cli/cmd/utils/prompt.go
  5. 4 0
      dashboard/src/assets/key.svg
  6. 6 0
      dashboard/src/assets/sliders.svg
  7. 4 3
      dashboard/src/components/StatusIndicator.tsx
  8. 14 7
      dashboard/src/components/repo-selector/ActionConfEditor.tsx
  9. 7 0
      dashboard/src/components/repo-selector/ActionDetails.tsx
  10. 114 17
      dashboard/src/components/values-form/KeyValueArray.tsx
  11. 27 0
      dashboard/src/components/values-form/ValuesForm.tsx
  12. 3 3
      dashboard/src/components/values-form/ValuesWrapper.tsx
  13. 2 2
      dashboard/src/main/home/Home.tsx
  14. 47 30
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  15. 113 0
      dashboard/src/main/home/cluster-dashboard/DashboardHeader.tsx
  16. 0 1
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  17. 325 0
      dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx
  18. 259 0
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroup.tsx
  19. 185 0
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  20. 166 0
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx
  21. 463 0
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  22. 2 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  23. 14 4
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  24. 13 20
      dashboard/src/main/home/dashboard/ClusterPlaceholder.tsx
  25. 12 14
      dashboard/src/main/home/dashboard/Dashboard.tsx
  26. 1 0
      dashboard/src/main/home/integrations/IntegrationRow.tsx
  27. 4 2
      dashboard/src/main/home/launch/Launch.tsx
  28. 11 1
      dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx
  29. 236 0
      dashboard/src/main/home/modals/LoadEnvGroupModal.tsx
  30. 14 8
      dashboard/src/main/home/provisioner/AWSFormSection.tsx
  31. 1 2
      dashboard/src/main/home/provisioner/DOFormSection.tsx
  32. 2 2
      dashboard/src/main/home/provisioner/Provisioner.tsx
  33. 3 1
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  34. 1 1
      dashboard/src/main/home/sidebar/Drawer.tsx
  35. 10 14
      dashboard/src/main/home/sidebar/Sidebar.tsx
  36. 62 0
      dashboard/src/shared/api.tsx
  37. 2 0
      dashboard/src/shared/routing.tsx
  38. 1 0
      dashboard/src/shared/types.tsx
  39. 2 0
      go.mod
  40. 16 2
      go.sum
  41. 3 0
      internal/forms/git_action.go
  42. 6 0
      internal/forms/k8s.go
  43. 8 1
      internal/integrations/ci/actions/actions.go
  44. 2 1
      internal/integrations/ci/actions/steps.go
  45. 64 0
      internal/kubernetes/agent.go
  46. 2 2
      internal/kubernetes/config.go
  47. 3 0
      internal/models/gitrepo.go
  48. 16 3
      internal/registry/registry.go
  49. 6 1
      internal/templater/utils/values.go
  50. 1 0
      server/api/deploy_handler.go
  51. 3 0
      server/api/git_action_handler.go
  52. 301 0
      server/api/k8s_handler.go
  53. 155 1
      server/api/release_handler.go
  54. 98 0
      server/router/router.go

+ 1 - 1
README.md

@@ -3,7 +3,7 @@
 [![MIT License](https://img.shields.io/apm/l/atomic-design-ui.svg?)](https://github.com/tterb/atomic-design-ui/blob/master/LICENSEs) [![Go Report Card](https://goreportcard.com/badge/gojp/goreportcard)](https://goreportcard.com/report/github.com/porter-dev/porter) [![Discord](https://img.shields.io/discord/542888846271184896?color=7389D8&label=community&logo=discord&logoColor=ffffff)](https://discord.gg/34n7NN7FJ7)
 [![MIT License](https://img.shields.io/apm/l/atomic-design-ui.svg?)](https://github.com/tterb/atomic-design-ui/blob/master/LICENSEs) [![Go Report Card](https://goreportcard.com/badge/gojp/goreportcard)](https://goreportcard.com/report/github.com/porter-dev/porter) [![Discord](https://img.shields.io/discord/542888846271184896?color=7389D8&label=community&logo=discord&logoColor=ffffff)](https://discord.gg/34n7NN7FJ7)
 [![Twitter](https://img.shields.io/twitter/url/https/twitter.com/cloudposse.svg?style=social&label=Follow)](https://twitter.com/getporterdev)
 [![Twitter](https://img.shields.io/twitter/url/https/twitter.com/cloudposse.svg?style=social&label=Follow)](https://twitter.com/getporterdev)
 
 
-**Porter is a Kubernetes-powered PaaS that runs in your own cloud provider.** Porter brings the Heroku experience to Kubernetes without compromising its flexibility. Get started on Porter without the overhead of DevOps and fully customize your infra later when you need to.
+**Porter is a Kubernetes-powered PaaS that runs in your own cloud provider.** Porter brings the Heroku experience to your own AWS/GCP account, while upgrading your infrastructure to Kubernetes. Get started on Porter without the overhead of DevOps and customize your infrastructure later when you need to.
 
 
 ![Provisioning View](https://user-images.githubusercontent.com/22849518/104234811-fe2dcb00-5421-11eb-9ce3-c0ebefc37476.png)
 ![Provisioning View](https://user-images.githubusercontent.com/22849518/104234811-fe2dcb00-5421-11eb-9ce3-c0ebefc37476.png)
 
 

+ 79 - 0
cli/cmd/api/k8s.go

@@ -46,3 +46,82 @@ func (c *Client) GetK8sNamespaces(
 
 
 	return bodyResp, nil
 	return bodyResp, nil
 }
 }
+
+// GetKubeconfigResponse is the list of namespaces returned when a
+// user has successfully authenticated
+type GetKubeconfigResponse struct {
+	Kubeconfig []byte `json:"kubeconfig"`
+}
+
+// GetK8sNamespaces gets a namespaces list in a k8s cluster
+func (c *Client) GetKubeconfig(
+	ctx context.Context,
+	projectID uint,
+	clusterID uint,
+) (*GetKubeconfigResponse, error) {
+	cl := fmt.Sprintf("%d", clusterID)
+
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/k8s/kubeconfig?"+url.Values{
+			"cluster_id": []string{cl},
+		}.Encode(), c.BaseURL, projectID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &GetKubeconfigResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// GetReleaseAllPodsResponse is the list of all pods for a given Helm release
+type GetReleaseAllPodsResponse []v1.Pod
+
+// GetK8sAllPods gets all pods for a given release
+func (c *Client) GetK8sAllPods(
+	ctx context.Context,
+	projectID, clusterID uint,
+	namespace, name string,
+) (GetReleaseAllPodsResponse, error) {
+	cl := fmt.Sprintf("%d", clusterID)
+
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/releases/%s/0/pods/all?"+url.Values{
+			"cluster_id": []string{cl},
+			"namespace":  []string{namespace},
+			"storage":    []string{"secret"},
+		}.Encode(), c.BaseURL, projectID, name),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &GetReleaseAllPodsResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return *bodyResp, nil
+}

+ 188 - 0
cli/cmd/run.go

@@ -0,0 +1,188 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+	"github.com/spf13/cobra"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/client-go/rest"
+	"k8s.io/client-go/tools/clientcmd"
+	"k8s.io/client-go/tools/remotecommand"
+	"k8s.io/kubectl/pkg/util/term"
+)
+
+var namespace string
+
+// runCmd represents the "porter run" base command when called
+// without any subcommands
+var runCmd = &cobra.Command{
+	Use:   "run [release] -- COMMAND [args...]",
+	Args:  cobra.MinimumNArgs(2),
+	Short: "Runs a command inside a connected cluster container.",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, run)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(runCmd)
+
+	runCmd.PersistentFlags().StringVar(
+		&host,
+		"host",
+		getHost(),
+		"host url of Porter instance",
+	)
+
+	runCmd.PersistentFlags().StringVar(
+		&namespace,
+		"namespace",
+		"default",
+		"namespace of release to connect to",
+	)
+}
+
+func run(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
+	color.New(color.FgGreen).Println("Running", strings.Join(args[1:], " "), "for release", args[0])
+
+	podNames, err := getPods(client, namespace, args[0])
+
+	if err != nil {
+		return fmt.Errorf("Could not retrieve list of pods: %s", err.Error())
+	}
+
+	// if length of pods is 0, throw error
+	pod := ""
+
+	if len(podNames) == 0 {
+		return fmt.Errorf("At least one pod must exist in this deployment.")
+	} else if len(podNames) == 1 {
+		pod = podNames[0]
+	} else {
+		pod, err = utils.PromptSelect("Select the pod:", podNames)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	restConf, err := getRESTConfig(client)
+
+	if err != nil {
+		return fmt.Errorf("Could not retrieve kube credentials: %s", err.Error())
+	}
+
+	return executeRun(restConf, namespace, pod, args[1:])
+}
+
+func getRESTConfig(client *api.Client) (*rest.Config, error) {
+	pID := getProjectID()
+	cID := getClusterID()
+
+	kubeResp, err := client.GetKubeconfig(context.TODO(), pID, cID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	kubeBytes := kubeResp.Kubeconfig
+
+	cmdConf, err := clientcmd.NewClientConfigFromBytes(kubeBytes)
+
+	if err != nil {
+		return nil, err
+	}
+
+	restConf, err := cmdConf.ClientConfig()
+
+	if err != nil {
+		return nil, err
+	}
+
+	restConf.GroupVersion = &schema.GroupVersion{
+		Group:   "api",
+		Version: "v1",
+	}
+
+	restConf.NegotiatedSerializer = runtime.NewSimpleNegotiatedSerializer(runtime.SerializerInfo{})
+
+	return restConf, nil
+}
+
+func getPods(client *api.Client, namespace, releaseName string) ([]string, error) {
+	pID := getProjectID()
+	cID := getClusterID()
+
+	resp, err := client.GetK8sAllPods(context.TODO(), pID, cID, namespace, releaseName)
+
+	if err != nil {
+		return nil, err
+	}
+
+	res := make([]string, 0)
+
+	for _, pod := range resp {
+		res = append(res, pod.ObjectMeta.Name)
+	}
+
+	return res, nil
+}
+
+func executeRun(config *rest.Config, namespace, name string, args []string) error {
+	restClient, err := rest.RESTClientFor(config)
+
+	if err != nil {
+		return err
+	}
+
+	req := restClient.Post().
+		Resource("pods").
+		Name(name).
+		Namespace(namespace).
+		SubResource("exec")
+
+	// req.Param("container", "web")
+	for _, arg := range args {
+		req.Param("command", arg)
+	}
+	req.Param("stdin", "true")
+	req.Param("stdout", "true")
+	req.Param("tty", "true")
+
+	t := term.TTY{
+		In:  os.Stdin,
+		Out: os.Stdout,
+	}
+
+	fn := func() error {
+		exec, err := remotecommand.NewSPDYExecutor(config, "POST", req.URL())
+
+		if err != nil {
+			return err
+		}
+
+		return exec.Stream(remotecommand.StreamOptions{
+			Stdin:  os.Stdin,
+			Stdout: os.Stdout,
+			Stderr: os.Stderr,
+			Tty:    true,
+		})
+	}
+
+	if err := t.Safe(fn); err != nil {
+		return err
+	}
+
+	return err
+}

+ 24 - 0
cli/cmd/utils/prompt.go

@@ -7,6 +7,7 @@ import (
 	"os"
 	"os"
 	"strings"
 	"strings"
 
 
+	"github.com/AlecAivazis/survey/v2"
 	"golang.org/x/crypto/ssh/terminal"
 	"golang.org/x/crypto/ssh/terminal"
 )
 )
 
 
@@ -55,3 +56,26 @@ func PromptPasswordWithConfirmation() (string, error) {
 
 
 	return pw, nil
 	return pw, nil
 }
 }
+
+type selectAnswer struct {
+	Response string `survey:"response"`
+}
+
+func PromptSelect(prompt string, options []string) (string, error) {
+	var qs = []*survey.Question{
+		{
+			Name: "response",
+			Prompt: &survey.Select{
+				Message: prompt,
+				Options: options,
+				Default: options[0],
+			},
+		},
+	}
+
+	ans := &selectAnswer{}
+
+	err := survey.Ask(qs, ans)
+
+	return ans.Response, err
+}

+ 4 - 0
dashboard/src/assets/key.svg

@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path opacity="0.4" d="M16.3344 1.9998H7.6654C4.2764 1.9998 2.0004 4.3778 2.0004 7.9168V16.0838C2.0004 19.6218 4.2764 21.9998 7.6654 21.9998H16.3334C19.7224 21.9998 22.0004 19.6218 22.0004 16.0838V7.9168C22.0004 4.3778 19.7234 1.9998 16.3344 1.9998Z" fill="white"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M11.3142 11.2485H17.0142C17.4242 11.2485 17.7642 11.5885 17.7642 11.9985V13.8485C17.7642 14.2685 17.4242 14.5985 17.0142 14.5985C16.5942 14.5985 16.2642 14.2685 16.2642 13.8485V12.7485H14.9342V13.8485C14.9342 14.2685 14.5942 14.5985 14.1842 14.5985C13.7642 14.5985 13.4342 14.2685 13.4342 13.8485V12.7485H11.3142C10.9942 13.8185 10.0142 14.5985 8.84419 14.5985C7.40419 14.5985 6.23419 13.4385 6.23419 11.9985C6.23419 10.5685 7.40419 9.3985 8.84419 9.3985C10.0142 9.3985 10.9942 10.1785 11.3142 11.2485ZM7.73419 11.9985C7.73419 12.6085 8.23419 13.0985 8.84419 13.0985C9.44419 13.0985 9.94419 12.6085 9.94419 11.9985C9.94419 11.3885 9.44419 10.8985 8.84419 10.8985C8.23419 10.8985 7.73419 11.3885 7.73419 11.9985Z" fill="white"/>
+</svg>

+ 6 - 0
dashboard/src/assets/sliders.svg

@@ -0,0 +1,6 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path opacity="0.4" d="M10.0833 15.958H3.50777C2.67555 15.958 2 16.6217 2 17.4393C2 18.2558 2.67555 18.9206 3.50777 18.9206H10.0833C10.9155 18.9206 11.5911 18.2558 11.5911 17.4393C11.5911 16.6217 10.9155 15.958 10.0833 15.958Z" fill="white"/>
+<path opacity="0.4" d="M22 6.37856C22 5.56203 21.3244 4.89832 20.4933 4.89832H13.9178C13.0856 4.89832 12.4101 5.56203 12.4101 6.37856C12.4101 7.19618 13.0856 7.85989 13.9178 7.85989H20.4933C21.3244 7.85989 22 7.19618 22 6.37856Z" fill="white"/>
+<path d="M8.87774 6.37856C8.87774 8.24523 7.33886 9.75821 5.43887 9.75821C3.53999 9.75821 2 8.24523 2 6.37856C2 4.51298 3.53999 3 5.43887 3C7.33886 3 8.87774 4.51298 8.87774 6.37856Z" fill="white"/>
+<path d="M22 17.3992C22 19.2648 20.4611 20.7778 18.5611 20.7778C16.6623 20.7778 15.1223 19.2648 15.1223 17.3992C15.1223 15.5325 16.6623 14.0196 18.5611 14.0196C20.4611 14.0196 22 15.5325 22 17.3992Z" fill="white"/>
+</svg>

+ 4 - 3
dashboard/src/components/StatusIndicator.tsx

@@ -87,11 +87,11 @@ const Spinner = styled.img`
   width: 15px;
   width: 15px;
   height: 15px;
   height: 15px;
   margin-right: 15px;
   margin-right: 15px;
-  margin-bottom: -1px;
+  margin-bottom: -3px;
 `;
 `;
 
 
 const StatusColor = styled.div`
 const StatusColor = styled.div`
-  margin-bottom: 1px;
+  margin-top: 1px;
   width: 8px;
   width: 8px;
   height: 8px;
   height: 8px;
   background: ${(props: { status: string }) =>
   background: ${(props: { status: string }) =>
@@ -103,6 +103,7 @@ const StatusColor = styled.div`
       ? "#00d12a"
       ? "#00d12a"
       : "#f5cb42"};
       : "#f5cb42"};
   border-radius: 20px;
   border-radius: 20px;
+  margin-left: 3px;
   margin-right: 16px;
   margin-right: 16px;
 `;
 `;
 
 
@@ -113,7 +114,7 @@ const Status = styled.div`
   flex-direction: row;
   flex-direction: row;
   text-transform: capitalize;
   text-transform: capitalize;
   align-items: center;
   align-items: center;
-  font-family: "Hind Siliguri", sans-serif;
+  font-family: "Work Sans", sans-serif;
   color: #aaaabb;
   color: #aaaabb;
   animation: fadeIn 0.5s;
   animation: fadeIn 0.5s;
   margin-left: ${(props: { margin_left: string }) => props.margin_left};
   margin-left: ${(props: { margin_left: string }) => props.margin_left};

+ 14 - 7
dashboard/src/components/repo-selector/ActionConfEditor.tsx

@@ -31,6 +31,7 @@ type StateType = {
 const defaultActionConfig: ActionConfigType = {
 const defaultActionConfig: ActionConfigType = {
   git_repo: "",
   git_repo: "",
   image_repo_uri: "",
   image_repo_uri: "",
+  branch: "",
   git_repo_id: 0,
   git_repo_id: 0,
 };
 };
 
 
@@ -53,8 +54,7 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
           />
           />
         </ExpandedWrapper>
         </ExpandedWrapper>
       );
       );
-    } else if (!this.props.dockerfilePath && !this.props.folderPath) {
-      /* else if (!branch) {
+    } else if (!branch) {
       return (
       return (
         <>
         <>
           <ExpandedWrapperAlt>
           <ExpandedWrapperAlt>
@@ -64,13 +64,18 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
             />
             />
           </ExpandedWrapperAlt>
           </ExpandedWrapperAlt>
           <Br />
           <Br />
-          <BackButton width="135px" onClick={() => setActionConfig({ ...defaultActionConfig })}>
+          <BackButton
+            width="135px"
+            onClick={() => {
+              setActionConfig({ ...defaultActionConfig });
+            }}
+          >
             <i className="material-icons">keyboard_backspace</i>
             <i className="material-icons">keyboard_backspace</i>
             Select Repo
             Select Repo
           </BackButton>
           </BackButton>
         </>
         </>
       );
       );
-    } */
+    } else if (!this.props.dockerfilePath && !this.props.folderPath) {
       return (
       return (
         <>
         <>
           <ExpandedWrapperAlt>
           <ExpandedWrapperAlt>
@@ -84,11 +89,13 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
           </ExpandedWrapperAlt>
           </ExpandedWrapperAlt>
           <Br />
           <Br />
           <BackButton
           <BackButton
-            width="135px"
-            onClick={() => setActionConfig({ ...defaultActionConfig })}
+            width="145px"
+            onClick={() => {
+              setBranch("");
+            }}
           >
           >
             <i className="material-icons">keyboard_backspace</i>
             <i className="material-icons">keyboard_backspace</i>
-            Select Repo
+            Select Branch
           </BackButton>
           </BackButton>
         </>
         </>
       );
       );

+ 7 - 0
dashboard/src/components/repo-selector/ActionDetails.tsx

@@ -122,6 +122,13 @@ export default class ActionDetails extends Component<PropsType, StateType> {
           width="100%"
           width="100%"
           value={this.props.actionConfig.git_repo}
           value={this.props.actionConfig.git_repo}
         />
         />
+        <InputRow
+          disabled={true}
+          label="Branch"
+          type="text"
+          width="100%"
+          value={this.props.branch}
+        />
         {this.props.dockerfilePath ? (
         {this.props.dockerfilePath ? (
           <InputRow
           <InputRow
             disabled={true}
             disabled={true}

+ 114 - 17
dashboard/src/components/values-form/KeyValueArray.tsx

@@ -1,5 +1,9 @@
 import React, { Component } from "react";
 import React, { Component } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
+import Modal from "../../main/home/modals/Modal";
+import LoadEnvGroupModal from "../../main/home/modals/LoadEnvGroupModal";
+
+import sliders from "assets/sliders.svg";
 
 
 type PropsType = {
 type PropsType = {
   label?: string;
   label?: string;
@@ -7,15 +11,20 @@ type PropsType = {
   setValues: (x: any) => void;
   setValues: (x: any) => void;
   width?: string;
   width?: string;
   disabled?: boolean;
   disabled?: boolean;
+  namespace?: string;
+  clusterId?: number;
+  envLoader?: boolean;
 };
 };
 
 
 type StateType = {
 type StateType = {
   values: any[];
   values: any[];
+  showEnvModal: boolean;
 };
 };
 
 
 export default class KeyValueArray extends Component<PropsType, StateType> {
 export default class KeyValueArray extends Component<PropsType, StateType> {
   state = {
   state = {
     values: [] as any[],
     values: [] as any[],
+    showEnvModal: false,
   };
   };
 
 
   componentDidMount() {
   componentDidMount() {
@@ -34,6 +43,17 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
     return obj;
     return obj;
   };
   };
 
 
+  objectToValues = (obj: any) => {
+    let values = [] as any[];
+    Object.keys(obj).forEach((key: string, i: number) => {
+      let entry = {} as any;
+      entry.key = key;
+      entry.value = obj[key];
+      values.push(entry);
+    });
+    return values;
+  };
+
   renderDeleteButton = (i: number) => {
   renderDeleteButton = (i: number) => {
     if (!this.props.disabled) {
     if (!this.props.disabled) {
       return (
       return (
@@ -93,28 +113,86 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
     );
     );
   };
   };
 
 
+  renderEnvModal = () => {
+    if (this.state.showEnvModal) {
+      return (
+        <Modal
+          onRequestClose={() => this.setState({ showEnvModal: false })}
+          width="665px"
+          height="342px"
+        >
+          <LoadEnvGroupModal
+            namespace={this.props.namespace}
+            clusterId={this.props.clusterId}
+            closeModal={() => this.setState({ showEnvModal: false })}
+            setValues={(values: any) => {
+              this.props.setValues(values);
+              this.setState({ values: this.objectToValues(values) });
+            }}
+          />
+        </Modal>
+      );
+    }
+  };
+
   render() {
   render() {
     return (
     return (
-      <StyledInputArray>
-        <Label>{this.props.label}</Label>
-        {this.state.values.length === 0 ? <></> : this.renderInputList()}
-        {this.props.disabled ? (
-          <></>
-        ) : (
-          <AddRowButton
-            onClick={() => {
-              this.state.values.push({ key: "", value: "" });
-              this.setState({ values: this.state.values });
-            }}
-          >
-            <i className="material-icons">add</i> Add Row
-          </AddRowButton>
-        )}
-      </StyledInputArray>
+      <>
+        <StyledInputArray>
+          <Label>{this.props.label}</Label>
+          {this.state.values.length === 0 ? <></> : this.renderInputList()}
+          {this.props.disabled ? (
+            <></>
+          ) : (
+            <InputWrapper>
+              <AddRowButton
+                onClick={() => {
+                  this.state.values.push({ key: "", value: "" });
+                  this.setState({ values: this.state.values });
+                }}
+              >
+                <i className="material-icons">add</i> Add Row
+              </AddRowButton>
+              <Spacer />
+              {this.props.namespace && this.props.envLoader && (
+                <LoadButton
+                  onClick={() =>
+                    this.setState({ showEnvModal: !this.state.showEnvModal })
+                  }
+                >
+                  <img src={sliders} /> Load from Env Group
+                </LoadButton>
+              )}
+            </InputWrapper>
+          )}
+        </StyledInputArray>
+        {this.renderEnvModal()}
+      </>
     );
     );
   }
   }
 }
 }
 
 
+const CloseOverlay = styled.div`
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100vw;
+  height: 100vh;
+  z-index: 999;
+  background: #202227;
+  animation: fadeIn 0.2s 0s;
+  opacity: 0;
+  animation-fill-mode: forwards;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
 const Spacer = styled.div`
 const Spacer = styled.div`
   width: 10px;
   width: 10px;
   height: 20px;
   height: 20px;
@@ -123,7 +201,6 @@ const Spacer = styled.div`
 const AddRowButton = styled.div`
 const AddRowButton = styled.div`
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
-  margin-top: 5px;
   width: 270px;
   width: 270px;
   font-size: 13px;
   font-size: 13px;
   color: #aaaabb;
   color: #aaaabb;
@@ -146,6 +223,25 @@ const AddRowButton = styled.div`
   }
   }
 `;
 `;
 
 
+const LoadButton = styled(AddRowButton)`
+  background: none;
+  border: 1px solid #ffffff55;
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  > img {
+    width: 14px;
+    margin-left: 10px;
+    margin-right: 12px;
+  }
+`;
+
 const DeleteButton = styled.div`
 const DeleteButton = styled.div`
   width: 15px;
   width: 15px;
   height: 15px;
   height: 15px;
@@ -171,6 +267,7 @@ const DeleteButton = styled.div`
 const InputWrapper = styled.div`
 const InputWrapper = styled.div`
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
+  margin-top: 5px;
 `;
 `;
 
 
 const Input = styled.input`
 const Input = styled.input`

+ 27 - 0
dashboard/src/components/values-form/ValuesForm.tsx

@@ -21,6 +21,8 @@ type PropsType = {
   setMetaState?: any;
   setMetaState?: any;
   handleEnvChange?: (x: any) => void;
   handleEnvChange?: (x: any) => void;
   disabled?: boolean;
   disabled?: boolean;
+  namespace?: string;
+  clusterId?: number;
 };
 };
 
 
 type StateType = any;
 type StateType = any;
@@ -74,10 +76,35 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               label={item.label}
               label={item.label}
             />
             />
           );
           );
+        case "env-key-value-array":
+          return (
+            <KeyValueArray
+              key={i}
+              envLoader={true}
+              namespace={this.props.namespace}
+              clusterId={this.props.clusterId}
+              values={this.props.metaState[key]}
+              setValues={(x: any) => {
+                this.props.setMetaState({ [key]: x });
+
+                // Need to pull env vars out of form.yaml for createGHA build env vars
+                if (
+                  this.props.handleEnvChange &&
+                  key === "container.env.normal"
+                ) {
+                  this.props.handleEnvChange(x);
+                }
+              }}
+              label={item.label}
+              disabled={this.props.disabled}
+            />
+          );
         case "key-value-array":
         case "key-value-array":
           return (
           return (
             <KeyValueArray
             <KeyValueArray
               key={i}
               key={i}
+              namespace={this.props.namespace}
+              clusterId={this.props.clusterId}
               values={this.props.metaState[key]}
               values={this.props.metaState[key]}
               setValues={(x: any) => {
               setValues={(x: any) => {
                 this.props.setMetaState({ [key]: x });
                 this.props.setMetaState({ [key]: x });

+ 3 - 3
dashboard/src/components/values-form/ValuesWrapper.tsx

@@ -38,15 +38,15 @@ export default class ValuesWrapper extends Component<PropsType, StateType> {
           section.contents.forEach((item: FormElement, i: number) => {
           section.contents.forEach((item: FormElement, i: number) => {
             // If no name is assigned use values.yaml variable as identifier
             // If no name is assigned use values.yaml variable as identifier
             let key = item.name || item.variable;
             let key = item.name || item.variable;
-            
+
             let def =
             let def =
               item.settings && item.settings.unit
               item.settings && item.settings.unit
                 ? `${item.settings.default}${item.settings.unit}`
                 ? `${item.settings.default}${item.settings.unit}`
                 : item.settings.default;
                 : item.settings.default;
             def = (item.value && item.value[0]) || def;
             def = (item.value && item.value[0]) || def;
 
 
-            if (item.type === 'checkbox') {
-              def = item.value[0]
+            if (item.type === "checkbox") {
+              def = item.value[0];
             }
             }
 
 
             // Handle add to list of required fields
             // Handle add to list of required fields

+ 2 - 2
dashboard/src/main/home/Home.tsx

@@ -294,7 +294,8 @@ class Home extends Component<PropsType, StateType> {
       if (
       if (
         currentView === "cluster-dashboard" ||
         currentView === "cluster-dashboard" ||
         currentView === "applications" ||
         currentView === "applications" ||
-        currentView === "jobs"
+        currentView === "jobs" ||
+        currentView === "env-groups"
       ) {
       ) {
         return this.renderDashboard();
         return this.renderDashboard();
       } else if (currentView === "dashboard") {
       } else if (currentView === "dashboard") {
@@ -313,7 +314,6 @@ class Home extends Component<PropsType, StateType> {
       } else if (currentView === "project-settings") {
       } else if (currentView === "project-settings") {
         return <ProjectSettings />;
         return <ProjectSettings />;
       }
       }
-
       return <Templates />;
       return <Templates />;
     } else if (currentView === "new-project") {
     } else if (currentView === "new-project") {
       return <NewProject />;
       return <NewProject />;

+ 47 - 30
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -3,12 +3,14 @@ import styled from "styled-components";
 import gradient from "assets/gradient.jpg";
 import gradient from "assets/gradient.jpg";
 import monojob from "assets/monojob.png";
 import monojob from "assets/monojob.png";
 import monoweb from "assets/monoweb.png";
 import monoweb from "assets/monoweb.png";
+import sliders from "assets/sliders.svg";
 
 
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 import { ChartType, ClusterType } from "shared/types";
 import { ChartType, ClusterType } from "shared/types";
 import { PorterUrl } from "shared/routing";
 import { PorterUrl } from "shared/routing";
 
 
 import ChartList from "./chart/ChartList";
 import ChartList from "./chart/ChartList";
+import EnvGroupDashboard from "./env-groups/EnvGroupDashboard";
 import NamespaceSelector from "./NamespaceSelector";
 import NamespaceSelector from "./NamespaceSelector";
 import SortSelector from "./SortSelector";
 import SortSelector from "./SortSelector";
 import ExpandedChart from "./expanded-chart/ExpandedChart";
 import ExpandedChart from "./expanded-chart/ExpandedChart";
@@ -88,6 +90,47 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     }
     }
   };
   };
 
 
+  getDescription = (currentView: string): string => {
+    if (currentView === "jobs") {
+      return "Scripts and tasks that run once or on a repeating interval.";
+    } else {
+      return "Continuously running web services, workers, and add-ons.";
+    }
+  };
+
+  renderBody = () => {
+    let { currentCluster, setSidebar, currentView } = this.props;
+    return (
+      <>
+        <ControlRow>
+          <Button onClick={() => this.props.history.push("launch")}>
+            <i className="material-icons">add</i> Launch Template
+          </Button>
+          <SortFilterWrapper>
+            <SortSelector
+              setSortType={(sortType) => this.setState({ sortType })}
+              sortType={this.state.sortType}
+            />
+            <NamespaceSelector
+              setNamespace={(namespace) => this.setState({ namespace })}
+              namespace={this.state.namespace}
+            />
+          </SortFilterWrapper>
+        </ControlRow>
+
+        <ChartList
+          currentView={currentView}
+          currentCluster={currentCluster}
+          namespace={this.state.namespace}
+          sortType={this.state.sortType}
+          setCurrentChart={(x: ChartType | null) =>
+            this.setState({ currentChart: x })
+          }
+        />
+      </>
+    );
+  };
+
   renderContents = () => {
   renderContents = () => {
     let { currentCluster, setSidebar, currentView } = this.props;
     let { currentCluster, setSidebar, currentView } = this.props;
     if (this.state.currentChart && currentView === "jobs") {
     if (this.state.currentChart && currentView === "jobs") {
@@ -111,6 +154,8 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           setSidebar={setSidebar}
           setSidebar={setSidebar}
         />
         />
       );
       );
+    } else if (currentView === "env-groups") {
+      return <EnvGroupDashboard currentCluster={this.props.currentCluster} />;
     }
     }
 
 
     return (
     return (
@@ -126,40 +171,12 @@ class ClusterDashboard extends Component<PropsType, StateType> {
               <i className="material-icons">info</i> Info
               <i className="material-icons">info</i> Info
             </InfoLabel>
             </InfoLabel>
           </TopRow>
           </TopRow>
-          <Description>
-            {currentView === "jobs"
-              ? `An overview of past and current jobs for ${currentCluster.name}.`
-              : `An overview of web services and workers running on ${currentCluster.name}.`}
-          </Description>
+          <Description>{this.getDescription(currentView)}</Description>
         </InfoSection>
         </InfoSection>
 
 
         <LineBreak />
         <LineBreak />
 
 
-        <ControlRow>
-          <Button onClick={() => this.props.history.push("launch")}>
-            <i className="material-icons">add</i> Launch Template
-          </Button>
-          <SortFilterWrapper>
-            <SortSelector
-              setSortType={(sortType) => this.setState({ sortType })}
-              sortType={this.state.sortType}
-            />
-            <NamespaceSelector
-              setNamespace={(namespace) => this.setState({ namespace })}
-              namespace={this.state.namespace}
-            />
-          </SortFilterWrapper>
-        </ControlRow>
-
-        <ChartList
-          currentView={currentView}
-          currentCluster={currentCluster}
-          namespace={this.state.namespace}
-          sortType={this.state.sortType}
-          setCurrentChart={(x: ChartType | null) =>
-            this.setState({ currentChart: x })
-          }
-        />
+        {this.renderBody()}
       </div>
       </div>
     );
     );
   };
   };

+ 113 - 0
dashboard/src/main/home/cluster-dashboard/DashboardHeader.tsx

@@ -0,0 +1,113 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+type PropsType = {
+  image: any;
+  title: string;
+  description: string;
+};
+
+type StateType = {};
+
+export default class DashboardHeader extends Component<PropsType, StateType> {
+  render() {
+    return (
+      <>
+        <TitleSection>
+          <Img src={this.props.image} />
+          <Title>{this.props.title}</Title>
+        </TitleSection>
+
+        <InfoSection>
+          <TopRow>
+            <InfoLabel>
+              <i className="material-icons">info</i> Info
+            </InfoLabel>
+          </TopRow>
+          <Description>{this.props.description}</Description>
+        </InfoSection>
+
+        <LineBreak />
+      </>
+    );
+  }
+}
+
+const Img = styled.img`
+  width: 30px;
+`;
+
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 10px 0px 35px;
+`;
+
+const TopRow = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Description = styled.div`
+  color: #aaaabb;
+  margin-top: 13px;
+  margin-left: 2px;
+  font-size: 13px;
+`;
+
+const InfoLabel = styled.div`
+  width: 72px;
+  height: 20px;
+  display: flex;
+  align-items: center;
+  color: #7a838f;
+  font-size: 13px;
+  > i {
+    color: #8b949f;
+    font-size: 18px;
+    margin-right: 5px;
+  }
+`;
+
+const InfoSection = styled.div`
+  margin-top: 20px;
+  font-family: "Work Sans", sans-serif;
+  margin-left: 0px;
+  margin-bottom: 35px;
+`;
+
+const Title = styled.div`
+  font-size: 20px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  margin-left: 18px;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  text-transform: capitalize;
+`;
+
+const TitleSection = styled.div`
+  height: 80px;
+  margin-top: 10px;
+  margin-bottom: 10px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  padding-left: 0px;
+
+  > i {
+    margin-left: 10px;
+    cursor: pointer;
+    font-size 18px;
+    color: #858FAAaa;
+    padding: 5px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+    margin-bottom: -3px;
+  }
+`;

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx

@@ -116,7 +116,6 @@ const InfoWrapper = styled.div`
 const LastDeployed = styled.div`
 const LastDeployed = styled.div`
   font-size: 13px;
   font-size: 13px;
   margin-left: 10px;
   margin-left: 10px;
-  margin-top: -1px;
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   color: #aaaabb66;
   color: #aaaabb66;

+ 325 - 0
dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx

@@ -0,0 +1,325 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import sliders from "assets/sliders.svg";
+import api from "shared/api";
+
+import { Context } from "shared/Context";
+import { ClusterType } from "shared/types";
+
+import InputRow from "components/values-form/InputRow";
+import KeyValueArray from "components/values-form/KeyValueArray";
+import Selector from "components/Selector";
+import Helper from "components/values-form/Helper";
+import SaveButton from "components/SaveButton";
+import { isAlphanumeric } from "shared/common";
+
+type PropsType = {
+  goBack: () => void;
+  currentCluster: ClusterType;
+};
+
+type StateType = {
+  expand: boolean;
+  update: any[];
+  envGroupName: string;
+  selectedNamespace: string;
+  namespaceOptions: any[];
+  envVariables: any;
+  submitStatus: string;
+};
+
+export default class CreateEnvGroup extends Component<PropsType, StateType> {
+  state = {
+    expand: false,
+    update: [] as any[],
+    envGroupName: "",
+    selectedNamespace: "default",
+    namespaceOptions: [] as any[],
+    envVariables: {} as any,
+    submitStatus: "",
+  };
+
+  componentDidMount() {
+    this.updateNamespaces();
+  }
+
+  isDisabled = () => {
+    return (
+      !isAlphanumeric(this.state.envGroupName) || this.state.envGroupName === ""
+    );
+  };
+
+  onSubmit = () => {
+    this.setState({ submitStatus: "loading" });
+    api
+      .createConfigMap(
+        "<token>",
+        {
+          name: this.state.envGroupName,
+          namespace: this.state.selectedNamespace,
+          variables: this.state.envVariables,
+        },
+        {
+          id: this.context.currentProject.id,
+          cluster_id: this.props.currentCluster.id,
+        }
+      )
+      .then((res) => {
+        this.setState({ submitStatus: "successful" });
+        this.props.goBack();
+      })
+      .catch((err) => {
+        this.setState({ submitStatus: "Could not create" });
+      });
+  };
+
+  updateNamespaces = () => {
+    let { currentProject } = this.context;
+    api
+      .getNamespaces(
+        "<token>",
+        {
+          cluster_id: this.props.currentCluster.id,
+        },
+        { id: currentProject.id }
+      )
+      .then((res) => {
+        if (res.data) {
+          let namespaceOptions = res.data.items.map(
+            (x: { metadata: { name: string } }) => {
+              return { label: x.metadata.name, value: x.metadata.name };
+            }
+          );
+          if (res.data.items.length > 0) {
+            this.setState({ namespaceOptions });
+          }
+        }
+      })
+      .catch(console.log);
+  };
+
+  render() {
+    return (
+      <>
+        <StyledCreateEnvGroup>
+          <HeaderSection>
+            <Button onClick={this.props.goBack}>
+              <i className="material-icons">keyboard_backspace</i>
+              Back
+            </Button>
+            <Title>Create an Environment Group</Title>
+          </HeaderSection>
+          <DarkMatter antiHeight="-13px" />
+          <Heading isAtTop={true}>Name</Heading>
+          <Subtitle>
+            <Warning
+              makeFlush={true}
+              highlight={
+                !isAlphanumeric(this.state.envGroupName) &&
+                this.state.envGroupName !== ""
+              }
+            >
+              Lowercase letters, numbers, and "-" only.
+            </Warning>
+          </Subtitle>
+          <DarkMatter antiHeight="-29px" />
+          <InputRow
+            type="text"
+            value={this.state.envGroupName}
+            setValue={(x: string) => this.setState({ envGroupName: x })}
+            placeholder="ex: doctor-scientist"
+            width="100%"
+          />
+
+          <Heading>Destination</Heading>
+          <Subtitle>
+            Specify the namespace you would like to create this environment
+            group in.
+          </Subtitle>
+          <DestinationSection>
+            <NamespaceLabel>
+              <i className="material-icons">view_list</i>Namespace
+            </NamespaceLabel>
+            <Selector
+              key={"namespace"}
+              activeValue={this.state.selectedNamespace}
+              setActiveValue={(namespace: string) =>
+                this.setState({ selectedNamespace: namespace })
+              }
+              options={this.state.namespaceOptions}
+              width="250px"
+              dropdownWidth="335px"
+              closeOverlay={true}
+            />
+          </DestinationSection>
+
+          <Heading>Environment Variables</Heading>
+          <Helper>
+            Set environment variables for your secrets and environment-specific
+            configuration.
+          </Helper>
+          <KeyValueArray
+            namespace={this.state.selectedNamespace}
+            values={this.state.envVariables}
+            setValues={(x: any) => this.setState({ envVariables: x })}
+          />
+          <SaveButton
+            disabled={this.isDisabled()}
+            text="Create Env Group"
+            onClick={this.onSubmit}
+            status={
+              this.isDisabled()
+                ? "Missing required fields"
+                : this.state.submitStatus
+            }
+            makeFlush={true}
+          />
+        </StyledCreateEnvGroup>
+        <Buffer />
+      </>
+    );
+  }
+}
+
+CreateEnvGroup.contextType = Context;
+
+const Buffer = styled.div`
+  width: 100%;
+  height: 150px;
+`;
+
+const StyledCreateEnvGroup = styled.div`
+  padding-bottom: 70px;
+  position: relative;
+`;
+
+const NamespaceLabel = styled.div`
+  margin-right: 10px;
+  display: flex;
+  align-items: center;
+  > i {
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const DestinationSection = styled.div`
+  display: flex;
+  align-items: center;
+  color: #ffffff;
+  font-family: "Work Sans", sans-serif;
+  font-size: 14px;
+  margin-top: 2px;
+  font-weight: 500;
+  margin-bottom: 32px;
+
+  > i {
+    font-size: 25px;
+    color: #ffffff44;
+    margin-right: 13px;
+  }
+`;
+
+const Button = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 20px;
+  color: white;
+  height: 35px;
+  margin-left: -2px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: pointer;
+  border: 2px solid #969fbbaa;
+  :hover {
+    background: #ffffff11;
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    color: #969fbbaa;
+    font-weight: 600;
+    font-size: 14px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const DarkMatter = styled.div<{ antiHeight?: string }>`
+  width: 100%;
+  margin-top: ${(props) => props.antiHeight || "-15px"};
+`;
+
+const Warning = styled.span<{ highlight: boolean; makeFlush?: boolean }>`
+  color: ${(props) => (props.highlight ? "#f5cb42" : "")};
+  margin-left: ${(props) => (props.makeFlush ? "" : "5px")};
+`;
+
+const Subtitle = styled.div`
+  padding: 11px 0px 16px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  line-height: 1.6em;
+  display: flex;
+  align-items: center;
+`;
+
+const Title = styled.div`
+  font-size: 24px;
+  font-weight: 600;
+  font-family: "Work Sans", sans-serif;
+  margin-left: 15px;
+  border-radius: 2px;
+  color: #ffffff;
+`;
+
+const HeaderSection = styled.div`
+  display: flex;
+  align-items: center;
+  margin-bottom: 40px;
+
+  > i {
+    cursor: pointer;
+    font-size 20px;
+    color: #969Fbbaa;
+    padding: 2px;
+    border: 2px solid #969fbbaa;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+
+  > img {
+    width: 20px;
+    margin-left: 17px;
+    margin-right: 7px;
+  }
+`;
+
+const Heading = styled.div<{ isAtTop?: boolean }>`
+  color: white;
+  font-weight: 500;
+  font-size: 16px;
+  margin-bottom: 5px;
+  margin-top: ${(props) => (props.isAtTop ? "10px" : "30px")};
+  display: flex;
+  align-items: center;
+`;

+ 259 - 0
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroup.tsx

@@ -0,0 +1,259 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import key from "assets/key.svg";
+
+import { ChartType, StorageType } from "shared/types";
+import { Context } from "shared/Context";
+import StatusIndicator from "components/StatusIndicator";
+
+type PropsType = {
+  envGroup: any;
+  setExpanded: () => void;
+};
+
+type StateType = {
+  expand: boolean;
+  update: any[];
+};
+
+export default class EnvGroup extends Component<PropsType, StateType> {
+  state = {
+    expand: false,
+    update: [] as any[],
+  };
+
+  readableDate = (s: string) => {
+    let ts = new Date(s);
+    let date = ts.toLocaleDateString();
+    let time = ts.toLocaleTimeString([], {
+      hour: "numeric",
+      minute: "2-digit",
+    });
+    return `${time} on ${date}`;
+  };
+
+  render() {
+    let { envGroup, setExpanded } = this.props;
+    let name = envGroup?.metadata?.name;
+    let timestamp = envGroup?.metadata?.creationTimestamp;
+    let namespace = envGroup?.metadata?.namespace;
+    let varCount = Object.values(envGroup?.data || {}).length;
+
+    return (
+      <StyledEnvGroup
+        onMouseEnter={() => this.setState({ expand: true })}
+        onMouseLeave={() => this.setState({ expand: false })}
+        expand={this.state.expand}
+        onClick={() => setExpanded()}
+      >
+        <Title>
+          <IconWrapper>
+            <Icon src={key} />
+          </IconWrapper>
+          {name}
+        </Title>
+
+        <BottomWrapper>
+          <InfoWrapper>
+            <LastDeployed>
+              Last updated {this.readableDate(timestamp)}
+            </LastDeployed>
+          </InfoWrapper>
+
+          <TagWrapper>
+            Namespace
+            <NamespaceTag>{namespace}</NamespaceTag>
+          </TagWrapper>
+        </BottomWrapper>
+
+        <Version>{varCount} variables</Version>
+      </StyledEnvGroup>
+    );
+  }
+}
+
+EnvGroup.contextType = Context;
+
+const BottomWrapper = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding-right: 11px;
+  margin-top: 12px;
+`;
+
+const Version = styled.div`
+  position: absolute;
+  top: 12px;
+  right: 12px;
+  font-size: 12px;
+  color: #aaaabb;
+`;
+
+const Dot = styled.div`
+  margin-right: 9px;
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-right: 8px;
+`;
+
+const LastDeployed = styled.div`
+  font-size: 13px;
+  margin-left: 14px;
+  margin-top: -1px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb66;
+`;
+
+const TagWrapper = styled.div`
+  height: 20px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  border: 1px solid #ffffff44;
+  border-radius: 3px;
+  padding-left: 5px;
+`;
+
+const NamespaceTag = styled.div`
+  height: 20px;
+  margin-left: 6px;
+  color: #aaaabb;
+  background: #ffffff22;
+  border-radius: 3px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0px 6px;
+  padding-left: 7px;
+  border-top-left-radius: 0px;
+  border-bottom-left-radius: 0px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const Icon = styled.img`
+  width: 100%;
+`;
+
+const IconWrapper = styled.div`
+  color: #efefef;
+  background: none;
+  font-size: 16px;
+  top: 11px;
+  left: 14px;
+  height: 20px;
+  width: 20px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 3px;
+  position: absolute;
+
+  > i {
+    font-size: 17px;
+    margin-top: -1px;
+  }
+`;
+
+const Title = styled.div`
+  position: relative;
+  text-decoration: none;
+  padding: 12px 35px 12px 45px;
+  font-size: 14px;
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+  width: 80%;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  animation: fadeIn 0.5s;
+
+  > img {
+    background: none;
+    top: 12px;
+    left: 13px;
+
+    padding: 5px 4px;
+    width: 24px;
+    position: absolute;
+  }
+`;
+
+const StyledEnvGroup = styled.div`
+  background: #26282f;
+  cursor: pointer;
+  margin-bottom: 25px;
+  padding: 1px;
+  border-radius: 5px;
+  box-shadow: 0 5px 8px 0px #00000033;
+  position: relative;
+  border: 2px solid #9eb4ff00;
+  width: calc(100% + 2px);
+  height: calc(100% + 2px);
+
+  animation: ${(props: { expand: boolean }) =>
+      props.expand ? "expand" : "shrink"}
+    0.12s;
+  animation-fill-mode: forwards;
+  animation-timing-function: ease-out;
+
+  @keyframes expand {
+    from {
+      width: calc(100% + 2px);
+      padding-top: 4px;
+      padding-bottom: 14px;
+      margin-left: 0px;
+      box-shadow: 0 5px 8px 0px #00000033;
+      padding-left: 1px;
+      margin-bottom: 25px;
+      margin-top: 0px;
+    }
+    to {
+      width: calc(100% + 22px);
+      padding-top: 7px;
+      padding-bottom: 20px;
+      margin-left: -10px;
+      box-shadow: 0 8px 20px 0px #00000030;
+      padding-left: 5px;
+      margin-bottom: 21px;
+      margin-top: -4px;
+    }
+  }
+
+  @keyframes shrink {
+    from {
+      width: calc(100% + 22px);
+      padding-top: 7px;
+      padding-bottom: 20px;
+      margin-left: -10px;
+      box-shadow: 0 8px 20px 0px #00000030;
+      padding-left: 5px;
+      margin-bottom: 21px;
+      margin-top: -4px;
+    }
+    to {
+      width: calc(100% + 2px);
+      padding-top: 4px;
+      padding-bottom: 14px;
+      margin-left: 0px;
+      box-shadow: 0 5px 8px 0px #00000033;
+      padding-left: 1px;
+      margin-bottom: 25px;
+      margin-top: 0px;
+    }
+  }
+`;

+ 185 - 0
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx

@@ -0,0 +1,185 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import sliders from "assets/sliders.svg";
+
+import { Context } from "shared/Context";
+import { ClusterType } from "shared/types";
+
+import DashboardHeader from "../DashboardHeader";
+import NamespaceSelector from "../NamespaceSelector";
+import SortSelector from "../SortSelector";
+import EnvGroupList from "./EnvGroupList";
+import CreateEnvGroup from "./CreateEnvGroup";
+import ExpandedEnvGroup from "./ExpandedEnvGroup";
+import { RouteComponentProps, withRouter } from "react-router";
+
+type PropsType = RouteComponentProps & {
+  currentCluster: ClusterType;
+};
+
+type StateType = {
+  expand: boolean;
+  update: any[];
+  sortType: string;
+  expandedEnvGroup: any;
+  namespace: string;
+  createEnvMode: boolean;
+};
+
+class EnvGroupDashboard extends Component<PropsType, StateType> {
+  state = {
+    expand: false,
+    update: [] as any[],
+    namespace: "default",
+    expandedEnvGroup: null as any,
+    createEnvMode: false,
+    sortType: localStorage.getItem("SortType")
+      ? localStorage.getItem("SortType")
+      : "Newest",
+  };
+
+  readableDate = (s: string) => {
+    let ts = new Date(s);
+    let date = ts.toLocaleDateString();
+    let time = ts.toLocaleTimeString([], {
+      hour: "numeric",
+      minute: "2-digit",
+    });
+    return `${time} on ${date}`;
+  };
+
+  renderBody = () => {
+    if (this.state.createEnvMode) {
+      return (
+        <CreateEnvGroup
+          goBack={() => this.setState({ createEnvMode: false })}
+          currentCluster={this.props.currentCluster}
+        />
+      );
+    } else {
+      return (
+        <>
+          <ControlRow>
+            <Button
+              onClick={() =>
+                this.setState({ createEnvMode: !this.state.createEnvMode })
+              }
+            >
+              <i className="material-icons">add</i> Create Env Group
+            </Button>
+            <SortFilterWrapper>
+              <SortSelector
+                setSortType={(sortType) => this.setState({ sortType })}
+                sortType={this.state.sortType}
+              />
+              <NamespaceSelector
+                setNamespace={(namespace) => this.setState({ namespace })}
+                namespace={this.state.namespace}
+              />
+            </SortFilterWrapper>
+          </ControlRow>
+
+          <EnvGroupList
+            currentCluster={this.props.currentCluster}
+            namespace={this.state.namespace}
+            sortType={this.state.sortType}
+            setExpandedEnvGroup={(envGroup: any) =>
+              this.setState({ expandedEnvGroup: envGroup })
+            }
+          />
+        </>
+      );
+    }
+  };
+
+  renderContents = () => {
+    if (this.state.expandedEnvGroup) {
+      return (
+        <ExpandedEnvGroup
+          namespace={this.state.namespace}
+          currentCluster={this.props.currentCluster}
+          envGroup={this.state.expandedEnvGroup}
+          closeExpanded={() => this.setState({ expandedEnvGroup: null })}
+        />
+      );
+    } else {
+      return (
+        <>
+          <DashboardHeader
+            image={sliders}
+            title="Environment Groups"
+            description="Groups of environment variables for storing secrets and configuration."
+          />
+          {this.renderBody()}
+        </>
+      );
+    }
+  };
+
+  render() {
+    return <>{this.renderContents()}</>;
+  }
+}
+
+EnvGroupDashboard.contextType = Context;
+
+export default withRouter(EnvGroupDashboard);
+
+const SortFilterWrapper = styled.div`
+  width: 468px;
+  display: flex;
+  justify-content: space-between;
+`;
+
+const ControlRow = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+  padding-left: 0px;
+`;
+
+const Button = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 20px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-right: 10px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;

+ 166 - 0
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx

@@ -0,0 +1,166 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { ClusterType } from "shared/types";
+
+import EnvGroup from "./EnvGroup";
+import Loading from "components/Loading";
+
+type PropsType = {
+  currentCluster: ClusterType;
+  namespace: string;
+  sortType: string;
+  setExpandedEnvGroup: (envGroup: any) => void;
+};
+
+type StateType = {
+  envGroups: any[];
+  loading: boolean;
+  error: boolean;
+};
+
+const dummyEnvGroups = [
+  { name: "sapporo", last_updated: "12", namespace: "default" },
+  { name: "backend-staging", last_updated: "4", namespace: "default" },
+  { name: "backend-production", last_updated: "7", namespace: "default" },
+];
+
+export default class EnvGroupList extends Component<PropsType, StateType> {
+  state = {
+    envGroups: [] as any[],
+    loading: false,
+    error: false,
+  };
+
+  updateEnvGroups = () => {
+    api
+      .listConfigMaps(
+        "<token>",
+        {
+          namespace: this.props.namespace,
+          cluster_id: this.props.currentCluster.id,
+        },
+        {
+          id: this.context.currentProject.id,
+        }
+      )
+      .then((res) => {
+        let sortedGroups = res?.data?.items;
+        switch (this.props.sortType) {
+          case "Oldest":
+            sortedGroups.sort((a: any, b: any) =>
+              Date.parse(a.metadata.creationTimestamp) >
+              Date.parse(b.metadata.creationTimestamp)
+                ? 1
+                : -1
+            );
+            break;
+          case "Alphabetical":
+            sortedGroups.sort((a: any, b: any) =>
+              a.metadata.name > b.metadata.name ? 1 : -1
+            );
+            console.log(sortedGroups);
+            break;
+          default:
+            sortedGroups.sort((a: any, b: any) =>
+              Date.parse(a.metadata.creationTimestamp) >
+              Date.parse(b.metadata.creationTimestamp)
+                ? -1
+                : 1
+            );
+        }
+        this.setState({ envGroups: sortedGroups, loading: false });
+      })
+      .catch((err) => {
+        this.setState({ loading: false, error: true });
+      });
+  };
+
+  componentDidMount() {
+    this.setState({ loading: true });
+    this.updateEnvGroups();
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    // Ret2: Prevents reload when opening ClusterConfigModal
+    if (
+      prevProps.currentCluster !== this.props.currentCluster ||
+      prevProps.namespace !== this.props.namespace ||
+      prevProps.sortType !== this.props.sortType
+    ) {
+      this.updateEnvGroups();
+    }
+  }
+
+  renderEnvGroupList = () => {
+    let { loading, error, envGroups } = this.state;
+
+    if (loading) {
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
+    } else if (error) {
+      return (
+        <Placeholder>
+          <i className="material-icons">error</i> Error connecting to cluster.
+        </Placeholder>
+      );
+    } else if (envGroups.length === 0) {
+      return (
+        <Placeholder>
+          <i className="material-icons">category</i>
+          No environment groups found in this namespace.
+        </Placeholder>
+      );
+    }
+
+    return this.state.envGroups.map((envGroup: any, i: number) => {
+      return (
+        <EnvGroup
+          key={i}
+          envGroup={envGroup}
+          setExpanded={() => this.props.setExpandedEnvGroup(envGroup)}
+        />
+      );
+    });
+  };
+
+  render() {
+    return <StyledEnvGroupList>{this.renderEnvGroupList()}</StyledEnvGroupList>;
+  }
+}
+
+EnvGroupList.contextType = Context;
+
+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 StyledEnvGroupList = styled.div`
+  padding-bottom: 85px;
+`;

+ 463 - 0
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -0,0 +1,463 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import yaml from "js-yaml";
+import close from "assets/close.png";
+import key from "assets/key.svg";
+import _ from "lodash";
+
+import { ChartType, StorageType, ClusterType } from "shared/types";
+import { Context } from "shared/Context";
+import api from "shared/api";
+
+import SaveButton from "components/SaveButton";
+import ConfirmOverlay from "components/ConfirmOverlay";
+import Loading from "components/Loading";
+import TabRegion from "components/TabRegion";
+import KeyValueArray from "components/values-form/KeyValueArray";
+import Heading from "components/values-form/Heading";
+import Helper from "components/values-form/Helper";
+
+type PropsType = {
+  namespace: string;
+  envGroup: any;
+  currentCluster: ClusterType;
+  closeExpanded: () => void;
+};
+
+type StateType = {
+  loading: boolean;
+  currentTab: string | null;
+  showDeleteOverlay: boolean;
+  deleting: boolean;
+  saveValuesStatus: string | null;
+  values: any;
+};
+
+const tabOptions = [
+  { value: "environment", label: "Environment Variables" },
+  { value: "settings", label: "Settings" },
+];
+
+export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
+  state = {
+    loading: true,
+    currentTab: "environment",
+    showDeleteOverlay: false,
+    deleting: false,
+    saveValuesStatus: null as string | null,
+    values: this.props.envGroup.data as any,
+  };
+
+  handleUpdateValues = (config?: any) => {
+    let { envGroup } = this.props;
+    let name = envGroup.metadata.name;
+    let namespace = envGroup.metadata.namespace;
+
+    this.setState({ saveValuesStatus: "loading" });
+    api
+      .updateConfigMap(
+        "<token>",
+        {
+          name,
+          namespace,
+          variables: this.state.values,
+        },
+        {
+          id: this.context.currentProject.id,
+          cluster_id: this.props.currentCluster.id,
+        }
+      )
+      .then((res) => {
+        this.setState({ saveValuesStatus: "successful" });
+      })
+      .catch((err) => {
+        this.setState({ saveValuesStatus: "error" });
+      });
+  };
+
+  renderTabContents = () => {
+    let currentTab = this.state.currentTab;
+    let { envGroup, namespace } = this.props;
+    let name = envGroup.metadata.name;
+
+    switch (currentTab) {
+      case "environment":
+        return (
+          <TabWrapper>
+            <InnerWrapper>
+              <Heading>Environment Variables</Heading>
+              <Helper>
+                Set environment variables for your secrets and
+                environment-specific configuration.
+              </Helper>
+              <KeyValueArray
+                namespace={namespace}
+                values={this.state.values || {}}
+                setValues={(x: any) => this.setState({ values: x })}
+              />
+            </InnerWrapper>
+            <SaveButton
+              text="Update"
+              onClick={() => this.handleUpdateValues()}
+              status={this.state.saveValuesStatus}
+              makeFlush={true}
+            />
+          </TabWrapper>
+        );
+      default:
+        return (
+          <TabWrapper>
+            <InnerWrapper full={true}>
+              <Heading>Manage Environment Group</Heading>
+              <Helper>
+                Permanently delete this set of environment variables. This
+                action cannot be undone.
+              </Helper>
+              <Button
+                color="#b91133"
+                onClick={() => this.setState({ showDeleteOverlay: true })}
+              >
+                Delete {name}
+              </Button>
+            </InnerWrapper>
+          </TabWrapper>
+        );
+    }
+  };
+
+  readableDate = (s: string) => {
+    let ts = new Date(s);
+    let date = ts.toLocaleDateString();
+    let time = ts.toLocaleTimeString([], {
+      hour: "numeric",
+      minute: "2-digit",
+    });
+    return `${time} on ${date}`;
+  };
+
+  handleDeleteEnvGroup = () => {
+    let { envGroup } = this.props;
+    let name = envGroup.metadata.name;
+    let namespace = envGroup.metadata.namespace;
+
+    this.setState({ deleting: true });
+    api
+      .deleteConfigMap(
+        "<token>",
+        {
+          name,
+          namespace,
+          cluster_id: this.props.currentCluster.id,
+        },
+        { id: this.context.currentProject.id }
+      )
+      .then((res) => {
+        this.props.closeExpanded();
+        this.setState({ deleting: false });
+        // console.log("CONFIGMAP", res);
+      })
+      .catch((err) => {
+        this.setState({ deleting: false, showDeleteOverlay: false });
+        // console.log("CONFIGMAP", err);
+      });
+  };
+
+  renderDeleteOverlay = () => {
+    if (this.state.deleting) {
+      return (
+        <DeleteOverlay>
+          <Loading />
+        </DeleteOverlay>
+      );
+    }
+  };
+
+  render() {
+    let { closeExpanded } = this.props;
+    let { envGroup } = this.props;
+    let name = envGroup.metadata.name;
+    let timestamp = envGroup.metadata.creationTimestamp;
+    let namespace = envGroup.metadata.namespace;
+
+    return (
+      <>
+        <CloseOverlay onClick={closeExpanded} />
+        <StyledExpandedChart>
+          <ConfirmOverlay
+            show={this.state.showDeleteOverlay}
+            message={`Are you sure you want to delete ${name}?`}
+            onYes={this.handleDeleteEnvGroup}
+            onNo={() => this.setState({ showDeleteOverlay: false })}
+          />
+          {this.renderDeleteOverlay()}
+
+          <HeaderWrapper>
+            <TitleSection>
+              <Title>
+                <IconWrapper>
+                  <Icon src={key} />
+                </IconWrapper>
+                {name}
+              </Title>
+              <InfoWrapper>
+                <LastDeployed>
+                  Last updated {this.readableDate(timestamp)}
+                </LastDeployed>
+              </InfoWrapper>
+
+              <TagWrapper>
+                Namespace <NamespaceTag>{namespace}</NamespaceTag>
+              </TagWrapper>
+            </TitleSection>
+
+            <CloseButton onClick={closeExpanded}>
+              <CloseButtonImg src={close} />
+            </CloseButton>
+          </HeaderWrapper>
+
+          <TabRegion
+            currentTab={this.state.currentTab}
+            setCurrentTab={(x: string) => this.setState({ currentTab: x })}
+            options={tabOptions}
+            color={null}
+          >
+            {this.renderTabContents()}
+          </TabRegion>
+        </StyledExpandedChart>
+      </>
+    );
+  }
+}
+
+ExpandedEnvGroup.contextType = Context;
+
+const Button = styled.button`
+  height: 35px;
+  font-size: 13px;
+  margin-top: 5px;
+  margin-bottom: 30px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  padding: 6px 20px 7px 20px;
+  text-align: left;
+  border: 0;
+  border-radius: 5px;
+  background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
+  box-shadow: ${(props) =>
+    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
+  cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
+  user-select: none;
+  :focus {
+    outline: 0;
+  }
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
+`;
+
+const InnerWrapper = styled.div<{ full?: boolean }>`
+  width: 100%;
+  height: ${(props) => (props.full ? "100%" : "calc(100% - 65px)")};
+  background: #ffffff11;
+  padding: 0 35px;
+  padding-bottom: 50px;
+  position: relative;
+  border-radius: 5px;
+  overflow: auto;
+`;
+
+const TabWrapper = styled.div`
+  height: 100%;
+  width: 100%;
+  overflow: hidden;
+`;
+
+const DeleteOverlay = styled.div`
+  position: absolute;
+  top: 0px;
+  opacity: 100%;
+  left: 0px;
+  width: 100%;
+  height: 100%;
+  z-index: 999;
+  display: flex;
+  padding-bottom: 30px;
+  align-items: center;
+  justify-content: center;
+  font-family: "Work Sans", sans-serif;
+  font-size: 18px;
+  font-weight: 500;
+  color: white;
+  flex-direction: column;
+  background: rgb(0, 0, 0, 0.73);
+  opacity: 0;
+  animation: lindEnter 0.2s;
+  animation-fill-mode: forwards;
+
+  @keyframes lindEnter {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const CloseOverlay = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background: #202227;
+  animation: fadeIn 0.2s 0s;
+  opacity: 0;
+  animation-fill-mode: forwards;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const HeaderWrapper = styled.div``;
+
+const Dot = styled.div`
+  margin-right: 9px;
+  margin-left: 9px;
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin: 24px 0px 17px 0px;
+  height: 20px;
+`;
+
+const LastDeployed = styled.div`
+  font-size: 13px;
+  margin-left: 0;
+  margin-top: -1px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb66;
+`;
+
+const TagWrapper = styled.div`
+  position: absolute;
+  right: 0px;
+  bottom: 0px;
+  height: 20px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  border: 1px solid #ffffff44;
+  border-radius: 3px;
+  padding-left: 5px;
+  background: #26282e;
+`;
+
+const NamespaceTag = styled.div`
+  height: 20px;
+  margin-left: 6px;
+  color: #aaaabb;
+  background: #43454a;
+  border-radius: 3px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0px 6px;
+  padding-left: 7px;
+  border-top-left-radius: 0px;
+  border-bottom-left-radius: 0px;
+`;
+
+const Icon = styled.img`
+  width: 100%;
+`;
+
+const IconWrapper = styled.div`
+  color: #efefef;
+  font-size: 16px;
+  height: 20px;
+  width: 20px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 3px;
+  margin-right: 12px;
+
+  > i {
+    font-size: 20px;
+  }
+`;
+
+const Title = styled.div`
+  font-size: 18px;
+  font-weight: 500;
+  display: flex;
+  align-items: center;
+`;
+
+const TitleSection = styled.div`
+  width: 100%;
+  position: relative;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const StyledExpandedChart = styled.div`
+  width: calc(100% - 50px);
+  height: calc(100% - 50px);
+  z-index: 0;
+  position: absolute;
+  top: 25px;
+  left: 25px;
+  border-radius: 10px;
+  background: #26272f;
+  box-shadow: 0 5px 12px 4px #00000033;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  padding: 25px;
+  display: flex;
+  flex-direction: column;
+
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(30px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;

+ 2 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -351,6 +351,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
                         metaState={metaState}
                         metaState={metaState}
                         setMetaState={setMetaState}
                         setMetaState={setMetaState}
                         sections={tab.sections}
                         sections={tab.sections}
+                        // For env group loader
+                        namespace={this.props.namespace}
                       />
                       />
                     );
                     );
                   }
                   }

+ 14 - 4
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx

@@ -141,7 +141,10 @@ export default class ControllerTab extends Component<PropsType, StateType> {
 
 
       status?.containerStatuses?.forEach((s: any) => {
       status?.containerStatuses?.forEach((s: any) => {
         if (s.state?.waiting) {
         if (s.state?.waiting) {
-          collatedStatus = "waiting";
+          collatedStatus =
+            s.state?.waiting.reason === "CrashLoopBackOff"
+              ? "failed"
+              : "waiting";
         } else if (s.state?.terminated) {
         } else if (s.state?.terminated) {
           collatedStatus = "failed";
           collatedStatus = "failed";
         }
         }
@@ -195,6 +198,16 @@ export default class ControllerTab extends Component<PropsType, StateType> {
     let [available, total] = this.getAvailability(controller.kind, controller);
     let [available, total] = this.getAvailability(controller.kind, controller);
     let status = available == total ? "running" : "waiting";
     let status = available == total ? "running" : "waiting";
 
 
+    controller?.status?.conditions?.forEach((condition: any) => {
+      if (
+        condition.type == "Progressing" &&
+        condition.status == "False" &&
+        condition.reason == "ProgressDeadlineExceeded"
+      ) {
+        status = "failed";
+      }
+    });
+
     if (controller.kind.toLowerCase() === "job" && this.state.raw.length == 0) {
     if (controller.kind.toLowerCase() === "job" && this.state.raw.length == 0) {
       status = "completed";
       status = "completed";
     }
     }
@@ -210,9 +223,6 @@ export default class ControllerTab extends Component<PropsType, StateType> {
       >
       >
         {this.state.raw.map((pod, i) => {
         {this.state.raw.map((pod, i) => {
           let status = this.getPodStatus(pod.status);
           let status = this.getPodStatus(pod.status);
-          if (i === 2) {
-            status = "failed";
-          }
           return (
           return (
             <Tab
             <Tab
               key={pod.metadata?.name}
               key={pod.metadata?.name}

+ 13 - 20
dashboard/src/main/home/dashboard/ClusterPlaceholder.tsx

@@ -44,21 +44,16 @@ export default class ClusterPlaceholder extends Component<
       );
       );
     } else if (!this.props.currentCluster) {
     } else if (!this.props.currentCluster) {
       return (
       return (
-        <>
-          <Banner>
-            <i className="material-icons">error_outline</i>
-            This project currently has no clusters connected.
-          </Banner>
-          <StyledStatusPlaceholder>
-            <Highlight
-              onClick={() => {
-                this.context.setCurrentModal("ClusterInstructionsModal", {});
-              }}
-            >
-              + Connect a Cluster
-            </Highlight>
-          </StyledStatusPlaceholder>
-        </>
+        <StyledStatusPlaceholder>
+          You need to connect a cluster to use Porter.
+          <Highlight
+            onClick={() => {
+              this.context.setCurrentModal("ClusterInstructionsModal", {});
+            }}
+          >
+            + Connect an existing cluster
+          </Highlight>
+        </StyledStatusPlaceholder>
       );
       );
     } else {
     } else {
       return <ClusterList currentCluster={this.props.currentCluster} />;
       return <ClusterList currentCluster={this.props.currentCluster} />;
@@ -78,7 +73,7 @@ const LoadingWrapper = styled.div`
 const Highlight = styled.div`
 const Highlight = styled.div`
   color: #8590ff;
   color: #8590ff;
   cursor: pointer;
   cursor: pointer;
-  margin-left: 10px;
+  margin-left: 5px;
   margin-right: 10px;
   margin-right: 10px;
 `;
 `;
 
 
@@ -100,15 +95,13 @@ const Banner = styled.div`
 
 
 const StyledStatusPlaceholder = styled.div`
 const StyledStatusPlaceholder = styled.div`
   width: 100%;
   width: 100%;
-  height: calc(100vh - 450px);
-  margin-top: 30px;
+  height: calc(100vh - 470px);
+  margin-top: 10px;
   display: flex;
   display: flex;
-  align-items: center;
   color: #aaaabb;
   color: #aaaabb;
   border-radius: 5px;
   border-radius: 5px;
   text-align: center;
   text-align: center;
   font-size: 13px;
   font-size: 13px;
-  padding-bottom: 25px;
   background: #ffffff09;
   background: #ffffff09;
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;

+ 12 - 14
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -21,11 +21,12 @@ type PropsType = RouteComponentProps & {
 
 
 const tabOptions = [
 const tabOptions = [
   { label: "Project Overview", value: "overview" },
   { label: "Project Overview", value: "overview" },
+  { label: "Create a Cluster", value: "create-cluster" },
   { label: "Provisioner Status", value: "provisioner" },
   { label: "Provisioner Status", value: "provisioner" },
 ];
 ];
 
 
 // TODO: rethink this list, should be coupled with tabOptions
 // TODO: rethink this list, should be coupled with tabOptions
-const tabOptionStrings = ["overview", "provisioner"];
+const tabOptionStrings = ["overview", "create-cluster", "provisioner"];
 
 
 type StateType = {
 type StateType = {
   infras: InfraType[];
   infras: InfraType[];
@@ -74,27 +75,24 @@ class Dashboard extends Component<PropsType, StateType> {
   renderTabContents = () => {
   renderTabContents = () => {
     if (this.currentTab() === "provisioner") {
     if (this.currentTab() === "provisioner") {
       return <Provisioner setRefreshClusters={this.props.setRefreshClusters} />;
       return <Provisioner setRefreshClusters={this.props.setRefreshClusters} />;
-    } else {
+    } else if (this.currentTab() === "create-cluster") {
       return (
       return (
         <>
         <>
-          {!this.context.currentCluster ? (
-            <>
-              <Banner>
-                <i className="material-icons">error_outline</i>
-                This project currently has no clusters connected.
-              </Banner>
-              <ProvisionerSettings infras={this.state.infras} />
-            </>
-          ) : (
-            <ClusterPlaceholderContainer />
-          )}
+          <Banner>
+            <i className="material-icons">info</i>
+            Create a cluster to link to this project.
+          </Banner>
+          <ProvisionerSettings infras={this.state.infras} />
         </>
         </>
       );
       );
+    } else {
+      return <ClusterPlaceholderContainer />;
     }
     }
   };
   };
 
 
-  setCurrentTab = (x: string) =>
+  setCurrentTab = (x: string) => {
     this.props.history.push(setSearchParam(this.props.location, "tab", x));
     this.props.history.push(setSearchParam(this.props.location, "tab", x));
+  };
 
 
   render() {
   render() {
     let { currentProject } = this.context;
     let { currentProject } = this.context;

+ 1 - 0
dashboard/src/main/home/integrations/IntegrationRow.tsx

@@ -94,6 +94,7 @@ export default class IntegrationRow extends Component<PropsType, StateType> {
                   {
                   {
                     git_repo: "",
                     git_repo: "",
                     image_repo_uri: "",
                     image_repo_uri: "",
+                    branch: "",
                     git_repo_id: 0,
                     git_repo_id: 0,
                     dockerfile_path: "",
                     dockerfile_path: "",
                   } as ActionConfigType
                   } as ActionConfigType

+ 4 - 2
dashboard/src/main/home/launch/Launch.tsx

@@ -61,9 +61,11 @@ export default class Templates extends Component<PropsType, StateType> {
       )
       )
       .then((res) => {
       .then((res) => {
         this.setState({ applicationTemplates: res.data, error: false }, () => {
         this.setState({ applicationTemplates: res.data, error: false }, () => {
-          let preferredOrder = ['web', 'worker', 'job']
+          let preferredOrder = ["web", "worker", "job"];
           this.state.applicationTemplates.sort((a, b) => {
           this.state.applicationTemplates.sort((a, b) => {
-            return preferredOrder.indexOf(a.name) - preferredOrder.indexOf(b.name)
+            return (
+              preferredOrder.indexOf(a.name) - preferredOrder.indexOf(b.name)
+            );
           });
           });
           this.setState({
           this.setState({
             loading: false,
             loading: false,

+ 11 - 1
dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx

@@ -40,6 +40,7 @@ type StateType = {
   saveValuesStatus: string | null;
   saveValuesStatus: string | null;
   selectedNamespace: string;
   selectedNamespace: string;
   selectedCluster: string;
   selectedCluster: string;
+  selectedClusterId: number;
   selectedImageUrl: string | null;
   selectedImageUrl: string | null;
   sourceType: string;
   sourceType: string;
   selectedTag: string | null;
   selectedTag: string | null;
@@ -60,6 +61,7 @@ type StateType = {
 const defaultActionConfig: ActionConfigType = {
 const defaultActionConfig: ActionConfigType = {
   git_repo: "",
   git_repo: "",
   image_repo_uri: "",
   image_repo_uri: "",
+  branch: "",
   git_repo_id: 0,
   git_repo_id: 0,
 };
 };
 
 
@@ -70,6 +72,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
     clusterMap: {} as { [clusterId: string]: ClusterType },
     clusterMap: {} as { [clusterId: string]: ClusterType },
     saveValuesStatus: "" as string | null,
     saveValuesStatus: "" as string | null,
     selectedCluster: this.context.currentCluster.name,
     selectedCluster: this.context.currentCluster.name,
+    selectedClusterId: this.context.currentCluster.id,
     selectedNamespace: "default",
     selectedNamespace: "default",
     selectedImageUrl: "" as string | null,
     selectedImageUrl: "" as string | null,
     sourceType: "",
     sourceType: "",
@@ -103,6 +106,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
         "<token>",
         "<token>",
         {
         {
           git_repo: actionConfig.git_repo,
           git_repo: actionConfig.git_repo,
+          git_branch: this.state.branch,
           registry_id: this.state.selectedRegistry.id,
           registry_id: this.state.selectedRegistry.id,
           dockerfile_path: this.state.dockerfilePath,
           dockerfile_path: this.state.dockerfilePath,
           folder_path: this.state.folderPath,
           folder_path: this.state.folderPath,
@@ -412,6 +416,9 @@ class LaunchTemplate extends Component<PropsType, StateType> {
                   setMetaState={setMetaState}
                   setMetaState={setMetaState}
                   key={tab.name}
                   key={tab.name}
                   sections={tab.sections}
                   sections={tab.sections}
+                  // For env group loader
+                  namespace={this.state.selectedNamespace}
+                  clusterId={this.state.selectedClusterId}
                 />
                 />
               );
               );
             }
             }
@@ -738,7 +745,10 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             setActiveValue={(cluster: string) => {
             setActiveValue={(cluster: string) => {
               this.context.setCurrentCluster(this.state.clusterMap[cluster]);
               this.context.setCurrentCluster(this.state.clusterMap[cluster]);
               this.updateNamespaces(this.state.clusterMap[cluster].id);
               this.updateNamespaces(this.state.clusterMap[cluster].id);
-              this.setState({ selectedCluster: cluster });
+              this.setState({
+                selectedCluster: cluster,
+                selectedClusterId: this.state.clusterMap[cluster].id,
+              });
             }}
             }}
             options={this.state.clusterOptions}
             options={this.state.clusterOptions}
             width="250px"
             width="250px"

+ 236 - 0
dashboard/src/main/home/modals/LoadEnvGroupModal.tsx

@@ -0,0 +1,236 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import close from "assets/close.png";
+import sliders from "assets/sliders.svg";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+import Loading from "components/Loading";
+import SaveButton from "components/SaveButton";
+
+type PropsType = {
+  namespace: string;
+  clusterId: number;
+  closeModal: () => void;
+  setValues: (values: any) => void;
+};
+
+type StateType = {
+  envGroups: any[];
+  loading: boolean;
+  error: boolean;
+  selectedEnvGroup: any;
+  buttonStatus: string;
+};
+
+export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
+  state = {
+    envGroups: [] as any[],
+    loading: true,
+    error: false,
+    selectedEnvGroup: null as any,
+    buttonStatus: "",
+  };
+
+  onSubmit = () => {
+    this.props.setValues(this.state.selectedEnvGroup.data);
+    this.props.closeModal();
+  };
+
+  updateEnvGroups = () => {
+    api
+      .listConfigMaps(
+        "<token>",
+        {
+          namespace: this.props.namespace,
+          cluster_id: this.props.clusterId || this.context.currentCluster.id,
+        },
+        {
+          id: this.context.currentProject.id,
+        }
+      )
+      .then((res) => {
+        this.setState({
+          envGroups: res?.data?.items as any[],
+          loading: false,
+        });
+        console.log(res.data.items);
+      })
+      .catch((err) => {
+        this.setState({ loading: false, error: true });
+      });
+  };
+
+  componentDidMount() {
+    this.updateEnvGroups();
+  }
+
+  renderEnvGroupList = () => {
+    if (this.state.loading) {
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
+    } else if (this.state.envGroups.length === 0) {
+      return (
+        <Placeholder>
+          No environment groups found in this namespace ({this.props.namespace}
+          ).
+        </Placeholder>
+      );
+    } else {
+      return this.state.envGroups.map((envGroup: any, i: number) => {
+        return (
+          <EnvGroupRow
+            key={i}
+            isSelected={this.state.selectedEnvGroup === envGroup}
+            lastItem={i === this.state.envGroups.length - 1}
+            onClick={() => this.setState({ selectedEnvGroup: envGroup })}
+          >
+            <img src={sliders} />
+            {envGroup.metadata.name}
+          </EnvGroupRow>
+        );
+      });
+    }
+  };
+
+  render() {
+    return (
+      <StyledLoadEnvGroupModal>
+        <CloseButton onClick={this.props.closeModal}>
+          <CloseButtonImg src={close} />
+        </CloseButton>
+
+        <ModalTitle>Load from Environment Group</ModalTitle>
+        <Subtitle>
+          Select an existing group of environment variables in this namespace (
+          {this.props.namespace}).
+        </Subtitle>
+
+        <EnvGroupList>{this.renderEnvGroupList()}</EnvGroupList>
+
+        <SaveButton
+          disabled={!this.state.selectedEnvGroup}
+          text="Load Selected Env Group"
+          status={
+            !this.state.selectedEnvGroup
+              ? "No env group selected"
+              : "Existing env variables will be overidden"
+          }
+          onClick={this.onSubmit}
+        />
+      </StyledLoadEnvGroupModal>
+    );
+  }
+}
+
+LoadEnvGroupModal.contextType = Context;
+
+const Placeholder = styled.div`
+  width: 100%;
+  height: 150px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #aaaabb;
+  font-size: 13px;
+`;
+
+const LoadingWrapper = styled.div`
+  height: 150px;
+`;
+
+const EnvGroupRow = styled.div<{ lastItem?: boolean; isSelected: boolean }>`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  border-bottom: 1px solid
+    ${(props) => (props.lastItem ? "#00000000" : "#606166")};
+  color: #ffffff;
+  user-select: none;
+  align-items: center;
+  padding: 10px 0px;
+  cursor: pointer;
+  background: ${(props) => (props.isSelected ? "#ffffff11" : "")};
+  :hover {
+    background: #ffffff11;
+  }
+
+  > img,
+  i {
+    width: 16px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+    font-size: 20px;
+  }
+`;
+
+const EnvGroupList = styled.div`
+  margin-top: 20px;
+  width: 100%;
+  border-radius: 3px;
+  background: #ffffff11;
+  border: 1px solid #ffffff44;
+  max-height: 160px;
+  overflow-y: auto;
+`;
+
+const Subtitle = styled.div`
+  margin-top: 15px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+`;
+
+const ModalTitle = styled.div`
+  margin: 0px 0px 13px;
+  display: flex;
+  flex: 1;
+  font-family: "Assistant";
+  font-size: 18px;
+  color: #ffffff;
+  user-select: none;
+  font-weight: 700;
+  align-items: center;
+  position: relative;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const StyledLoadEnvGroupModal = styled.div`
+  width: 100%;
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  padding: 25px 30px;
+  overflow: hidden;
+  border-radius: 6px;
+  background: #202227;
+`;

+ 14 - 8
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -62,13 +62,13 @@ const regionOptions = [
 ];
 ];
 
 
 const machineTypeOptions = [
 const machineTypeOptions = [
-  { value: "t2.medium", label: "t2.medium"},
-  { value: "t2.xlarge", label: "t2.xlarge"},
-  { value: "t2.2xlarge", label: "t2.2xlarge"},
-  { value: "t3.medium", label: "t3.medium"},
-  { value: "t3.xlarge", label: "t3.xlarge"},
-  { value: "t3.2xlarge", label: "t3.2xlarge"},
-]
+  { value: "t2.medium", label: "t2.medium" },
+  { value: "t2.xlarge", label: "t2.xlarge" },
+  { value: "t2.2xlarge", label: "t2.2xlarge" },
+  { value: "t3.medium", label: "t3.medium" },
+  { value: "t3.xlarge", label: "t3.xlarge" },
+  { value: "t3.2xlarge", label: "t3.2xlarge" },
+];
 
 
 // TODO: Consolidate across forms w/ HOC
 // TODO: Consolidate across forms w/ HOC
 class AWSFormSection extends Component<PropsType, StateType> {
 class AWSFormSection extends Component<PropsType, StateType> {
@@ -275,7 +275,13 @@ class AWSFormSection extends Component<PropsType, StateType> {
 
 
   render() {
   render() {
     let { setSelectedProvisioner } = this.props;
     let { setSelectedProvisioner } = this.props;
-    let { awsRegion, awsMachineType, awsAccessId, awsSecretKey, selectedInfras } = this.state;
+    let {
+      awsRegion,
+      awsMachineType,
+      awsAccessId,
+      awsSecretKey,
+      selectedInfras,
+    } = this.state;
 
 
     return (
     return (
       <StyledAWSFormSection>
       <StyledAWSFormSection>

+ 1 - 2
dashboard/src/main/home/provisioner/DOFormSection.tsx

@@ -35,7 +35,6 @@ const provisionOptions = [
 
 
 const tierOptions = [
 const tierOptions = [
   { value: "basic", label: "Basic" },
   { value: "basic", label: "Basic" },
-  { value: "starter", label: "Starter" },
   { value: "professional", label: "Professional" },
   { value: "professional", label: "Professional" },
 ];
 ];
 
 
@@ -56,7 +55,7 @@ const regionOptions = [
 export default class DOFormSection extends Component<PropsType, StateType> {
 export default class DOFormSection extends Component<PropsType, StateType> {
   state = {
   state = {
     selectedInfras: [...provisionOptions],
     selectedInfras: [...provisionOptions],
-    subscriptionTier: "starter",
+    subscriptionTier: "basic",
     doRegion: "nyc1",
     doRegion: "nyc1",
     provisionConfirmed: false,
     provisionConfirmed: false,
   };
   };

+ 2 - 2
dashboard/src/main/home/provisioner/Provisioner.tsx

@@ -159,8 +159,8 @@ export default withRouter(Provisioner);
 
 
 const StyledProvisioner = styled.div`
 const StyledProvisioner = styled.div`
   width: 100%;
   width: 100%;
-  height: 350px;
-  background: #ffffff11;
+  height: calc(100vh - 470px);
+  background: #ffffff09;
   color: #aaaabb;
   color: #aaaabb;
   border-radius: 5px;
   border-radius: 5px;
   display: flex;
   display: flex;

+ 3 - 1
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -246,6 +246,7 @@ const ClusterName = styled.div`
   width: 130px;
   width: 130px;
   margin-left: 3px;
   margin-left: 3px;
   font-weight: 400;
   font-weight: 400;
+  color: #ffffff44;
 `;
 `;
 
 
 const DropdownIcon = styled.span`
 const DropdownIcon = styled.span`
@@ -289,6 +290,7 @@ const ClusterIcon = styled.div`
     margin-bottom: 0px;
     margin-bottom: 0px;
     margin-left: 17px;
     margin-left: 17px;
     margin-right: 10px;
     margin-right: 10px;
+    color: #ffffff44;
   }
   }
 `;
 `;
 
 
@@ -306,7 +308,7 @@ const ClusterSelector = styled.div`
   padding-left: 7px;
   padding-left: 7px;
   width: 100%;
   width: 100%;
   height: 42px;
   height: 42px;
-  margin: 8px auto 0 auto;
+  margin: 0 auto 0 auto;
   font-size: 14px;
   font-size: 14px;
   font-weight: 500;
   font-weight: 500;
   color: white;
   color: white;

+ 1 - 1
dashboard/src/main/home/sidebar/Drawer.tsx

@@ -71,7 +71,7 @@ class Drawer extends Component<PropsType, StateType> {
               this.context.setCurrentModal("ClusterInstructionsModal", {});
               this.context.setCurrentModal("ClusterInstructionsModal", {});
             }}
             }}
           >
           >
-            <Plus>+</Plus> Add a Cluster
+            <Plus>+</Plus> Connect a Cluster
           </InitializeButton>
           </InitializeButton>
         </StyledDrawer>
         </StyledDrawer>
       </div>
       </div>

+ 10 - 14
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -7,6 +7,7 @@ import monojob from "assets/monojob.png";
 import monoweb from "assets/monoweb.png";
 import monoweb from "assets/monoweb.png";
 import settings from "assets/settings.svg";
 import settings from "assets/settings.svg";
 import discordLogo from "assets/discord.svg";
 import discordLogo from "assets/discord.svg";
+import sliders from "assets/sliders.svg";
 
 
 import { Context } from "shared/Context";
 import { Context } from "shared/Context";
 
 
@@ -108,13 +109,6 @@ class Sidebar extends Component<PropsType, StateType> {
               this.props.history.push("/applications");
               this.props.history.push("/applications");
             }}
             }}
           >
           >
-            <BranchPad>
-              <Gutter>
-                <Rail />
-                <Circle />
-                <Rail lastTab={false} />
-              </Gutter>
-            </BranchPad>
             <Img src={monoweb} />
             <Img src={monoweb} />
             Applications
             Applications
           </NavButton>
           </NavButton>
@@ -124,16 +118,18 @@ class Sidebar extends Component<PropsType, StateType> {
               this.props.history.push("/jobs");
               this.props.history.push("/jobs");
             }}
             }}
           >
           >
-            <BranchPad>
-              <Gutter>
-                <Rail />
-                <Circle />
-                <Rail lastTab={true} />
-              </Gutter>
-            </BranchPad>
             <Img src={monojob} />
             <Img src={monojob} />
             Jobs
             Jobs
           </NavButton>
           </NavButton>
+          <NavButton
+            selected={currentView === "env-groups"}
+            onClick={() => {
+              this.props.history.push("/env-groups");
+            }}
+          >
+            <Img src={sliders} />
+            Env Groups
+          </NavButton>
         </>
         </>
       );
       );
     }
     }

+ 62 - 0
dashboard/src/shared/api.tsx

@@ -102,6 +102,7 @@ const createGCR = baseApi<
 const createGHAction = baseApi<
 const createGHAction = baseApi<
   {
   {
     git_repo: string;
     git_repo: string;
+    git_branch: string;
     registry_id: number;
     registry_id: number;
     image_repo_uri: string;
     image_repo_uri: string;
     dockerfile_path: string;
     dockerfile_path: string;
@@ -710,6 +711,62 @@ const upgradeChartValues = baseApi<
   return `/api/projects/${id}/releases/${name}/upgrade?cluster_id=${cluster_id}`;
   return `/api/projects/${id}/releases/${name}/upgrade?cluster_id=${cluster_id}`;
 });
 });
 
 
+const listConfigMaps = baseApi<
+  {
+    namespace: string;
+    cluster_id: number;
+  },
+  { id: number }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.id}/k8s/configmap/list`;
+});
+
+const getConfigMap = baseApi<
+  {
+    name: string;
+    namespace: string;
+    cluster_id: number;
+  },
+  { id: number }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.id}/k8s/configmap`;
+});
+
+const createConfigMap = baseApi<
+  {
+    name: string;
+    namespace: string;
+    variables: Record<string, string>;
+  },
+  { id: number; cluster_id: number }
+>("POST", (pathParams) => {
+  let { id, cluster_id } = pathParams;
+  return `/api/projects/${id}/k8s/configmap/create?cluster_id=${cluster_id}`;
+});
+
+const updateConfigMap = baseApi<
+  {
+    name: string;
+    namespace: string;
+    variables: Record<string, string>;
+  },
+  { id: number; cluster_id: number }
+>("POST", (pathParams) => {
+  let { id, cluster_id } = pathParams;
+  return `/api/projects/${id}/k8s/configmap/update?cluster_id=${cluster_id}`;
+});
+
+const deleteConfigMap = baseApi<
+  {
+    name: string;
+    namespace: string;
+    cluster_id: number;
+  },
+  { id: number }
+>("DELETE", (pathParams) => {
+  return `/api/projects/${pathParams.id}/k8s/configmap/delete`;
+});
+
 // Bundle export to allow default api import (api.<method> is more readable)
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
 export default {
   checkAuth,
   checkAuth,
@@ -728,7 +785,9 @@ export default {
   createPasswordResetVerify,
   createPasswordResetVerify,
   createPasswordResetFinalize,
   createPasswordResetFinalize,
   createProject,
   createProject,
+  createConfigMap,
   deleteCluster,
   deleteCluster,
+  deleteConfigMap,
   deleteGitRepoIntegration,
   deleteGitRepoIntegration,
   deleteInvite,
   deleteInvite,
   deletePod,
   deletePod,
@@ -747,6 +806,7 @@ export default {
   getChartControllers,
   getChartControllers,
   getClusterIntegrations,
   getClusterIntegrations,
   getClusters,
   getClusters,
+  getConfigMap,
   getGitRepoList,
   getGitRepoList,
   getGitRepos,
   getGitRepos,
   getImageRepos,
   getImageRepos,
@@ -776,6 +836,7 @@ export default {
   getApplicationTemplates,
   getApplicationTemplates,
   getUser,
   getUser,
   linkGithubProject,
   linkGithubProject,
+  listConfigMaps,
   logInUser,
   logInUser,
   logOutUser,
   logOutUser,
   provisionECR,
   provisionECR,
@@ -784,5 +845,6 @@ export default {
   rollbackChart,
   rollbackChart,
   uninstallTemplate,
   uninstallTemplate,
   updateUser,
   updateUser,
+  updateConfigMap,
   upgradeChartValues,
   upgradeChartValues,
 };
 };

+ 2 - 0
dashboard/src/shared/routing.tsx

@@ -8,6 +8,7 @@ export type PorterUrl =
   | "cluster-dashboard"
   | "cluster-dashboard"
   | "project-settings"
   | "project-settings"
   | "applications"
   | "applications"
+  | "env-groups"
   | "jobs";
   | "jobs";
 
 
 export const PorterUrls = [
 export const PorterUrls = [
@@ -18,6 +19,7 @@ export const PorterUrls = [
   "cluster-dashboard",
   "cluster-dashboard",
   "project-settings",
   "project-settings",
   "applications",
   "applications",
+  "env-groups",
   "jobs",
   "jobs",
 ];
 ];
 
 

+ 1 - 0
dashboard/src/shared/types.tsx

@@ -163,6 +163,7 @@ export interface InviteType {
 
 
 export interface ActionConfigType {
 export interface ActionConfigType {
   git_repo: string;
   git_repo: string;
+  branch: string;
   image_repo_uri: string;
   image_repo_uri: string;
   git_repo_id: number;
   git_repo_id: number;
 }
 }

+ 2 - 0
go.mod

@@ -4,6 +4,7 @@ go 1.15
 
 
 require (
 require (
 	cloud.google.com/go v0.65.0
 	cloud.google.com/go v0.65.0
+	github.com/AlecAivazis/survey/v2 v2.2.9
 	github.com/Azure/go-autorest/autorest v0.11.1 // indirect
 	github.com/Azure/go-autorest/autorest v0.11.1 // indirect
 	github.com/Azure/go-autorest/autorest/adal v0.9.5 // indirect
 	github.com/Azure/go-autorest/autorest/adal v0.9.5 // indirect
 	github.com/DATA-DOG/go-sqlmock v1.5.0
 	github.com/DATA-DOG/go-sqlmock v1.5.0
@@ -88,6 +89,7 @@ require (
 	k8s.io/client-go v0.18.8
 	k8s.io/client-go v0.18.8
 	k8s.io/helm v2.16.12+incompatible
 	k8s.io/helm v2.16.12+incompatible
 	k8s.io/klog/v2 v2.2.0 // indirect
 	k8s.io/klog/v2 v2.2.0 // indirect
+	k8s.io/kubectl v0.18.8
 	k8s.io/utils v0.0.0-20200912215256-4140de9c8800 // indirect
 	k8s.io/utils v0.0.0-20200912215256-4140de9c8800 // indirect
 	rsc.io/letsencrypt v0.0.3 // indirect
 	rsc.io/letsencrypt v0.0.3 // indirect
 	sigs.k8s.io/aws-iam-authenticator v0.5.2
 	sigs.k8s.io/aws-iam-authenticator v0.5.2

+ 16 - 2
go.sum

@@ -33,6 +33,9 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
 cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
 cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
 cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
 cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
+github.com/AlecAivazis/survey v1.8.8 h1:Y4yypp763E8cbqb5RBqZhGgkCFLRFnbRBHrxnpMMsgQ=
+github.com/AlecAivazis/survey/v2 v2.2.9 h1:LWvJtUswz/W9/zVVXELrmlvdwWcKE60ZAw0FWV9vssk=
+github.com/AlecAivazis/survey/v2 v2.2.9/go.mod h1:9DYvHgXtiXm6nCn+jXnOXLKbH+Yo9u8fAS/SduGdoPk=
 github.com/Azure/azure-sdk-for-go v16.2.1+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
 github.com/Azure/azure-sdk-for-go v16.2.1+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
 github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8=
 github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8=
 github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
 github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
@@ -86,6 +89,7 @@ github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tT
 github.com/Microsoft/hcsshim v0.8.7 h1:ptnOoufxGSzauVTsdE+wMYnCWA301PdoN4xg5oRdZpg=
 github.com/Microsoft/hcsshim v0.8.7 h1:ptnOoufxGSzauVTsdE+wMYnCWA301PdoN4xg5oRdZpg=
 github.com/Microsoft/hcsshim v0.8.7/go.mod h1:OHd7sQqRFrYd3RmSgbgji+ctCwkbq2wbEYNSzOYtcBQ=
 github.com/Microsoft/hcsshim v0.8.7/go.mod h1:OHd7sQqRFrYd3RmSgbgji+ctCwkbq2wbEYNSzOYtcBQ=
 github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
 github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
+github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc=
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
 github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
 github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
 github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
 github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
@@ -564,6 +568,7 @@ github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0m
 github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
 github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
 github.com/hashicorp/serf v0.8.2 h1:YZ7UKsJv+hKjqGVUUbtE3HNj79Eln2oQ75tniF6iPt0=
 github.com/hashicorp/serf v0.8.2 h1:YZ7UKsJv+hKjqGVUUbtE3HNj79Eln2oQ75tniF6iPt0=
 github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
 github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
+github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A=
 github.com/hokaccha/go-prettyjson v0.0.0-20190818114111-108c894c2c0e/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI=
 github.com/hokaccha/go-prettyjson v0.0.0-20190818114111-108c894c2c0e/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/huandu/xstrings v1.3.1 h1:4jgBlKK6tLKFvO8u5pmYjG91cqytmDCDvGh7ECVFfFs=
 github.com/huandu/xstrings v1.3.1 h1:4jgBlKK6tLKFvO8u5pmYjG91cqytmDCDvGh7ECVFfFs=
@@ -671,6 +676,8 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1
 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
 github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
 github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
 github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
 github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
 github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
 github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
@@ -682,6 +689,7 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
 github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs=
 github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs=
 github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
 github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/pty v1.1.4/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA=
 github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA=
 github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI=
 github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI=
 github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
 github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
@@ -751,6 +759,8 @@ github.com/mattn/go-sqlite3 v1.14.3 h1:j7a/xn1U6TKA/PHHxqZuzh64CdtRc7rU9M+AvkOl5
 github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
 github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
 github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
 github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
+github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
+github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
 github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
 github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
 github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
 github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
 github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=
 github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=
@@ -926,14 +936,14 @@ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb
 github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
 github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
+github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3 h1:ZuhckGJ10ulaKkdvJtiAqsLTiPrLaXSdnVgXJKJkTxE=
+github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3/go.mod h1:9/Rh6yILuLysoQnZ2oNooD2g7aBnvM7r/fNVxRNWfBc=
 github.com/sendgrid/rest v1.0.2 h1:xdfALkR1m9eqf41/zEnUmV0fw4b31ZzGZ4Dj5f2/w04=
 github.com/sendgrid/rest v1.0.2 h1:xdfALkR1m9eqf41/zEnUmV0fw4b31ZzGZ4Dj5f2/w04=
 github.com/sendgrid/rest v2.6.3+incompatible h1:h/uruXAzKxVyDDIQX/MkQI73p/gsdpEnb5q2wxSvTsA=
 github.com/sendgrid/rest v2.6.3+incompatible h1:h/uruXAzKxVyDDIQX/MkQI73p/gsdpEnb5q2wxSvTsA=
 github.com/sendgrid/rest v2.6.3+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE=
 github.com/sendgrid/rest v2.6.3+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE=
 github.com/sendgrid/sendgrid-go v1.2.0 h1:2K3teZdhaPe12ftFyFL4AWDH4QmNPc+sCi6mWFx5+oo=
 github.com/sendgrid/sendgrid-go v1.2.0 h1:2K3teZdhaPe12ftFyFL4AWDH4QmNPc+sCi6mWFx5+oo=
 github.com/sendgrid/sendgrid-go v3.8.0+incompatible h1:7yoUFMwT+jDI2ArBpC6zvtuQj1RUyYfCDl7zZea3XV4=
 github.com/sendgrid/sendgrid-go v3.8.0+incompatible h1:7yoUFMwT+jDI2ArBpC6zvtuQj1RUyYfCDl7zZea3XV4=
 github.com/sendgrid/sendgrid-go v3.8.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
 github.com/sendgrid/sendgrid-go v3.8.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
-github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3 h1:ZuhckGJ10ulaKkdvJtiAqsLTiPrLaXSdnVgXJKJkTxE=
-github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3/go.mod h1:9/Rh6yILuLysoQnZ2oNooD2g7aBnvM7r/fNVxRNWfBc=
 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
 github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc h1:jUIKcSPO9MoMJBbEoyE/RJoE8vz7Mb8AjvifMMwSyvY=
 github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc h1:jUIKcSPO9MoMJBbEoyE/RJoE8vz7Mb8AjvifMMwSyvY=
@@ -982,6 +992,7 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
 github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
 github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
 github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
 github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
 github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -1065,6 +1076,7 @@ golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576/go.mod h1:djNgcEr1/C05ACk
 golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
 golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20190617133340-57b3e21c3d56/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20190617133340-57b3e21c3d56/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@@ -1205,6 +1217,7 @@ golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190514135907-3a4b5fb9f71f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190514135907-3a4b5fb9f71f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190515120540-06a5c4944438/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190515120540-06a5c4944438/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190530182044-ad28b68e88f1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190602015325-4c4f7f33c9ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190602015325-4c4f7f33c9ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1494,6 +1507,7 @@ k8s.io/apiextensions-apiserver v0.18.8/go.mod h1:7f4ySEkkvifIr4+BRrRWriKKIJjPyg9
 k8s.io/apimachinery v0.16.8/go.mod h1:Xk2vD2TRRpuWYLQNM6lT9R7DSFZUYG03SarNkbGrnKE=
 k8s.io/apimachinery v0.16.8/go.mod h1:Xk2vD2TRRpuWYLQNM6lT9R7DSFZUYG03SarNkbGrnKE=
 k8s.io/apimachinery v0.18.8 h1:jimPrycCqgx2QPearX3to1JePz7wSbVLq+7PdBTTwQ0=
 k8s.io/apimachinery v0.18.8 h1:jimPrycCqgx2QPearX3to1JePz7wSbVLq+7PdBTTwQ0=
 k8s.io/apimachinery v0.18.8/go.mod h1:6sQd+iHEqmOtALqOFjSWp2KZ9F0wlU/nWm0ZgsYWMig=
 k8s.io/apimachinery v0.18.8/go.mod h1:6sQd+iHEqmOtALqOFjSWp2KZ9F0wlU/nWm0ZgsYWMig=
+k8s.io/apimachinery v0.21.0 h1:3Fx+41if+IRavNcKOz09FwEXDBG6ORh6iMsTSelhkMA=
 k8s.io/apiserver v0.18.8/go.mod h1:12u5FuGql8Cc497ORNj79rhPdiXQC4bf53X/skR/1YM=
 k8s.io/apiserver v0.18.8/go.mod h1:12u5FuGql8Cc497ORNj79rhPdiXQC4bf53X/skR/1YM=
 k8s.io/cli-runtime v0.18.8 h1:ycmbN3hs7CfkJIYxJAOB10iW7BVPmXGXkfEyiV9NJ+k=
 k8s.io/cli-runtime v0.18.8 h1:ycmbN3hs7CfkJIYxJAOB10iW7BVPmXGXkfEyiV9NJ+k=
 k8s.io/cli-runtime v0.18.8/go.mod h1:7EzWiDbS9PFd0hamHHVoCY4GrokSTPSL32MA4rzIu0M=
 k8s.io/cli-runtime v0.18.8/go.mod h1:7EzWiDbS9PFd0hamHHVoCY4GrokSTPSL32MA4rzIu0M=

+ 3 - 0
internal/forms/git_action.go

@@ -9,6 +9,7 @@ import (
 type CreateGitAction struct {
 type CreateGitAction struct {
 	ReleaseID      uint              `json:"release_id" form:"required"`
 	ReleaseID      uint              `json:"release_id" form:"required"`
 	GitRepo        string            `json:"git_repo" form:"required"`
 	GitRepo        string            `json:"git_repo" form:"required"`
+	GitBranch      string            `json:"git_branch"`
 	ImageRepoURI   string            `json:"image_repo_uri" form:"required"`
 	ImageRepoURI   string            `json:"image_repo_uri" form:"required"`
 	DockerfilePath string            `json:"dockerfile_path"`
 	DockerfilePath string            `json:"dockerfile_path"`
 	FolderPath     string            `json:"folder_path"`
 	FolderPath     string            `json:"folder_path"`
@@ -22,6 +23,7 @@ func (ca *CreateGitAction) ToGitActionConfig() (*models.GitActionConfig, error)
 	return &models.GitActionConfig{
 	return &models.GitActionConfig{
 		ReleaseID:      ca.ReleaseID,
 		ReleaseID:      ca.ReleaseID,
 		GitRepo:        ca.GitRepo,
 		GitRepo:        ca.GitRepo,
+		GitBranch:      ca.GitBranch,
 		ImageRepoURI:   ca.ImageRepoURI,
 		ImageRepoURI:   ca.ImageRepoURI,
 		DockerfilePath: ca.DockerfilePath,
 		DockerfilePath: ca.DockerfilePath,
 		FolderPath:     ca.FolderPath,
 		FolderPath:     ca.FolderPath,
@@ -31,6 +33,7 @@ func (ca *CreateGitAction) ToGitActionConfig() (*models.GitActionConfig, error)
 
 
 type CreateGitActionOptional struct {
 type CreateGitActionOptional struct {
 	GitRepo        string            `json:"git_repo"`
 	GitRepo        string            `json:"git_repo"`
+	GitBranch      string            `json:"git_branch"`
 	ImageRepoURI   string            `json:"image_repo_uri"`
 	ImageRepoURI   string            `json:"image_repo_uri"`
 	DockerfilePath string            `json:"dockerfile_path"`
 	DockerfilePath string            `json:"dockerfile_path"`
 	FolderPath     string            `json:"folder_path"`
 	FolderPath     string            `json:"folder_path"`

+ 6 - 0
internal/forms/k8s.go

@@ -37,3 +37,9 @@ func (kf *K8sForm) PopulateK8sOptionsFromQueryParams(
 
 
 	return nil
 	return nil
 }
 }
+
+type ConfigMapForm struct {
+	Name string `json:"name" form:"required"`
+	Namespace string `json:"namespace" form:"required"`
+	EnvVariables map[string]string `json:"variables"`
+}

+ 8 - 1
internal/integrations/ci/actions/actions.go

@@ -30,6 +30,7 @@ type GithubActions struct {
 	ProjectID    uint
 	ProjectID    uint
 	ReleaseName  string
 	ReleaseName  string
 
 
+	GitBranch      string
 	DockerFilePath string
 	DockerFilePath string
 	FolderPath     string
 	FolderPath     string
 	ImageRepoURL   string
 	ImageRepoURL   string
@@ -167,11 +168,17 @@ func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
 
 
 	gaSteps = append(gaSteps, deployPorterWebhookStep(g.getWebhookSecretName(), g.ImageRepoURL))
 	gaSteps = append(gaSteps, deployPorterWebhookStep(g.getWebhookSecretName(), g.ImageRepoURL))
 
 
+	branch := g.GitBranch
+
+	if branch == "" {
+		branch = g.defaultBranch
+	}
+
 	actionYAML := &GithubActionYAML{
 	actionYAML := &GithubActionYAML{
 		On: GithubActionYAMLOnPush{
 		On: GithubActionYAMLOnPush{
 			Push: GithubActionYAMLOnPushBranches{
 			Push: GithubActionYAMLOnPushBranches{
 				Branches: []string{
 				Branches: []string{
-					g.defaultBranch,
+					branch,
 				},
 				},
 			},
 			},
 		},
 		},

+ 2 - 1
internal/integrations/ci/actions/steps.go

@@ -45,7 +45,8 @@ func getConfigurePorterStep(porterTokenSecretName string) GithubActionYAMLStep {
 
 
 const dockerBuildPush string = `
 const dockerBuildPush string = `
 export $(echo "${{secrets.%s}}" | xargs)
 export $(echo "${{secrets.%s}}" | xargs)
-sudo docker build %s --file %s -t %s:$(git rev-parse --short HEAD)
+echo "${{secrets.%s}}" > ./env_porter
+sudo docker build %s $(cat ./env_porter | 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)
 sudo docker push %s:$(git rev-parse --short HEAD)
 `
 `
 
 

+ 64 - 0
internal/kubernetes/agent.go

@@ -58,6 +58,70 @@ type ListOptions struct {
 	FieldSelector string
 	FieldSelector string
 }
 }
 
 
+// CreateConfigMap creates the configmap given the key-value pairs and namespace
+func (a *Agent) CreateConfigMap(name string, namespace string, configMap map[string]string) (*v1.ConfigMap, error) {
+	return a.Clientset.CoreV1().ConfigMaps(namespace).Create(
+		context.TODO(),
+		&v1.ConfigMap{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: name,
+				Namespace: namespace,
+				Labels: map[string]string{
+					"porter": "true",
+				},
+			},
+			Data: configMap,
+		},
+		metav1.CreateOptions{},
+	)
+}
+
+// UpdateConfigMap updates the configmap given its name and namespace
+func (a *Agent) UpdateConfigMap(name string, namespace string, configMap map[string]string) (*v1.ConfigMap, error) {
+	return a.Clientset.CoreV1().ConfigMaps(namespace).Update(
+		context.TODO(),
+		&v1.ConfigMap{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: name,
+				Namespace: namespace,
+				Labels: map[string]string{
+					"porter": "true",
+				},
+			},
+			Data: configMap,
+		},
+		metav1.UpdateOptions{},
+	)
+}
+
+// DeleteConfigMap deletes the configmap given its name and namespace
+func (a *Agent) DeleteConfigMap(name string, namespace string) (error) {
+	return a.Clientset.CoreV1().ConfigMaps(namespace).Delete(
+		context.TODO(),
+		name,
+		metav1.DeleteOptions{},
+	)
+}
+
+// GetConfigMap retrieves the configmap given its name and namespace
+func (a *Agent) GetConfigMap(name string, namespace string) (*v1.ConfigMap, error) {
+	return a.Clientset.CoreV1().ConfigMaps(namespace).Get(
+		context.TODO(),
+		name,
+		metav1.GetOptions{},
+	)
+}
+
+// ListConfigMaps simply lists namespaces
+func (a *Agent) ListConfigMaps(namespace string) (*v1.ConfigMapList, error) {
+	return a.Clientset.CoreV1().ConfigMaps(namespace).List(
+		context.TODO(),
+		metav1.ListOptions{
+			LabelSelector: "porter=true",
+		},
+	)
+}
+
 // ListNamespaces simply lists namespaces
 // ListNamespaces simply lists namespaces
 func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
 func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
 	return a.Clientset.CoreV1().Namespaces().List(
 	return a.Clientset.CoreV1().Namespaces().List(

+ 2 - 2
internal/kubernetes/config.go

@@ -181,7 +181,7 @@ func (conf *OutOfClusterConfig) GetClientConfigFromCluster() (clientcmd.ClientCo
 		return clientcmd.NewClientConfigFromBytes(kubeAuth.Kubeconfig)
 		return clientcmd.NewClientConfigFromBytes(kubeAuth.Kubeconfig)
 	}
 	}
 
 
-	apiConfig, err := conf.createRawConfigFromCluster()
+	apiConfig, err := conf.CreateRawConfigFromCluster()
 
 
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
@@ -200,7 +200,7 @@ func (conf *OutOfClusterConfig) GetClientConfigFromCluster() (clientcmd.ClientCo
 	return config, nil
 	return config, nil
 }
 }
 
 
-func (conf *OutOfClusterConfig) createRawConfigFromCluster() (*api.Config, error) {
+func (conf *OutOfClusterConfig) CreateRawConfigFromCluster() (*api.Config, error) {
 	cluster := conf.Cluster
 	cluster := conf.Cluster
 
 
 	apiConfig := &api.Config{}
 	apiConfig := &api.Config{}

+ 3 - 0
internal/models/gitrepo.go

@@ -55,6 +55,9 @@ type GitActionConfig struct {
 	// The git repo in ${owner}/${repo} form
 	// The git repo in ${owner}/${repo} form
 	GitRepo string `json:"git_repo"`
 	GitRepo string `json:"git_repo"`
 
 
+	// The git branch to use
+	GitBranch string `json:"git_branch"`
+
 	// The complete image repository uri to pull from
 	// The complete image repository uri to pull from
 	ImageRepoURI string `json:"image_repo_uri"`
 	ImageRepoURI string `json:"image_repo_uri"`
 
 

+ 16 - 3
internal/registry/registry.go

@@ -10,6 +10,7 @@ import (
 	"strings"
 	"strings"
 	"time"
 	"time"
 
 
+	"github.com/aws/aws-sdk-go/aws/awserr"
 	"github.com/aws/aws-sdk-go/service/ecr"
 	"github.com/aws/aws-sdk-go/service/ecr"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/oauth"
@@ -408,11 +409,23 @@ func (r *Registry) createECRRepository(
 
 
 	svc := ecr.New(sess)
 	svc := ecr.New(sess)
 
 
-	_, err = svc.CreateRepository(&ecr.CreateRepositoryInput{
-		RepositoryName: &name,
+	// determine if repository already exists
+	_, err = svc.DescribeRepositories(&ecr.DescribeRepositoriesInput{
+		RepositoryNames: []*string{&name},
 	})
 	})
 
 
-	return err
+	// if the repository was not found, create it
+	if aerr, ok := err.(awserr.Error); ok && aerr.Code() == ecr.ErrCodeRepositoryNotFoundException {
+		_, err = svc.CreateRepository(&ecr.CreateRepositoryInput{
+			RepositoryName: &name,
+		})
+
+		return err
+	} else if err != nil {
+		return err
+	}
+
+	return nil
 }
 }
 
 
 // ListImages lists the images for an image repository
 // ListImages lists the images for an image repository

+ 6 - 1
internal/templater/utils/values.go

@@ -24,8 +24,13 @@ func MergeYAML(base, override []byte) (map[string]interface{}, error) {
 
 
 // CoalesceValues replaces arrays and scalar values, merges maps
 // CoalesceValues replaces arrays and scalar values, merges maps
 func CoalesceValues(base, override map[string]interface{}) map[string]interface{} {
 func CoalesceValues(base, override map[string]interface{}) map[string]interface{} {
-	for key, val := range base {
+	if base == nil && override != nil {
+		return override
+	} else if override == nil {
+		return base
+	}
 
 
+	for key, val := range base {
 		if oVal, ok := override[key]; ok {
 		if oVal, ok := override[key]; ok {
 			if oVal == nil {
 			if oVal == nil {
 				delete(override, key)
 				delete(override, key)

+ 1 - 0
server/api/deploy_handler.go

@@ -257,6 +257,7 @@ func (app *App) HandleUninstallTemplate(w http.ResponseWriter, r *http.Request)
 				WebhookToken:   release.WebhookToken,
 				WebhookToken:   release.WebhookToken,
 				ProjectID:      uint(projID),
 				ProjectID:      uint(projID),
 				ReleaseName:    name,
 				ReleaseName:    name,
+				GitBranch:      gitAction.GitBranch,
 				DockerFilePath: gitAction.DockerfilePath,
 				DockerFilePath: gitAction.DockerfilePath,
 				FolderPath:     gitAction.FolderPath,
 				FolderPath:     gitAction.FolderPath,
 				ImageRepoURL:   gitAction.ImageRepoURI,
 				ImageRepoURL:   gitAction.ImageRepoURI,

+ 3 - 0
server/api/git_action_handler.go

@@ -151,6 +151,8 @@ func (app *App) createGitActionFromForm(
 		return nil
 		return nil
 	}
 	}
 
 
+	fmt.Println("GIT ACTIONB BRANCH IS", gitAction.GitBranch)
+
 	// create the commit in the git repo
 	// create the commit in the git repo
 	gaRunner := &actions.GithubActions{
 	gaRunner := &actions.GithubActions{
 		GitIntegration: gr,
 		GitIntegration: gr,
@@ -161,6 +163,7 @@ func (app *App) createGitActionFromForm(
 		WebhookToken:   release.WebhookToken,
 		WebhookToken:   release.WebhookToken,
 		ProjectID:      uint(projID),
 		ProjectID:      uint(projID),
 		ReleaseName:    name,
 		ReleaseName:    name,
+		GitBranch:      gitAction.GitBranch,
 		DockerFilePath: gitAction.DockerfilePath,
 		DockerFilePath: gitAction.DockerfilePath,
 		FolderPath:     gitAction.FolderPath,
 		FolderPath:     gitAction.FolderPath,
 		ImageRepoURL:   gitAction.ImageRepoURI,
 		ImageRepoURL:   gitAction.ImageRepoURI,

+ 301 - 0
server/api/k8s_handler.go

@@ -13,12 +13,14 @@ import (
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
 	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
 	v1 "k8s.io/api/core/v1"
 	v1 "k8s.io/api/core/v1"
+	"k8s.io/client-go/tools/clientcmd"
 )
 )
 
 
 // Enumeration of k8s API error codes, represented as int64
 // Enumeration of k8s API error codes, represented as int64
 const (
 const (
 	ErrK8sDecode ErrorCode = iota + 600
 	ErrK8sDecode ErrorCode = iota + 600
 	ErrK8sValidate
 	ErrK8sValidate
+	ErrEnvDecode
 )
 )
 
 
 var upgrader = websocket.Upgrader{
 var upgrader = websocket.Upgrader{
@@ -73,6 +75,264 @@ func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 }
 }
 
 
+// HandleCreateConfigMap deletes the pod given the name and namespace.
+func (app *App) HandleCreateConfigMap(w http.ResponseWriter, r *http.Request) {
+	fmt.Println("CREATING CONFGIMAP")
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	configMap := &forms.ConfigMapForm{}
+
+	if err := json.NewDecoder(r.Body).Decode(configMap); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	_, err = agent.CreateConfigMap(configMap.Name, configMap.Namespace, configMap.EnvVariables)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(configMap); err != nil {
+		app.handleErrorFormDecoding(err, ErrEnvDecode, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+// HandleListConfigMaps lists all configmaps in a namespace.
+func (app *App) HandleListConfigMaps(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	configMaps, err := agent.ListConfigMaps(vals["namespace"][0])
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(configMaps); err != nil {
+		app.handleErrorFormDecoding(err, ErrEnvDecode, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+// HandleGetConfigMap retreives the configmap given the name and namespace.
+func (app *App) HandleGetConfigMap(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	configMap, err := agent.GetConfigMap(vals["name"][0], vals["namespace"][0])
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(configMap); err != nil {
+		app.handleErrorFormDecoding(err, ErrEnvDecode, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+// HandleDeleteConfigMap deletes the pod given the name and namespace.
+func (app *App) HandleDeleteConfigMap(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	err = agent.DeleteConfigMap(vals["name"][0], vals["namespace"][0])
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+// HandleUpdateConfigMap deletes the pod given the name and namespace.
+func (app *App) HandleUpdateConfigMap(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	configMap := &forms.ConfigMapForm{}
+
+	if err := json.NewDecoder(r.Body).Decode(configMap); err != nil {
+		app.handleErrorFormDecoding(err, ErrEnvDecode, w)
+		return
+	}
+
+	_, err = agent.UpdateConfigMap(configMap.Name, configMap.Namespace, configMap.EnvVariables)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(configMap); err != nil {
+		app.handleErrorFormDecoding(err, ErrEnvDecode, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
 // HandleGetPodLogs returns real-time logs of the pod via websockets
 // HandleGetPodLogs returns real-time logs of the pod via websockets
 // TODO: Refactor repeated calls.
 // TODO: Refactor repeated calls.
 func (app *App) HandleGetPodLogs(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleGetPodLogs(w http.ResponseWriter, r *http.Request) {
@@ -644,3 +904,44 @@ func (app *App) HandleGetPodMetrics(w http.ResponseWriter, r *http.Request) {
 
 
 	fmt.Fprint(w, string(rawQuery))
 	fmt.Fprint(w, string(rawQuery))
 }
 }
+
+type KubeconfigResponse struct {
+	Kubeconfig []byte `json:"kubeconfig"`
+}
+
+func (app *App) HandleGetTemporaryKubeconfig(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// get the API config
+	apiConf, err := form.OutOfClusterConfig.CreateRawConfigFromCluster()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	bytes, err := clientcmd.Write(*apiConf)
+	res := &KubeconfigResponse{
+		Kubeconfig: bytes,
+	}
+
+	if err := json.NewEncoder(w).Encode(res); err != nil {
+		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
+		return
+	}
+}

+ 155 - 1
server/api/release_handler.go

@@ -12,6 +12,8 @@ import (
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/templater/parser"
 	"github.com/porter-dev/porter/internal/templater/parser"
 	"helm.sh/helm/v3/pkg/release"
 	"helm.sh/helm/v3/pkg/release"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 
 
 	"github.com/go-chi/chi"
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/forms"
@@ -395,6 +397,156 @@ func (app *App) HandleGetReleaseControllers(w http.ResponseWriter, r *http.Reque
 	}
 	}
 }
 }
 
 
+// HandleGetReleaseAllPods retrieves all pods that are associated with a given release.
+func (app *App) HandleGetReleaseAllPods(w http.ResponseWriter, r *http.Request) {
+	name := chi.URLParam(r, "name")
+	revision, err := strconv.ParseUint(chi.URLParam(r, "revision"), 0, 64)
+
+	form := &forms.GetReleaseForm{
+		ReleaseForm: &forms.ReleaseForm{
+			Form: &helm.Form{
+				Repo:              app.Repo,
+				DigitalOceanOAuth: app.DOConf,
+			},
+		},
+		Name:     name,
+		Revision: int(revision),
+	}
+
+	agent, err := app.getAgentFromQueryParams(
+		w,
+		r,
+		form.ReleaseForm,
+		form.ReleaseForm.PopulateHelmOptionsFromQueryParams,
+	)
+
+	// errors are handled in app.getAgentFromQueryParams
+	if err != nil {
+		return
+	}
+
+	release, err := agent.GetRelease(form.Name, form.Revision)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusNotFound, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"release not found"},
+		}, w)
+
+		return
+	}
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	k8sForm := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	k8sForm.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+	k8sForm.DefaultNamespace = form.ReleaseForm.Namespace
+
+	// validate the form
+	if err := app.validator.Struct(k8sForm); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new kubernetes agent
+	var k8sAgent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		k8sAgent = app.TestAgents.K8sAgent
+	} else {
+		k8sAgent, err = kubernetes.GetAgentOutOfClusterConfig(k8sForm.OutOfClusterConfig)
+	}
+
+	yamlArr := grapher.ImportMultiDocYAML([]byte(release.Manifest))
+	controllers := grapher.ParseControllers(yamlArr)
+	pods := make([]v1.Pod, 0)
+
+	// get current status of each controller
+	for _, c := range controllers {
+		var selector *metav1.LabelSelector
+
+		switch c.Kind {
+		case "Deployment":
+			rc, err := k8sAgent.GetDeployment(c)
+
+			if err != nil {
+				app.handleErrorDataRead(err, w)
+				return
+			}
+
+			selector = rc.Spec.Selector
+		case "StatefulSet":
+			rc, err := k8sAgent.GetStatefulSet(c)
+
+			if err != nil {
+				app.handleErrorDataRead(err, w)
+				return
+			}
+
+			selector = rc.Spec.Selector
+		case "DaemonSet":
+			rc, err := k8sAgent.GetDaemonSet(c)
+
+			if err != nil {
+				app.handleErrorDataRead(err, w)
+				return
+			}
+
+			selector = rc.Spec.Selector
+		case "ReplicaSet":
+			rc, err := k8sAgent.GetReplicaSet(c)
+
+			if err != nil {
+				app.handleErrorDataRead(err, w)
+				return
+			}
+
+			selector = rc.Spec.Selector
+		case "CronJob":
+			rc, err := k8sAgent.GetCronJob(c)
+
+			if err != nil {
+				app.handleErrorDataRead(err, w)
+				return
+			}
+
+			selector = rc.Spec.JobTemplate.Spec.Selector
+		}
+
+		selectors := make([]string, 0)
+
+		for key, val := range selector.MatchLabels {
+			selectors = append(selectors, key+"="+val)
+		}
+
+		podList, err := k8sAgent.GetPodsByLabel(strings.Join(selectors, ","))
+
+		if err != nil {
+			app.handleErrorDataRead(err, w)
+			return
+		}
+
+		pods = append(pods, podList.Items...)
+	}
+
+	if err := json.NewEncoder(w).Encode(pods); err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+}
+
 // HandleListReleaseHistory retrieves a history of releases based on a release name
 // HandleListReleaseHistory retrieves a history of releases based on a release name
 func (app *App) HandleListReleaseHistory(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleListReleaseHistory(w http.ResponseWriter, r *http.Request) {
 	name := chi.URLParam(r, "name")
 	name := chi.URLParam(r, "name")
@@ -593,6 +745,7 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 				WebhookToken:   release.WebhookToken,
 				WebhookToken:   release.WebhookToken,
 				ProjectID:      uint(projID),
 				ProjectID:      uint(projID),
 				ReleaseName:    name,
 				ReleaseName:    name,
+				GitBranch:      gitAction.GitBranch,
 				DockerFilePath: gitAction.DockerfilePath,
 				DockerFilePath: gitAction.DockerfilePath,
 				FolderPath:     gitAction.FolderPath,
 				FolderPath:     gitAction.FolderPath,
 				ImageRepoURL:   gitAction.ImageRepoURI,
 				ImageRepoURL:   gitAction.ImageRepoURI,
@@ -684,7 +837,7 @@ func (app *App) HandleReleaseDeployWebhook(w http.ResponseWriter, r *http.Reques
 		}, w)
 		}, w)
 
 
 		return
 		return
-	} 
+	}
 
 
 	registries, err := app.Repo.Registry.ListRegistriesByProjectID(uint(form.ReleaseForm.Cluster.ProjectID))
 	registries, err := app.Repo.Registry.ListRegistriesByProjectID(uint(form.ReleaseForm.Cluster.ProjectID))
 
 
@@ -843,6 +996,7 @@ func (app *App) HandleRollbackRelease(w http.ResponseWriter, r *http.Request) {
 				WebhookToken:   release.WebhookToken,
 				WebhookToken:   release.WebhookToken,
 				ProjectID:      uint(projID),
 				ProjectID:      uint(projID),
 				ReleaseName:    name,
 				ReleaseName:    name,
+				GitBranch:      gitAction.GitBranch,
 				DockerFilePath: gitAction.DockerfilePath,
 				DockerFilePath: gitAction.DockerfilePath,
 				FolderPath:     gitAction.FolderPath,
 				FolderPath:     gitAction.FolderPath,
 				ImageRepoURL:   gitAction.ImageRepoURI,
 				ImageRepoURL:   gitAction.ImageRepoURI,

+ 98 - 0
server/router/router.go

@@ -896,6 +896,20 @@ func New(a *api.App) *chi.Mux {
 			),
 			),
 		)
 		)
 
 
+		r.Method(
+			"GET",
+			"/projects/{project_id}/releases/{name}/{revision}/pods/all",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleGetReleaseAllPods, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		r.Method(
 		r.Method(
 			"GET",
 			"GET",
 			"/projects/{project_id}/releases/{name}/history",
 			"/projects/{project_id}/releases/{name}/history",
@@ -1084,6 +1098,20 @@ func New(a *api.App) *chi.Mux {
 			),
 			),
 		)
 		)
 
 
+		r.Method(
+			"GET",
+			"/projects/{project_id}/k8s/kubeconfig",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleGetTemporaryKubeconfig, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
 		r.Method(
 		r.Method(
 			"GET",
 			"GET",
 			"/projects/{project_id}/k8s/prometheus/detect",
 			"/projects/{project_id}/k8s/prometheus/detect",
@@ -1224,6 +1252,76 @@ func New(a *api.App) *chi.Mux {
 			),
 			),
 		)
 		)
 
 
+		r.Method(
+			"POST",
+			"/projects/{project_id}/k8s/configmap/create",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleCreateConfigMap, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"DELETE",
+			"/projects/{project_id}/k8s/configmap/delete",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleDeleteConfigMap, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/k8s/configmap",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleGetConfigMap, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/k8s/configmap/list",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleListConfigMaps, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"POST",
+			"/projects/{project_id}/k8s/configmap/update",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleUpdateConfigMap, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		// /api/projects/{project_id}/subdomain routes
 		// /api/projects/{project_id}/subdomain routes
 		r.Method(
 		r.Method(
 			"POST",
 			"POST",