Explorar el Código

Merge branch '0.4.0-cli-deployments' of https://github.com/porter-dev/porter into 0.4.0-cli-deployments

merge with remote
Alexander Belanger hace 4 años
padre
commit
3ec4bfc586
Se han modificado 37 ficheros con 3208 adiciones y 1858 borrados
  1. 8 7
      dashboard/react-table.d.ts
  2. BIN
      dashboard/src/assets/trash.png
  3. 1 1
      dashboard/src/components/Selector.tsx
  4. 10 12
      dashboard/src/components/Table.tsx
  5. 10 0
      dashboard/src/main/home/Home.tsx
  6. 406 395
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  7. 6 1
      dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx
  8. 152 170
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  9. 125 34
      dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx
  10. 20 16
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  11. 387 0
      dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx
  12. 7 3
      dashboard/src/main/home/cluster-dashboard/dashboard/NodeStatusModal.tsx
  13. 7 2
      dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx
  14. 28 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  15. 65 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx
  16. 51 25
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx
  17. 105 22
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  18. 2 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  19. 7 2
      dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx
  20. 7 2
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  21. 200 0
      dashboard/src/main/home/modals/DeleteNamespaceModal.tsx
  22. 36 27
      dashboard/src/main/home/modals/NamespaceModal.tsx
  23. 26 2
      dashboard/src/shared/api.tsx
  24. 12 0
      dashboard/src/shared/routing.tsx
  25. 1 0
      dashboard/src/shared/types.tsx
  26. 225 0
      docs/deploy/applications/deploying-from-the-cli.md
  27. 0 132
      docs/reference/cli-porter-deploy.md
  28. 9 0
      internal/forms/integration.go
  29. 1002 982
      internal/kubernetes/agent.go
  30. 10 6
      internal/models/cluster.go
  31. 17 0
      internal/repository/gorm/auth.go
  32. 51 0
      internal/repository/gorm/auth_test.go
  33. 1 0
      internal/repository/integrations.go
  34. 17 0
      internal/repository/memory/auth.go
  35. 101 0
      server/api/integration_handler.go
  36. 63 11
      server/api/k8s_handler.go
  37. 33 0
      server/router/router.go

+ 8 - 7
dashboard/react-table.d.ts

@@ -45,14 +45,15 @@ import {
   UseSortByHooks,
   UseSortByInstanceProps,
   UseSortByOptions,
-  UseSortByState
-} from 'react-table'
+  UseSortByState,
+} from "react-table";
 
-declare module 'react-table' {
+declare module "react-table" {
   // take this file as-is, or comment out the sections that don't apply to your plugin configuration
-  
-  export interface TableOptions<D extends object = {}>
-    extends UseExpandedOptions<D>,
+
+  export interface TableOptions<
+    D extends object = {}
+  > extends UseExpandedOptions<D>,
       UseFiltersOptions<D>,
       UseGlobalFiltersOptions<D>,
       UseGroupByOptions<D>,
@@ -117,4 +118,4 @@ declare module 'react-table' {
       UseGroupByRowProps<D>,
       UseRowSelectRowProps<D>,
       UseRowStateRowProps<D> {}
-}
+}

BIN
dashboard/src/assets/trash.png


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

@@ -84,7 +84,7 @@ export default class Selector extends Component<PropsType, StateType> {
       return (
         <NewOption
           onClick={() => {
-            this.context.setCurrentModal("NamespaceModal");
+            this.context.setCurrentModal("NamespaceModal", this.props.options);
           }}
         >
           <Plus>+</Plus>

+ 10 - 12
dashboard/src/components/Table.tsx

@@ -7,7 +7,7 @@ import Loading from "components/Loading";
 const GlobalFilter: React.FunctionComponent<any> = ({ setGlobalFilter }) => {
   const [value, setValue] = React.useState("");
   const onChange = (value: string) => {
-    setValue(value)
+    setValue(value);
     setGlobalFilter(value || undefined);
   };
 
@@ -99,7 +99,9 @@ const Table: React.FC<TableProps> = ({
 
   return (
     <TableWrapper>
-      {!disableGlobalFilter && <GlobalFilter setGlobalFilter={setGlobalFilter} />}
+      {!disableGlobalFilter && (
+        <GlobalFilter setGlobalFilter={setGlobalFilter} />
+      )}
       <StyledTable {...getTableProps()}>
         <StyledTHead>
           {headerGroups.map((headerGroup) => (
@@ -140,10 +142,10 @@ export const StyledTr = styled.tr`
 export const StyledTd = styled.td`
   font-size: 13px;
   color: #ffffff;
-  :first-child{
+  :first-child {
     padding-left: 10px;
   }
-  :last-child{
+  :last-child {
     padding-right: 10px;
   }
 `;
@@ -157,10 +159,10 @@ export const StyledTh = styled.th`
   font-size: 13px;
   font-weight: 500;
   color: #aaaabb;
-  :first-child{
+  :first-child {
     padding-left: 10px;
   }
-  :last-child{
+  :last-child {
     padding-right: 10px;
   }
 `;
@@ -171,7 +173,6 @@ export const StyledTable = styled.table`
   border-collapse: collapse;
 `;
 
-
 const SearchInput = styled.input`
   outline: none;
   border: none;
@@ -188,12 +189,12 @@ const SearchRow = styled.div`
   width: 100%;
   font-size: 13px;
   color: #ffffff55;
-  border-radius: 4px; 
+  border-radius: 4px;
   user-select: none;
   align-items: center;
   padding: 10px 0px;
   min-width: 300px;
-  max-width: min-content;  
+  max-width: min-content;
   background: #ffffff11;
   margin-bottom: 7px;
   margin-top: 7px;
@@ -204,7 +205,4 @@ const SearchRow = styled.div`
     margin-right: 12px;
     font-size: 20px;
   }
-
 `;
-
-

+ 10 - 0
dashboard/src/main/home/Home.tsx

@@ -25,6 +25,7 @@ import NewProject from "./new-project/NewProject";
 import ProjectSettings from "./project-settings/ProjectSettings";
 import Sidebar from "./sidebar/Sidebar";
 import PageNotFound from "components/PageNotFound";
+import DeleteNamespaceModal from "./modals/DeleteNamespaceModal";
 
 type PropsType = RouteComponentProps & {
   logOut: () => void;
@@ -510,6 +511,15 @@ class Home extends Component<PropsType, StateType> {
             <NamespaceModal />
           </Modal>
         )}
+        {currentModal === "DeleteNamespaceModal" && (
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width="700px"
+            height="280px"
+          >
+            <DeleteNamespaceModal />
+          </Modal>
+        )}
 
         {this.renderSidebar()}
 

+ 406 - 395
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -1,395 +1,406 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-import monojob from "assets/monojob.png";
-import monoweb from "assets/monoweb.png";
-import { Switch, Route } from "react-router-dom";
-
-import { Context } from "shared/Context";
-import { ChartType, ClusterType } from "shared/types";
-import { PorterUrl, pushFiltered, pushQueryParams } from "shared/routing";
-
-import ChartList from "./chart/ChartList";
-import EnvGroupDashboard from "./env-groups/EnvGroupDashboard";
-import NamespaceSelector from "./NamespaceSelector";
-import SortSelector from "./SortSelector";
-import ExpandedChart from "./expanded-chart/ExpandedChart";
-import ExpandedChartWrapper from "./expanded-chart/ExpandedChartWrapper";
-import { RouteComponentProps, withRouter } from "react-router";
-
-import api from "shared/api";
-import {Dashboard} from "./dashboard/Dashboard";
-
-type PropsType = RouteComponentProps & {
-  currentCluster: ClusterType;
-  setSidebar: (x: boolean) => void;
-  currentView: PorterUrl;
-};
-
-type StateType = {
-  namespace: string;
-  sortType: string;
-  currentChart: ChartType | null;
-  isMetricsInstalled: boolean;
-};
-
-// TODO: should try to maintain single source of truth b/w router and context/state (ex: namespace -> being managed in parallel right now so highly inextensible and routing is fragile)
-class ClusterDashboard extends Component<PropsType, StateType> {
-  state = {
-    namespace: null as string,
-    sortType: localStorage.getItem("SortType")
-      ? localStorage.getItem("SortType")
-      : "Newest",
-    currentChart: null as ChartType | null,
-    isMetricsInstalled: false,
-  };
-
-  componentDidMount() {
-    let { currentCluster, currentProject } = this.context;
-    let params = this.props.match.params as any;
-    let pathClusterName = params.cluster;
-    // Don't add cluster as query param if present in path
-    if (!pathClusterName) {
-      pushQueryParams(this.props, { cluster: currentCluster.name });
-    }
-    api
-      .getPrometheusIsInstalled(
-        "<token>",
-        {
-          cluster_id: currentCluster.id,
-        },
-        {
-          id: currentProject.id,
-        }
-      )
-      .then((res) => {
-        this.setState({ isMetricsInstalled: true });
-      })
-      .catch(() => {
-        this.setState({ isMetricsInstalled: false });
-      });
-  }
-
-  componentDidUpdate(prevProps: PropsType) {
-    // Reset namespace filter and close expanded chart on cluster change
-    if (prevProps.currentCluster !== this.props.currentCluster) {
-      this.setState(
-        {
-          namespace: "default",
-          sortType: localStorage.getItem("SortType")
-            ? localStorage.getItem("SortType")
-            : "Newest",
-          currentChart: null,
-        },
-        () => pushQueryParams(this.props, { namespace: "default" })
-      );
-    }
-
-    if (prevProps.currentView !== this.props.currentView) {
-      this.setState(
-        {
-          sortType: "Newest",
-          currentChart: null,
-        },
-        () =>
-          pushQueryParams(this.props, {
-            namespace:
-              this.state.namespace === null ? "default" : this.state.namespace,
-          })
-      );
-    }
-  }
-
-  renderDashboardIcon = () => {
-    if (this.props.currentView === "jobs") {
-      return <Img src={monojob} />;
-    } else {
-      return <Img src={monoweb} />;
-    }
-  };
-
-  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, currentView } = this.props;
-    return (
-      <>
-        <ControlRow>
-          <Button
-            onClick={() => pushFiltered(this.props, "/launch", ["project_id"])}
-          >
-            <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 }, () => {
-                  pushQueryParams(this.props, {
-                    namespace: this.state.namespace || "ALL",
-                  });
-                })
-              }
-              namespace={this.state.namespace}
-            />
-          </SortFilterWrapper>
-        </ControlRow>
-
-        <ChartList
-          currentView={currentView}
-          currentCluster={currentCluster}
-          namespace={this.state.namespace}
-          sortType={this.state.sortType}
-        />
-      </>
-    );
-  };
-
-  renderContents = () => {
-    let { currentCluster, setSidebar, currentView } = this.props;
-    if (currentView === "env-groups") {
-      return <EnvGroupDashboard currentCluster={this.props.currentCluster} />;
-    }
-
-    return (
-      <>
-        <TitleSection>
-          {this.renderDashboardIcon()}
-          <Title>{currentView}</Title>
-        </TitleSection>
-
-        <InfoSection>
-          <TopRow>
-            <InfoLabel>
-              <i className="material-icons">info</i> Info
-            </InfoLabel>
-          </TopRow>
-          <Description>{this.getDescription(currentView)}</Description>
-        </InfoSection>
-
-        <LineBreak />
-
-        {this.renderBody()}
-      </>
-    );
-  };
-
-  render() {
-    let { setSidebar } = this.props;
-    return (
-      <Switch>
-        <Route path="/:baseRoute/:clusterName+/:namespace/:chartName">
-          <ExpandedChartWrapper
-            setSidebar={setSidebar}
-            isMetricsInstalled={this.state.isMetricsInstalled}
-          />
-        </Route>
-        <Route path={["/jobs", "/applications", "/env-groups"]}>
-          {this.renderContents()}
-        </Route>
-        <Route path={["/cluster-dashboard"]}>
-          <Dashboard />
-        </Route>
-      </Switch>
-    );
-  }
-}
-
-ClusterDashboard.contextType = Context;
-
-export default withRouter(ClusterDashboard);
-
-const ControlRow = styled.div`
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 35px;
-  padding-left: 0px;
-`;
-
-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 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;
-  }
-`;
-
-const ButtonAlt = styled(Button)`
-  min-width: 150px;
-  max-width: 150px;
-  background: #7a838fdd;
-
-  :hover {
-    background: #69727eee;
-  }
-`;
-
-const LineBreak = styled.div`
-  width: calc(100% - 0px);
-  height: 2px;
-  background: #ffffff20;
-  margin: 10px 0px 35px;
-`;
-
-const Overlay = styled.div`
-  height: 100%;
-  width: 100%;
-  position: absolute;
-  background: #00000028;
-  top: 0;
-  left: 0;
-  border-radius: 5px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  font-size: 24px;
-  font-weight: 500;
-  font-family: "Work Sans", sans-serif;
-  color: white;
-`;
-
-const DashboardImage = styled.img`
-  height: 45px;
-  width: 45px;
-  border-radius: 5px;
-`;
-
-const DashboardIcon = styled.div`
-  position: relative;
-  height: 45px;
-  min-width: 45px;
-  width: 45px;
-  border-radius: 5px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: #676c7c;
-  border: 2px solid #8e94aa;
-
-  > i {
-    font-size: 22px;
-  }
-`;
-
-const Img = styled.img`
-  width: 30px;
-`;
-
-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;
-  }
-`;
-
-const SortFilterWrapper = styled.div`
-  width: 468px;
-  display: flex;
-  justify-content: space-between;
-`;
+import React, { Component } from "react";
+import styled from "styled-components";
+import monojob from "assets/monojob.png";
+import monoweb from "assets/monoweb.png";
+import { Switch, Route } from "react-router-dom";
+
+import { Context } from "shared/Context";
+import { ChartType, ClusterType } from "shared/types";
+import {
+  getQueryParam,
+  PorterUrl,
+  pushFiltered,
+  pushQueryParams,
+} from "shared/routing";
+
+import ChartList from "./chart/ChartList";
+import EnvGroupDashboard from "./env-groups/EnvGroupDashboard";
+import NamespaceSelector from "./NamespaceSelector";
+import SortSelector from "./SortSelector";
+import ExpandedChart from "./expanded-chart/ExpandedChart";
+import ExpandedChartWrapper from "./expanded-chart/ExpandedChartWrapper";
+import { RouteComponentProps, withRouter } from "react-router";
+
+import api from "shared/api";
+import { Dashboard } from "./dashboard/Dashboard";
+
+type PropsType = RouteComponentProps & {
+  currentCluster: ClusterType;
+  setSidebar: (x: boolean) => void;
+  currentView: PorterUrl;
+};
+
+type StateType = {
+  namespace: string;
+  sortType: string;
+  currentChart: ChartType | null;
+  isMetricsInstalled: boolean;
+};
+
+// TODO: should try to maintain single source of truth b/w router and context/state (ex: namespace -> being managed in parallel right now so highly inextensible and routing is fragile)
+class ClusterDashboard extends Component<PropsType, StateType> {
+  state = {
+    namespace: null as string,
+    sortType: localStorage.getItem("SortType")
+      ? localStorage.getItem("SortType")
+      : "Newest",
+    currentChart: null as ChartType | null,
+    isMetricsInstalled: false,
+  };
+
+  componentDidMount() {
+    let { currentCluster, currentProject } = this.context;
+    let params = this.props.match.params as any;
+    let pathClusterName = params.cluster;
+    // Don't add cluster as query param if present in path
+    if (!pathClusterName) {
+      pushQueryParams(this.props, { cluster: currentCluster.name });
+    }
+    api
+      .getPrometheusIsInstalled(
+        "<token>",
+        {
+          cluster_id: currentCluster.id,
+        },
+        {
+          id: currentProject.id,
+        }
+      )
+      .then((res) => {
+        this.setState({ isMetricsInstalled: true });
+      })
+      .catch(() => {
+        this.setState({ isMetricsInstalled: false });
+      });
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    // Reset namespace filter and close expanded chart on cluster change
+    if (prevProps.currentCluster !== this.props.currentCluster) {
+      this.setState(
+        {
+          namespace: "default",
+          sortType: localStorage.getItem("SortType")
+            ? localStorage.getItem("SortType")
+            : "Newest",
+          currentChart: null,
+        },
+        () => pushQueryParams(this.props, { namespace: "default" })
+      );
+    }
+
+    if (prevProps.currentView !== this.props.currentView) {
+      let params = this.props.match.params as any;
+      let currentNamespace = params.namespace;
+      if (!currentNamespace) {
+        currentNamespace = getQueryParam(this.props, "namespace");
+      }
+      this.setState(
+        {
+          sortType: "Newest",
+          currentChart: null,
+          namespace: currentNamespace || "default",
+        },
+        () =>
+          pushQueryParams(this.props, {
+            namespace:
+              this.state.namespace === null ? "default" : this.state.namespace,
+          })
+      );
+    }
+  }
+
+  renderDashboardIcon = () => {
+    if (this.props.currentView === "jobs") {
+      return <Img src={monojob} />;
+    } else {
+      return <Img src={monoweb} />;
+    }
+  };
+
+  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, currentView } = this.props;
+    return (
+      <>
+        <ControlRow>
+          <Button
+            onClick={() => pushFiltered(this.props, "/launch", ["project_id"])}
+          >
+            <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 }, () => {
+                  pushQueryParams(this.props, {
+                    namespace: this.state.namespace || "ALL",
+                  });
+                })
+              }
+              namespace={this.state.namespace}
+            />
+          </SortFilterWrapper>
+        </ControlRow>
+
+        <ChartList
+          currentView={currentView}
+          currentCluster={currentCluster}
+          namespace={this.state.namespace}
+          sortType={this.state.sortType}
+        />
+      </>
+    );
+  };
+
+  renderContents = () => {
+    let { currentCluster, setSidebar, currentView } = this.props;
+    if (currentView === "env-groups") {
+      return <EnvGroupDashboard currentCluster={this.props.currentCluster} />;
+    }
+
+    return (
+      <>
+        <TitleSection>
+          {this.renderDashboardIcon()}
+          <Title>{currentView}</Title>
+        </TitleSection>
+
+        <InfoSection>
+          <TopRow>
+            <InfoLabel>
+              <i className="material-icons">info</i> Info
+            </InfoLabel>
+          </TopRow>
+          <Description>{this.getDescription(currentView)}</Description>
+        </InfoSection>
+
+        <LineBreak />
+
+        {this.renderBody()}
+      </>
+    );
+  };
+
+  render() {
+    let { setSidebar } = this.props;
+    return (
+      <Switch>
+        <Route path="/:baseRoute/:clusterName+/:namespace/:chartName">
+          <ExpandedChartWrapper
+            setSidebar={setSidebar}
+            isMetricsInstalled={this.state.isMetricsInstalled}
+          />
+        </Route>
+        <Route path={["/jobs", "/applications", "/env-groups"]}>
+          {this.renderContents()}
+        </Route>
+        <Route path={["/cluster-dashboard"]}>
+          <Dashboard />
+        </Route>
+      </Switch>
+    );
+  }
+}
+
+ClusterDashboard.contextType = Context;
+
+export default withRouter(ClusterDashboard);
+
+const ControlRow = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+  padding-left: 0px;
+`;
+
+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 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;
+  }
+`;
+
+const ButtonAlt = styled(Button)`
+  min-width: 150px;
+  max-width: 150px;
+  background: #7a838fdd;
+
+  :hover {
+    background: #69727eee;
+  }
+`;
+
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 10px 0px 35px;
+`;
+
+const Overlay = styled.div`
+  height: 100%;
+  width: 100%;
+  position: absolute;
+  background: #00000028;
+  top: 0;
+  left: 0;
+  border-radius: 5px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 24px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+`;
+
+const DashboardImage = styled.img`
+  height: 45px;
+  width: 45px;
+  border-radius: 5px;
+`;
+
+const DashboardIcon = styled.div`
+  position: relative;
+  height: 45px;
+  min-width: 45px;
+  width: 45px;
+  border-radius: 5px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #676c7c;
+  border: 2px solid #8e94aa;
+
+  > i {
+    font-size: 22px;
+  }
+`;
+
+const Img = styled.img`
+  width: 30px;
+`;
+
+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;
+  }
+`;
+
+const SortFilterWrapper = styled.div`
+  width: 468px;
+  display: flex;
+  justify-content: space-between;
+`;

+ 6 - 1
dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx

@@ -49,7 +49,12 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
           }
 
           let defaultNamespace = "default";
-          res.data.items.forEach(
+          const availableNamespaces = res.data.items.filter(
+            (namespace: any) => {
+              return namespace.status.phase !== "Terminating";
+            }
+          );
+          availableNamespaces.forEach(
             (x: { metadata: { name: string } }, i: number) => {
               namespaceOptions.push({
                 label: x.metadata.name,

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

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 
 import { Context } from "shared/Context";
@@ -12,39 +12,37 @@ import Loading from "components/Loading";
 type PropsType = {
   currentCluster: ClusterType;
   namespace: string;
+  // TODO Convert to enum
   sortType: string;
   currentView: PorterUrl;
 };
 
-type StateType = {
-  charts: ChartType[];
-  chartLookupTable: Record<string, string>;
-  controllers: Record<string, Record<string, any>>;
-  loading: boolean;
-  error: boolean;
-  websockets: Record<string, any>;
-};
-
-export default class ChartList extends Component<PropsType, StateType> {
-  state = {
-    charts: [] as ChartType[],
-    chartLookupTable: {} as Record<string, string>,
-    controllers: {} as Record<string, Record<string, any>>,
-    loading: false,
-    error: false,
-    websockets: {} as Record<string, any>,
-  };
-
-  // TODO: promisify
-  updateCharts = (callback: Function) => {
-    let { currentCluster, currentProject, setCurrentError } = this.context;
-    this.setState({ loading: true });
-
-    api
-      .getCharts(
+const ChartList: React.FunctionComponent<PropsType> = ({
+  namespace,
+  sortType,
+  currentView,
+}) => {
+  const [charts, setCharts] = useState<ChartType[]>([]);
+  const [chartLookupTable, setChartLookupTable] = useState<
+    Record<string, string>
+  >({});
+  const [controllers, setControllers] = useState<
+    Record<string, Record<string, any>>
+  >({});
+  const [websockets, setWebsockets] = useState<WebSocket[]>([]);
+  const [isLoading, setIsLoading] = useState(false);
+  const [isError, setIsError] = useState(false);
+
+  const context = useContext(Context);
+
+  const updateCharts = async () => {
+    try {
+      const { currentCluster, currentProject } = context;
+      setIsLoading(true);
+      const res = await api.getCharts(
         "<token>",
         {
-          namespace: this.props.namespace,
+          namespace: namespace,
           cluster_id: currentCluster.id,
           storage: StorageType.Secret,
           limit: 50,
@@ -62,51 +60,50 @@ export default class ChartList extends Component<PropsType, StateType> {
           ],
         },
         { id: currentProject.id }
-      )
-      .then((res) => {
-        let charts = res.data || [];
-
-        // filter charts based on the current view
-        let { currentView } = this.props;
-
-        charts = charts.filter((chart: ChartType) => {
-          return (
-            (currentView == "jobs" && chart.chart.metadata.name == "job") ||
-            ((currentView == "applications" ||
-              currentView == "cluster-dashboard") &&
-              chart.chart.metadata.name != "job")
-          );
-        });
-
-        if (this.props.sortType == "Newest") {
-          charts.sort((a: any, b: any) =>
-            Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
-              ? -1
-              : 1
-          );
-        } else if (this.props.sortType == "Oldest") {
-          charts.sort((a: any, b: any) =>
-            Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
-              ? 1
-              : -1
-          );
-        } else if (this.props.sortType == "Alphabetical") {
-          charts.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
-        }
-        this.setState({ charts }, () => {
-          this.setState({ loading: false, error: false });
-        });
-        callback(charts);
-      })
-      .catch((err) => {
-        console.log(err);
-        setCurrentError(JSON.stringify(err));
-        this.setState({ loading: false, error: true });
+      );
+      const charts = res.data || [];
+
+      // filter charts based on the current view
+      const filteredCharts = charts.filter((chart: ChartType) => {
+        return (
+          (currentView == "jobs" && chart.chart.metadata.name == "job") ||
+          ((currentView == "applications" ||
+            currentView == "cluster-dashboard") &&
+            chart.chart.metadata.name != "job")
+        );
       });
+
+      let sortedCharts = filteredCharts;
+
+      if (sortType == "Newest") {
+        sortedCharts.sort((a: any, b: any) =>
+          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
+            ? -1
+            : 1
+        );
+      } else if (sortType == "Oldest") {
+        sortedCharts.sort((a: any, b: any) =>
+          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
+            ? 1
+            : -1
+        );
+      } else if (sortType == "Alphabetical") {
+        sortedCharts.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
+      }
+
+      setIsError(false);
+      return sortedCharts;
+    } catch (error) {
+      console.log(error);
+      context.setCurrentError(JSON.stringify(error));
+      setIsError(true);
+    } finally {
+      setIsLoading(false);
+    }
   };
 
-  setupWebsocket = (kind: string) => {
-    let { currentCluster, currentProject } = this.context;
+  const setupWebsocket = (kind: string) => {
+    let { currentCluster, currentProject } = context;
     let protocol = window.location.protocol == "https:" ? "wss" : "ws";
 
     let ws = new WebSocket(
@@ -120,22 +117,20 @@ export default class ChartList extends Component<PropsType, StateType> {
       let event = JSON.parse(evt.data);
       let object = event.Object;
       object.metadata.kind = event.Kind;
-      let chartKey = this.state.chartLookupTable[object.metadata.uid];
+      let chartKey = chartLookupTable[object.metadata.uid];
 
       // ignore if updated object does not belong to any chart in the list.
       if (!chartKey) {
         return;
       }
 
-      let chartControllers = this.state.controllers[chartKey];
+      let chartControllers = controllers[chartKey];
       chartControllers[object.metadata.uid] = object;
 
-      this.setState({
-        controllers: {
-          ...this.state.controllers,
-          [chartKey]: chartControllers,
-        },
-      });
+      setControllers((oldControllers) => ({
+        ...oldControllers,
+        [chartKey]: chartControllers,
+      }));
     };
 
     ws.onclose = () => {
@@ -150,114 +145,103 @@ export default class ChartList extends Component<PropsType, StateType> {
     return ws;
   };
 
-  setControllerWebsockets = (controllers: any[]) => {
+  const setControllerWebsockets = (controllers: any[]) => {
     let websockets = controllers.map((kind: string) => {
-      return this.setupWebsocket(kind);
+      return setupWebsocket(kind);
     });
-    this.setState({ websockets });
+    setWebsockets(websockets);
   };
 
-  getControllers = (charts: any[]) => {
-    let { currentCluster, currentProject, setCurrentError } = this.context;
+  const getControllerForChart = async (chart: ChartType) => {
+    try {
+      const { currentCluster, currentProject } = context;
+      const res = await api.getChartControllers(
+        "<token>",
+        {
+          namespace: chart.namespace,
+          cluster_id: currentCluster.id,
+          storage: StorageType.Secret,
+        },
+        {
+          id: currentProject.id,
+          name: chart.name,
+          revision: chart.version,
+        }
+      );
+
+      let chartControllers = {} as Record<string, Record<string, any>>;
+
+      res.data.forEach((c: any) => {
+        c.metadata.kind = c.kind;
+        chartControllers[c.metadata.uid] = c;
+      });
+
+      res.data.forEach(async (c: any) => {
+        setChartLookupTable((oldChartLookupTable) => ({
+          ...oldChartLookupTable,
+          [c.metadata.uid]: `${chart.namespace}-${chart.name}`,
+        }));
+        setControllers((oldControllers) => ({
+          ...oldControllers,
+          [`${chart.namespace}-${chart.name}`]: chartControllers,
+        }));
+      });
+    } catch (error) {
+      context.setCurrentError(JSON.stringify(error));
+    }
+  };
 
+  const getControllers = (charts: any[]) => {
     charts.forEach(async (chart: any) => {
       // don't retrieve controllers for chart that failed to even deploy.
       if (chart.info.status == "failed") return;
-
-      await new Promise((next: (res?: any) => void) => {
-        api
-          .getChartControllers(
-            "<token>",
-            {
-              namespace: chart.namespace,
-              cluster_id: currentCluster.id,
-              storage: StorageType.Secret,
-            },
-            {
-              id: currentProject.id,
-              name: chart.name,
-              revision: chart.version,
-            }
-          )
-          .then((res) => {
-            // transform controller array into hash table for easy lookup during updates.
-            let chartControllers = {} as Record<string, Record<string, any>>;
-            res.data.forEach((c: any) => {
-              c.metadata.kind = c.kind;
-              chartControllers[c.metadata.uid] = c;
-            });
-
-            res.data.forEach(async (c: any) => {
-              await new Promise((nextController: (res?: any) => void) => {
-                this.setState(
-                  {
-                    chartLookupTable: {
-                      ...this.state.chartLookupTable,
-                      [c.metadata.uid]: `${chart.namespace}-${chart.name}`,
-                    },
-                    controllers: {
-                      ...this.state.controllers,
-                      [`${chart.namespace}-${chart.name}`]: chartControllers,
-                    },
-                  },
-                  () => {
-                    nextController();
-                  }
-                );
-              });
-            });
-            next();
-          })
-          .catch((err) => {
-            setCurrentError(JSON.stringify(err));
-            return;
-          });
-      });
+      await getControllerForChart(chart);
     });
   };
 
-  componentDidMount() {
-    (this.props.namespace || this.props.namespace === "") &&
-      this.updateCharts(this.getControllers);
-    this.setControllerWebsockets([
+  // Setup basic websockets on start
+  useEffect(() => {
+    setControllerWebsockets([
       "deployment",
       "statefulset",
       "daemonset",
       "replicaset",
     ]);
-  }
+  }, []);
+
+  // Close Websockets on unmount
+  useEffect(() => {
+    return () => {
+      if (websockets.length) {
+        websockets.forEach((ws) => {
+          ws.close();
+        });
+      }
+    };
+  }, [websockets]);
 
-  componentWillUnmount() {
-    if (this.state.websockets) {
-      this.state.websockets.forEach((ws: WebSocket) => {
-        ws.close();
-      });
-    }
-  }
+  useEffect(() => {
+    let isSubscribed = true;
 
-  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 ||
-      prevProps.currentView !== this.props.currentView
-    ) {
-      (this.props.namespace || this.props.namespace === "") &&
-        this.updateCharts(this.getControllers);
+    if (namespace || namespace === "") {
+      updateCharts().then((charts) => {
+        if (isSubscribed) {
+          setCharts(charts);
+          getControllers(charts);
+        }
+      });
     }
-  }
-
-  renderChartList = () => {
-    let { loading, error, charts } = this.state;
+    return () => (isSubscribed = false);
+  }, [namespace]);
 
-    if (loading || (!this.props.namespace && this.props.namespace !== "")) {
+  const renderChartList = () => {
+    if (isLoading || (!namespace && namespace !== "")) {
       return (
         <LoadingWrapper>
           <Loading />
         </LoadingWrapper>
       );
-    } else if (error) {
+    } else if (isError) {
       return (
         <Placeholder>
           <i className="material-icons">error</i> Error connecting to cluster.
@@ -267,19 +251,19 @@ export default class ChartList extends Component<PropsType, StateType> {
       return (
         <Placeholder>
           <i className="material-icons">category</i> No
-          {this.props.currentView === "jobs" ? ` jobs` : ` charts`} found in
-          this namespace.
+          {currentView === "jobs" ? ` jobs` : ` charts`} found in this
+          namespace.
         </Placeholder>
       );
     }
 
-    return this.state.charts.map((chart: ChartType, i: number) => {
+    return charts.map((chart: ChartType, i: number) => {
       return (
         <Chart
           key={`${chart.namespace}-${chart.name}`}
           chart={chart}
           controllers={
-            this.state.controllers[`${chart.namespace}-${chart.name}`] ||
+            controllers[`${chart.namespace}-${chart.name}`] ||
             ({} as Record<string, any>)
           }
         />
@@ -287,12 +271,10 @@ export default class ChartList extends Component<PropsType, StateType> {
     });
   };
 
-  render() {
-    return <StyledChartList>{this.renderChartList()}</StyledChartList>;
-  }
-}
+  return <StyledChartList>{renderChartList()}</StyledChartList>;
+};
 
-ChartList.contextType = Context;
+export default ChartList;
 
 const Placeholder = styled.div`
   width: 100%;

+ 125 - 34
dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx

@@ -1,50 +1,141 @@
-import React, { useContext } from 'react'
-import styled from 'styled-components';
-import Heading from 'components/values-form/Heading';
+import React, { useContext, useState } from "react";
+import styled from "styled-components";
+import Heading from "components/values-form/Heading";
 import Helper from "components/values-form/Helper";
-import { Context } from 'shared/Context';
+import InputRow from "components/values-form/InputRow";
+import { Context } from "shared/Context";
+import api from "shared/api";
 
-export const ClusterSettings = () => {
+const ClusterSettings: React.FC = () => {
   const context = useContext(Context);
+  const [accessKeyId, setAccessKeyId] = useState<string>("");
+  const [secretKey, setSecretKey] = useState<string>("");
+  const [startRotateCreds, setStartRotateCreds] = useState<boolean>(false);
+  const [successfulRotate, setSuccessfulRotate] = useState<boolean>(false);
 
-  let helperText = <Helper>
-    Delete this cluster and underlying infrastructure. To
-    ensure that everything has been properly destroyed, please visit
-    your cloud provider's console. Instructions to properly delete all
-    resources can be found
-    <a
-      target="none"
-      href="https://docs.getporter.dev/docs/deleting-dangling-resources"
-    >
-      {" "}
-      here
-    </a>.
-  </Helper>
+  let rotateCredentials = () => {
+    api
+      .overwriteAWSIntegration(
+        "<token>",
+        {
+          aws_access_key_id: accessKeyId,
+          aws_secret_access_key: secretKey,
+        },
+        {
+          projectID: context.currentProject.id,
+          awsIntegrationID: context.currentCluster.aws_integration_id,
+          cluster_id: context.currentCluster.id,
+        }
+      )
+      .then(({ data }) => {
+        setSuccessfulRotate(true);
+      })
+      .catch(() => {
+        setSuccessfulRotate(false);
+      });
+  };
 
-  if (!context.currentCluster?.infra_id || !context.currentCluster?.service) {
-    helperText = <Helper>
-      Remove this cluster from Porter. Since this cluster was not provisioned by Porter, deleting the
-      cluster will only detach this cluster from your project. To delete the cluster itself, you must 
-      do so manually. This operation cannot be undone. 
+  let helperText = (
+    <Helper>
+      Delete this cluster and underlying infrastructure. To ensure that
+      everything has been properly destroyed, please visit your cloud provider's
+      console. Instructions to properly delete all resources can be found
+      <a
+        target="none"
+        href="https://docs.getporter.dev/docs/deleting-dangling-resources"
+      >
+        {" "}
+        here
+      </a>
+      .
     </Helper>
+  );
+
+  if (!context.currentCluster?.infra_id || !context.currentCluster?.service) {
+    helperText = (
+      <Helper>
+        Remove this cluster from Porter. Since this cluster was not provisioned
+        by Porter, deleting the cluster will only detach this cluster from your
+        project. To delete the cluster itself, you must do so manually. This
+        operation cannot be undone.
+      </Helper>
+    );
+  }
+
+  let keyRotationSection = null;
+
+  if (
+    context.currentCluster?.aws_integration_id &&
+    context.currentCluster?.aws_integration_id != 0
+  ) {
+    if (successfulRotate) {
+      keyRotationSection = (
+        <div>
+          <Heading>Credential Rotation</Heading>
+          <Helper>Successfully rotated credentials!</Helper>
+        </div>
+      );
+    } else if (startRotateCreds) {
+      keyRotationSection = (
+        <div>
+          <Heading>Credential Rotation</Heading>
+          <Helper>Input the new credentials for the EKS cluster.</Helper>
+          <InputRow
+            type="text"
+            value={accessKeyId}
+            setValue={(x: string) => setAccessKeyId(x)}
+            label="👤 AWS Access ID"
+            placeholder="ex: AKIAIOSFODNN7EXAMPLE"
+            width="100%"
+            isRequired={true}
+          />
+          <InputRow
+            type="password"
+            value={secretKey}
+            setValue={(x: string) => setSecretKey(x)}
+            label="🔒 AWS Secret Key"
+            placeholder="○ ○ ○ ○ ○ ○ ○ ○ ○"
+            width="100%"
+            isRequired={true}
+          />
+          <Button color="#616FEEcc" onClick={rotateCredentials}>
+            Submit
+          </Button>
+        </div>
+      );
+    } else {
+      keyRotationSection = (
+        <div>
+          <Heading>Credential Rotation</Heading>
+          <Helper>
+            Rotate the credentials that Porter uses to connect to the cluster.
+          </Helper>
+          <Button color="#616FEEcc" onClick={() => setStartRotateCreds(true)}>
+            Rotate Credentials
+          </Button>
+        </div>
+      );
+    }
   }
 
   return (
     <div>
       <StyledSettingsSection showSource={false}>
-          <Heading>Delete Cluster</Heading>
-          {helperText}
-          <Button
-            color="#b91133"
-            onClick={() => context.setCurrentModal("UpdateClusterModal")}
-          >
-            Delete Cluster
-          </Button>
-        </StyledSettingsSection>
+        {keyRotationSection}
+        <Heading>Delete Cluster</Heading>
+        {helperText}
+        <Button
+          color="#b91133"
+          onClick={() => context.setCurrentModal("UpdateClusterModal")}
+        >
+          Delete Cluster
+        </Button>
+      </StyledSettingsSection>
     </div>
-  )
-}
+  );
+};
 
+export default ClusterSettings;
 
 const StyledSettingsSection = styled.div<{ showSource: boolean }>`
   margin-top: 35px;

+ 20 - 16
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -5,26 +5,31 @@ import { Context } from "shared/Context";
 import TabSelector from "components/TabSelector";
 
 import NodeList from "./NodeList";
-import { ClusterSettings } from "./ClusterSettings";
 
+import { NamespaceList } from "./NamespaceList";
+import ClusterSettings from "./ClusterSettings";
 
-type TabEnum = "nodes" | "settings";
+
+type TabEnum = "nodes" | "settings" | "namespaces";
 
 const tabOptions: {
   label: string;
-  value: TabEnum
+  value: TabEnum;
 }[] = [
   { label: "Nodes", value: "nodes" },
-  { label: "Settings", value: "settings"}
+  { label: "Namespaces", value: "namespaces" },
+  { label: "Settings", value: "settings" },
 ];
 
-export const Dashboard: React.FC = ({ children }) => {
+export const Dashboard: React.FunctionComponent = () => {
   const [currentTab, setCurrentTab] = useState<TabEnum>("nodes");
   const context = useContext(Context);
-  const renderTab = (cluster: any) => {
+  const renderTab = () => {
     switch (currentTab) {
-      case "settings": 
-        return <ClusterSettings />
+      case "settings":
+        return <ClusterSettings />;
+      case "namespaces":
+        return <NamespaceList />;
       case "nodes":
       default:
         return <NodeList />;
@@ -32,7 +37,6 @@ export const Dashboard: React.FC = ({ children }) => {
   };
 
   return (
-    
     <>
       <TitleSection>
         <DashboardIcon>
@@ -47,18 +51,18 @@ export const Dashboard: React.FC = ({ children }) => {
             <i className="material-icons">info</i> Info
           </InfoLabel>
         </TopRow>
-        <Description>Cluster dashboard for {context.currentCluster.name}</Description>
+        <Description>
+          Cluster dashboard for {context.currentCluster.name}
+        </Description>
       </InfoSection>
 
       <TabSelector
         options={tabOptions}
         currentTab={currentTab}
-        setCurrentTab={(value: TabEnum) =>
-          setCurrentTab(value)
-        }
+        setCurrentTab={(value: TabEnum) => setCurrentTab(value)}
       />
 
-      {renderTab(context.currentCluster)}
+      {renderTab()}
     </>
   );
 };
@@ -134,8 +138,8 @@ const TitleSection = styled.div`
   > i {
     margin-left: 10px;
     cursor: pointer;
-    font-size 18px;
-    color: #858FAAaa;
+    font-size: 18px;
+    color: #858faaaa;
     padding: 5px;
     border-radius: 100px;
     :hover {

+ 387 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx

@@ -0,0 +1,387 @@
+import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
+import styled from "styled-components";
+import { Context } from "shared/Context";
+import { ClusterType, ProjectType } from "shared/types";
+import { pushFiltered } from "shared/routing";
+import { useHistory, useLocation } from "react-router";
+
+const OptionsDropdown: React.FC = ({ children }) => {
+  const [isOpen, setIsOpen] = useState(false);
+
+  const handleClick = (e: any) => {
+    e.stopPropagation();
+    setIsOpen(!isOpen);
+  };
+
+  const handleOnBlur = () => {
+    setIsOpen(false);
+  };
+
+  return (
+    <OptionsButton onClick={handleClick} onBlur={handleOnBlur}>
+      <i className="material-icons">{isOpen ? "expand_less" : "expand_more"}</i>
+      {isOpen && <DropdownMenu>{children}</DropdownMenu>}
+    </OptionsButton>
+  );
+};
+
+const useWebsocket = (
+  currentProject: ProjectType,
+  currentCluster: ClusterType
+) => {
+  const wsRef = useRef<WebSocket | undefined>(undefined);
+
+  useEffect(() => {
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
+    wsRef.current = new WebSocket(
+      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/namespace/status?cluster_id=${currentCluster.id}`
+    );
+
+    wsRef.current.onopen = () => {
+      console.log("Connected to websocket");
+    };
+
+    wsRef.current.onclose = () => {
+      console.log("closing websocket");
+    };
+
+    return () => {
+      wsRef.current.close();
+    };
+  }, []);
+
+  return wsRef;
+};
+
+export const NamespaceList: React.FunctionComponent = () => {
+  const {
+    currentCluster,
+    currentProject,
+    setCurrentModal,
+    setCurrentError,
+  } = useContext(Context);
+  const location = useLocation();
+  const history = useHistory();
+  const [namespaces, setNamespaces] = useState([]);
+  const websocket = useWebsocket(currentProject, currentCluster);
+  const onDelete = (namespace: any) => {
+    setCurrentModal("DeleteNamespaceModal", namespace);
+  };
+
+  const isAvailableForDeletion = (namespaceName: string) => {
+    // Only the namespaces that doesn't start with kube- or has by name default will be
+    // available for deletion (as those are the k8s namespaces)
+    return !/(^default$)|(^kube-.*)/.test(namespaceName);
+  };
+
+  useEffect(() => {
+    if (!websocket) {
+      return;
+    }
+
+    websocket.current.onerror = (err: ErrorEvent) => {
+      setCurrentError(err.message);
+      websocket.current.close();
+    };
+
+    websocket.current.onmessage = (evt: MessageEvent) => {
+      const data = JSON.parse(evt.data);
+      if (data.Kind !== "namespace") {
+        return;
+      }
+      if (data.event_type === "ADD") {
+        setNamespaces((oldNamespaces) => [...oldNamespaces, data.Object]);
+      }
+
+      if (data.event_type === "DELETE") {
+        setNamespaces((oldNamespaces) => {
+          const oldNamespaceIndex = oldNamespaces.findIndex(
+            (namespace) => namespace.metadata.name === data.Object.metadata.name
+          );
+          oldNamespaces.splice(oldNamespaceIndex, 1);
+          return [...oldNamespaces];
+        });
+      }
+
+      if (data.event_type === "UPDATE") {
+        setNamespaces((oldNamespaces) => {
+          const oldNamespaceIndex = oldNamespaces.findIndex(
+            (namespace) => namespace.metadata.name === data.Object.metadata.name
+          );
+          oldNamespaces.splice(oldNamespaceIndex, 1, data.Object);
+          return oldNamespaces;
+        });
+      }
+    };
+  }, [websocket]);
+
+  const sortAlphabetically = (prev: any, current: any) => {
+    return prev.metadata.name > current.metadata.name ? 1 : -1;
+  };
+
+  const sortedNamespaces = useMemo<any[]>(() => {
+    const nonDeletableNamespaces = namespaces
+      .filter((namespace) => !isAvailableForDeletion(namespace.metadata.name))
+      .sort(sortAlphabetically);
+    const deletableNamespaces = namespaces
+      .filter((namespace) => isAvailableForDeletion(namespace.metadata.name))
+      .sort(sortAlphabetically);
+
+    return [...deletableNamespaces, ...nonDeletableNamespaces];
+  }, [namespaces]);
+
+  return (
+    <NamespaceListWrapper>
+      <ControlRow>
+        <Button
+          onClick={() =>
+            setCurrentModal(
+              "NamespaceModal",
+              namespaces.map((namespace) => ({
+                value: namespace.metadata.name,
+              }))
+            )
+          }
+        >
+          <i className="material-icons">add</i> Add namespace
+        </Button>
+      </ControlRow>
+      <NamespacesGrid>
+        {sortedNamespaces.map((namespace) => {
+          return (
+            <StyledCard
+              key={namespace?.metadata?.name}
+              onClick={() =>
+                pushFiltered({ location, history }, `/applications`, [], {
+                  cluster: currentCluster.name,
+                  namespace: namespace.metadata.name,
+                })
+              }
+            >
+              <ContentContainer>
+                <Title>{namespace?.metadata?.name}</Title>
+                <Status margin_left={"0px"}>
+                  <StatusColor status={namespace.status.phase} />
+                  {namespace?.status?.phase}
+                </Status>
+              </ContentContainer>
+              {isAvailableForDeletion(namespace?.metadata?.name) && (
+                <OptionsDropdown>
+                  <DropdownOption onClick={() => onDelete(namespace)}>
+                    <i className="material-icons-outlined">delete</i>
+                    <span>Delete</span>
+                  </DropdownOption>
+                </OptionsDropdown>
+              )}
+            </StyledCard>
+          );
+        })}
+      </NamespacesGrid>
+    </NamespaceListWrapper>
+  );
+};
+
+const NamespaceListWrapper = styled.div`
+  margin-top: 35px;
+  padding-bottom: 80px;
+`;
+
+const NamespacesGrid = styled.div`
+  margin-top: 32px;
+  padding-bottom: 150px;
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(2, minmax(200px, 1fr));
+`;
+
+const Title = styled.div`
+  font-size: 14px;
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const StatusColor = styled.div`
+  margin-top: 1px;
+  width: 8px;
+  height: 8px;
+  background: ${(props: { status: string }) =>
+    props.status === "Active"
+      ? "#4797ff"
+      : props.status === "Terminating"
+      ? "#ed5f85"
+      : "#f5cb42"};
+  border-radius: 20px;
+  margin-left: 3px;
+  margin-right: 16px;
+`;
+
+const Status = styled.div`
+  display: flex;
+  height: 20px;
+  font-size: 13px;
+  flex-direction: row;
+  text-transform: capitalize;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  color: #aaaabb;
+  animation: fadeIn 0.5s;
+  margin-left: ${(props: { margin_left: string }) => props.margin_left};
+
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+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;
+  }
+`;
+
+const StyledCard = styled.div`
+  background: #26282f;
+  min-height: 80px;
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  border: 1px solid #26282f;
+  box-shadow: 0 5px 8px 0px #00000033;
+  border-radius: 5px;
+  padding: 14px;
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+
+  transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
+  :hover {
+    transform: scale(1.05);
+    box-shadow: 0 8px 20px 0px #00000030;
+    cursor: pointer;
+  }
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  height: 100%;
+`;
+
+const OptionsButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  color: #ffffff44;
+  :hover {
+    background: #32343a;
+    cursor: pointer;
+  }
+`;
+
+const DropdownMenu = styled.div`
+  position: absolute;
+  right: 12px;
+  top: 30px;
+  overflow: hidden;
+  width: 120px;
+  height: auto;
+  background: #26282f;
+  box-shadow: 0 8px 20px 0px #00000088;
+  color: white;
+`;
+
+const DropdownOption = styled.div`
+  width: 100%;
+  height: 37px;
+  font-size: 13px;
+  cursor: pointer;
+  padding-left: 10px;
+  padding-right: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  :hover {
+    background: #ffffff22;
+  }
+  :not(:first-child) {
+    border-top: 1px solid #00000000;
+  }
+
+  :not(:last-child) {
+    border-bottom: 1px solid #ffffff15;
+  }
+
+  > i {
+    margin-right: 5px;
+    font-size: 16px;
+  }
+`;

+ 7 - 3
dashboard/src/main/home/cluster-dashboard/dashboard/NodeStatusModal.tsx

@@ -17,7 +17,6 @@ export const NodeStatusModal: React.FunctionComponent<NodeStatusModalProps> = ({
   width = "800px",
   height = "min-content",
 }) => {
-
   const columns = useMemo<Column<any>[]>(
     () => [
       {
@@ -49,7 +48,12 @@ export const NodeStatusModal: React.FunctionComponent<NodeStatusModalProps> = ({
       <Modal onRequestClose={onClose} width={width} height={height}>
         Node {node?.name} conditions:
         <TableWrapper>
-          <Table columns={columns} data={data} isLoading={false} disableGlobalFilter={true}/>
+          <Table
+            columns={columns}
+            data={data}
+            isLoading={false}
+            disableGlobalFilter={true}
+          />
         </TableWrapper>
       </Modal>
     </div>
@@ -58,4 +62,4 @@ export const NodeStatusModal: React.FunctionComponent<NodeStatusModalProps> = ({
 
 const TableWrapper = styled.div`
   margin-top: 14px;
-`
+`;

+ 7 - 2
dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx

@@ -122,12 +122,17 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
       )
       .then((res) => {
         if (res.data) {
-          let namespaceOptions = res.data.items.map(
+          const availableNamespaces = res.data.items.filter(
+            (namespace: any) => {
+              return namespace.status.phase !== "Terminating";
+            }
+          );
+          const namespaceOptions = availableNamespaces.map(
             (x: { metadata: { name: string } }) => {
               return { label: x.metadata.name, value: x.metadata.name };
             }
           );
-          if (res.data.items.length > 0) {
+          if (availableNamespaces.length > 0) {
             this.setState({ namespaceOptions });
           }
         }

+ 28 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -141,6 +141,14 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
     this.sortJobsAndSave(jobs);
   };
 
+  removeJob = (deletedJob: any) => {
+    let jobs = this.state.jobs.filter((job) => {
+      return deletedJob.metadata?.name !== job.metadata?.name;
+    });
+
+    this.sortJobsAndSave(jobs);
+  };
+
   setupJobWebsocket = (chart: ChartType) => {
     let chartVersion = `${chart.chart.metadata.name}-${chart.chart.metadata.version}`;
 
@@ -173,6 +181,20 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         ) {
           this.mergeNewJob(event.Object);
         }
+      } else if (event.event_type == "DELETE") {
+        // filter job belonging to chart
+        let chartLabel = event.Object?.metadata?.labels["helm.sh/chart"];
+        let releaseLabel =
+          event.Object?.metadata?.labels["meta.helm.sh/release-name"];
+
+        if (
+          chartLabel &&
+          releaseLabel &&
+          chartLabel == chartVersion &&
+          releaseLabel == chart.name
+        ) {
+          this.removeJob(event.Object);
+        }
       }
     };
 
@@ -398,7 +420,6 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
   };
 
   renderTabContents = (currentTab: string, submitValues?: any) => {
-    console.log("CHART CONFIG", this.props.currentChart.config?.schedule?.enabled)
     let saveButton = null
 
     if (!this.props.currentChart.config?.schedule?.enabled) {
@@ -427,7 +448,12 @@ export default class ExpandedJobChart extends Component<PropsType, StateType> {
         }
         return (
           <TabWrapper>
-            <JobList jobs={this.state.jobs} />
+            <JobList
+              jobs={this.state.jobs}
+              setJobs={(jobs: any) => {
+                this.setState({ jobs });
+              }}
+            />
             {saveButton}
           </TabWrapper>
         );

+ 65 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobList.tsx

@@ -1,17 +1,28 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 
+import api from "shared/api";
 import _ from "lodash";
 import { Context } from "shared/Context";
 import JobResource from "./JobResource";
+import ConfirmOverlay from "components/ConfirmOverlay";
 
 type PropsType = {
   jobs: any[];
+  setJobs: (job: any) => void;
 };
 
-type StateType = {};
+type StateType = {
+  deletionCandidate: any;
+  deletionJob: any;
+};
 
 export default class JobList extends Component<PropsType, StateType> {
+  state = {
+    deletionCandidate: null as any,
+    deletionJob: null as any,
+  };
+
   renderJobList = () => {
     if (this.props.jobs.length === 0) {
       return (
@@ -24,15 +35,66 @@ export default class JobList extends Component<PropsType, StateType> {
       return (
         <>
           {this.props.jobs.map((job: any, i: number) => {
-            return <JobResource key={job?.metadata?.name} job={job} />;
+            return (
+              <JobResource
+                key={job?.metadata?.name}
+                job={job}
+                handleDelete={() => this.setState({ deletionCandidate: job })}
+                deleting={
+                  this.state.deletionJob?.metadata?.name == job.metadata?.name
+                }
+              />
+            );
           })}
         </>
       );
     }
   };
 
+  deleteJob = () => {
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+    let job = this.state.deletionCandidate;
+
+    api
+      .deleteJob(
+        "<token>",
+        {
+          cluster_id: currentCluster.id,
+        },
+        {
+          id: currentProject.id,
+          name: job.metadata?.name,
+          namespace: job.metadata?.namespace,
+        }
+      )
+      .then((res) => {
+        this.setState({
+          deletionJob: this.state.deletionCandidate,
+          deletionCandidate: null,
+        });
+      })
+      .catch((err) => {
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+        if (parsedErr) {
+          err = parsedErr;
+        }
+        setCurrentError(err);
+      });
+  };
+
   render() {
-    return <JobListWrapper>{this.renderJobList()}</JobListWrapper>;
+    return (
+      <>
+        <ConfirmOverlay
+          show={this.state.deletionCandidate}
+          message={`Are you sure you want to delete this job run?`}
+          onYes={this.deleteJob}
+          onNo={() => this.setState({ deletionCandidate: null })}
+        />
+        <JobListWrapper>{this.renderJobList()}</JobListWrapper>
+      </>
+    );
   }
 }
 

+ 51 - 25
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/JobResource.tsx

@@ -7,10 +7,13 @@ import api from "shared/api";
 import Logs from "../status/Logs";
 import plus from "assets/plus.svg";
 import closeRounded from "assets/close-rounded.png";
+import trash from "assets/trash.png";
 import KeyValueArray from "components/values-form/KeyValueArray";
 
 type PropsType = {
   job: any;
+  handleDelete: () => void;
+  deleting: boolean;
 };
 
 type StateType = {
@@ -55,7 +58,14 @@ export default class JobResource extends Component<PropsType, StateType> {
         }
       )
       .then((res) => {})
-      .catch((err) => setCurrentError(JSON.stringify(err)));
+      .catch((err) => {
+        let parsedErr =
+          err?.response?.data?.errors && err.response.data.errors[0];
+        if (parsedErr) {
+          err = parsedErr;
+        }
+        setCurrentError(err);
+      });
   };
 
   getPods = (callback: () => void) => {
@@ -217,6 +227,10 @@ export default class JobResource extends Component<PropsType, StateType> {
   };
 
   renderStatus = () => {
+    if (this.props.deleting) {
+      return <Status color="#cc3d42">Deleting</Status>;
+    }
+
     if (this.props.job.status?.succeeded >= 1) {
       return <Status color="#38a88a">Succeeded</Status>;
     }
@@ -249,30 +263,42 @@ export default class JobResource extends Component<PropsType, StateType> {
     );
 
     return (
-      <StyledJob>
-        <MainRow onClick={this.expandJob}>
-          <Flex>
-            <Icon src={icon && icon} />
-            <Description>
-              <Label>
-                Started at {this.readableDate(this.props.job.status?.startTime)}
-              </Label>
-              <Subtitle>{this.getSubtitle()}</Subtitle>
-            </Description>
-          </Flex>
-          <EndWrapper>
-            <CommandString>{commandString}</CommandString>
-            {this.renderStatus()}
-            <MaterialIconTray disabled={false}>
-              {this.renderStopButton()}
-              <i className="material-icons" onClick={this.expandJob}>
-                {this.state.expanded ? "expand_less" : "expand_more"}
-              </i>
-            </MaterialIconTray>
-          </EndWrapper>
-        </MainRow>
-        {this.renderLogsSection()}
-      </StyledJob>
+      <>
+        <StyledJob>
+          <MainRow onClick={this.expandJob}>
+            <Flex>
+              <Icon src={icon && icon} />
+              <Description>
+                <Label>
+                  Started at{" "}
+                  {this.readableDate(this.props.job.status?.startTime)}
+                </Label>
+                <Subtitle>{this.getSubtitle()}</Subtitle>
+              </Description>
+            </Flex>
+            <EndWrapper>
+              <CommandString>{commandString}</CommandString>
+              {this.renderStatus()}
+              <MaterialIconTray disabled={false}>
+                {this.renderStopButton()}
+                <i
+                  className="material-icons"
+                  onClick={(e) => {
+                    e.stopPropagation();
+                    this.props.handleDelete();
+                  }}
+                >
+                  delete
+                </i>
+                <i className="material-icons" onClick={this.expandJob}>
+                  {this.state.expanded ? "expand_less" : "expand_more"}
+                </i>
+              </MaterialIconTray>
+            </EndWrapper>
+          </MainRow>
+          {this.renderLogsSection()}
+        </StyledJob>
+      </>
     );
   }
 }

+ 105 - 22
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx

@@ -2,7 +2,7 @@ import React, { Component } from "react";
 import styled from "styled-components";
 import api from "shared/api";
 import { Context } from "shared/Context";
-
+import { ChartType } from "shared/types";
 import ResourceTab from "components/ResourceTab";
 import ConfirmOverlay from "components/ConfirmOverlay";
 
@@ -21,6 +21,10 @@ type StateType = {
   raw: any[];
   showTooltip: boolean[];
   podPendingDelete: any;
+  websockets: Record<string, any>;
+  selectors: string[];
+  available: number;
+  total: number;
 };
 
 // Controller tab in log section that displays list of pods on click.
@@ -30,37 +34,23 @@ export default class ControllerTab extends Component<PropsType, StateType> {
     raw: [] as any[],
     showTooltip: [] as boolean[],
     podPendingDelete: null as any,
+    websockets: {} as Record<string, any>,
+    selectors: [] as string[],
+    available: null as number,
+    total: null as number,
   };
 
   updatePods = () => {
     let { currentCluster, currentProject, setCurrentError } = this.context;
     let { controller, selectPod, isFirst } = this.props;
 
-    let selectors = [] as string[];
-    let ml =
-      controller?.spec?.selector?.matchLabels || controller?.spec?.selector;
-    let i = 1;
-    let selector = "";
-    for (var key in ml) {
-      selector += key + "=" + ml[key];
-      if (i != Object.keys(ml).length) {
-        selector += ",";
-      }
-      i += 1;
-    }
-    selectors.push(selector);
-
-    if (controller.kind.toLowerCase() == "job" && this.props.selectors) {
-      selectors = this.props.selectors;
-    }
-
     api
       .getMatchingPods(
         "<token>",
         {
           cluster_id: currentCluster.id,
           namespace: controller?.metadata?.namespace,
-          selectors,
+          selectors: this.state.selectors,
         },
         {
           id: currentProject.id,
@@ -97,10 +87,103 @@ export default class ControllerTab extends Component<PropsType, StateType> {
       });
   };
 
+  getPodSelectors = (callback: () => void) => {
+    let { controller } = this.props;
+
+    let selectors = [] as string[];
+    let ml =
+      controller?.spec?.selector?.matchLabels || controller?.spec?.selector;
+    let i = 1;
+    let selector = "";
+    for (var key in ml) {
+      selector += key + "=" + ml[key];
+      if (i != Object.keys(ml).length) {
+        selector += ",";
+      }
+      i += 1;
+    }
+    selectors.push(selector);
+    if (controller.kind.toLowerCase() == "job" && this.props.selectors) {
+      selectors = this.props.selectors;
+    }
+
+    this.setState({ selectors }, () => {
+      callback();
+    });
+  };
+
   componentDidMount() {
-    this.updatePods();
+    this.getPodSelectors(() => {
+      this.updatePods();
+      this.setControllerWebsockets([this.props.controller.kind, "pod"]);
+    });
+  }
+
+  componentWillUnmount() {
+    if (this.state.websockets) {
+      this.state.websockets.forEach((ws: WebSocket) => {
+        ws.close();
+      });
+    }
   }
 
+  setControllerWebsockets = (controller_types: any[]) => {
+    let websockets = controller_types.map((kind: string) => {
+      return this.setupWebsocket(kind);
+    });
+    this.setState({ websockets });
+  };
+
+  setupWebsocket = (kind: string) => {
+    let { currentCluster, currentProject } = this.context;
+    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
+    let connString = `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
+
+    if (kind == "pod" && this.state.selectors) {
+      connString += `&selectors=${this.state.selectors[0]}`;
+    }
+    let ws = new WebSocket(connString);
+
+    ws.onopen = () => {
+      console.log("connected to websocket");
+    };
+
+    ws.onmessage = (evt: MessageEvent) => {
+      let event = JSON.parse(evt.data);
+      let object = event.Object;
+      object.metadata.kind = event.Kind;
+
+      // update pods no matter what if ws message is a pod event.
+      // If controller event, check if ws message corresponds to the designated controller in props.
+      if (
+        event.Kind != "pod" &&
+        object.metadata.uid != this.props.controller.metadata.uid
+      )
+        return;
+
+      if (event.Kind != "pod") {
+        let [available, total] = this.getAvailability(
+          object.metadata.kind,
+          object
+        );
+        this.setState({ available, total });
+      }
+
+      this.updatePods();
+    };
+
+    ws.onclose = () => {
+      console.log("closing websocket");
+    };
+
+    ws.onerror = (err: ErrorEvent) => {
+      console.log(err);
+      ws.close();
+    };
+
+    return ws;
+  };
+
   getAvailability = (kind: string, c: any) => {
     switch (kind?.toLowerCase()) {
       case "deployment":
@@ -196,7 +279,7 @@ export default class ControllerTab extends Component<PropsType, StateType> {
 
   render() {
     let { controller, selectedPod, isLast, selectPod, isFirst } = this.props;
-    let [available, total] = this.getAvailability(controller.kind, controller);
+    let { available, total } = this.state;
     let status = available == total ? "running" : "waiting";
 
     controller?.status?.conditions?.forEach((condition: any) => {

+ 2 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -130,8 +130,9 @@ export default class Logs extends Component<PropsType, StateType> {
       this.ws = null;
       this.setState({ logs: [] });
       this.setupWebsocket();
+    } else if (this.state.currentTab == "System") {
+      this.retrieveEvents(selectedPod);
     }
-    this.retrieveEvents(selectedPod);
   };
 
   componentDidUpdate = (prevProps: any, prevState: any) => {

+ 7 - 2
dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx

@@ -444,12 +444,17 @@ class LaunchTemplate extends Component<PropsType, StateType> {
       )
       .then((res) => {
         if (res.data) {
-          let namespaceOptions = res.data.items.map(
+          const availableNamespaces = res.data.items.filter(
+            (namespace: any) => {
+              return namespace.status.phase !== "Terminating";
+            }
+          );
+          const namespaceOptions = availableNamespaces.map(
             (x: { metadata: { name: string } }) => {
               return { label: x.metadata.name, value: x.metadata.name };
             }
           );
-          if (res.data.items.length > 0) {
+          if (availableNamespaces.length > 0) {
             this.setState({ namespaceOptions });
           }
         }

+ 7 - 2
dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx

@@ -101,12 +101,17 @@ export default class SettingsPage extends Component<PropsType, StateType> {
       )
       .then((res) => {
         if (res.data) {
-          let namespaceOptions = res.data.items.map(
+          const availableNamespaces = res.data.items.filter(
+            (namespace: any) => {
+              return namespace.status.phase !== "Terminating";
+            }
+          );
+          const namespaceOptions = availableNamespaces.map(
             (x: { metadata: { name: string } }) => {
               return { label: x.metadata.name, value: x.metadata.name };
             }
           );
-          if (res.data.items.length > 0) {
+          if (availableNamespaces.length > 0) {
             this.setState({ namespaceOptions });
           }
         }

+ 200 - 0
dashboard/src/main/home/modals/DeleteNamespaceModal.tsx

@@ -0,0 +1,200 @@
+import React, { Component, useContext, useMemo, useState } from "react";
+import styled from "styled-components";
+import close from "assets/close.png";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+import SaveButton from "components/SaveButton";
+import InputRow from "components/values-form/InputRow";
+
+const DeleteNamespaceModal = () => {
+  const {
+    currentModalData,
+    currentCluster,
+    currentProject,
+    setCurrentError,
+    setCurrentModal,
+  } = useContext(Context);
+  const [namespaceNameForDelition, setNamespaceNameForDelition] = useState("");
+  const [status, setStatus] = useState<string>(null as string);
+  const deleteNamespace = () => {
+    if (namespaceNameForDelition !== currentModalData.metadata.name) {
+      setStatus("Please insert the name of the namespace to confirm deletion");
+      return;
+    }
+
+    api
+      .deleteNamespace(
+        "<token>",
+        { name: currentModalData.metadata.name, cluster_id: currentCluster.id },
+        {
+          id: currentProject.id,
+        }
+      )
+      .then((res) => {
+        if (res.status === 200) {
+          setStatus("successful");
+          setTimeout(() => {
+            setCurrentModal(null, null);
+          }, 1000);
+        }
+      })
+      .catch((err) => {
+        setCurrentError(err);
+      });
+  };
+
+  return (
+    <StyledUpdateProjectModal>
+      <CloseButton
+        onClick={() => {
+          setCurrentModal(null, null);
+        }}
+      >
+        <CloseButtonImg src={close} />
+      </CloseButton>
+
+      <ModalTitle>Delete Namespace</ModalTitle>
+      <Subtitle>
+        Please insert the name of the namespace to delete it:
+        <DangerText>{" " + currentModalData.metadata.name}</DangerText>
+      </Subtitle>
+
+      <InputWrapper>
+        <DashboardIcon>
+          <i className="material-icons">warning</i>
+        </DashboardIcon>
+        <InputRow
+          type="string"
+          value={namespaceNameForDelition}
+          setValue={(x: string) => setNamespaceNameForDelition(x)}
+          placeholder={currentModalData.metadata.name}
+          width="480px"
+        />
+      </InputWrapper>
+      <Warning highlight={true}>
+        ⚠️ Deleting this namespace will remove all resources attached to this
+        namespace.
+      </Warning>
+      <SaveButton
+        text="Delete Namespace"
+        color="#e62659"
+        onClick={() => deleteNamespace()}
+        status={status}
+      />
+    </StyledUpdateProjectModal>
+  );
+};
+
+export default DeleteNamespaceModal;
+
+const DangerText = styled.span`
+  color: #ed5f85;
+`;
+
+const DashboardIcon = styled.div`
+  width: 32px;
+  margin-top: 6px;
+  min-width: 25px;
+  height: 32px;
+  border-radius: 3px;
+  overflow: hidden;
+  position: relative;
+  margin-right: 15px;
+  font-weight: 400;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #676c7c;
+  border: 2px solid #8e94aa;
+  color: white;
+
+  > i {
+    font-size: 17px;
+  }
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-bottom: 15px;
+`;
+
+const Subtitle = styled.div`
+  margin-top: 23px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  margin-bottom: -10px;
+`;
+
+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 StyledUpdateProjectModal = styled.div`
+  width: 100%;
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  padding: 25px 30px;
+  overflow: hidden;
+  border-radius: 6px;
+  background: #202227;
+`;
+
+const Warning = styled.div`
+  font-size: 13px;
+  display: flex;
+  border-radius: 3px;
+  width: calc(100%);
+  margin-top: 10px;
+  margin-left: 2px;
+  line-height: 1.4em;
+  align-items: center;
+  color: white;
+  > i {
+    margin-right: 10px;
+    font-size: 18px;
+  }
+  color: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
+    props.highlight ? "#f5cb42" : ""};
+`;

+ 36 - 27
dashboard/src/main/home/modals/NamespaceModal.tsx

@@ -21,7 +21,40 @@ export default class NamespaceModal extends Component<PropsType, StateType> {
     status: null as string | null,
   };
 
+  isValidName = (namespaceName: string) =>
+    !/(^default$)|(^kube-.*)/.test(namespaceName);
+
+  hasInvalidCharacters = (namespaceName: string) =>
+    !/([a-z0-9]|\-)+/.test(namespaceName);
+
   createNamespace = () => {
+    if (!this.isValidName(this.state.namespaceName)) {
+      this.setState({
+        status: "The name cannot be default or start with kube-",
+      });
+      return;
+    }
+
+    if (!this.hasInvalidCharacters(this.state.namespaceName)) {
+      this.setState({
+        status: "Only lowercase, numbers or dash (-) are allowed",
+      });
+      return;
+    }
+
+    const namespaceExists = this.context.currentModalData?.find(
+      (namespace: any) => {
+        return namespace?.value === this.state.namespaceName;
+      }
+    );
+
+    if (namespaceExists) {
+      this.setState({
+        status: "Namespace already exist, choose another name",
+      });
+      return;
+    }
+
     api
       .createNamespace(
         "<token>",
@@ -66,19 +99,14 @@ export default class NamespaceModal extends Component<PropsType, StateType> {
           <InputRow
             type="string"
             value={this.state.namespaceName}
-            setValue={(x: string) => this.setState({ namespaceName: x })}
+            setValue={(x: string) =>
+              this.setState({ namespaceName: x, status: null })
+            }
             placeholder="ex: porter-workers"
             width="480px"
           />
         </InputWrapper>
 
-        {/* <Help
-          href="https://docs.getporter.dev/docs/deleting-dangling-resources"
-          target="_blank"
-        >
-          <i className="material-icons">help_outline</i> Help
-        </Help> */}
-
         <SaveButton
           text="Create Namespace"
           color="#616FEEcc"
@@ -92,25 +120,6 @@ export default class NamespaceModal extends Component<PropsType, StateType> {
 
 NamespaceModal.contextType = Context;
 
-const Help = styled.a`
-  position: absolute;
-  left: 31px;
-  bottom: 35px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #ffffff55;
-  font-size: 13px;
-  :hover {
-    color: #ffffff;
-  }
-
-  > i {
-    margin-right: 9px;
-    font-size: 16px;
-  }
-`;
-
 const DashboardIcon = styled.div`
   width: 32px;
   margin-top: 6px;

+ 26 - 2
dashboard/src/shared/api.tsx

@@ -45,6 +45,20 @@ const createAWSIntegration = baseApi<
   return `/api/projects/${pathParams.id}/integrations/aws`;
 });
 
+const overwriteAWSIntegration = baseApi<
+  {
+    aws_access_key_id: string;
+    aws_secret_access_key: string;
+  },
+  {
+    projectID: number;
+    awsIntegrationID: number;
+    cluster_id: number;
+  }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.projectID}/integrations/aws/${pathParams.awsIntegrationID}/overwrite?cluster_id=${pathParams.cluster_id}`;
+});
+
 const createDOCR = baseApi<
   {
     do_integration_id: number;
@@ -406,8 +420,8 @@ const getClusterNodes = baseApi<
     cluster_id: number;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/nodes`
-})
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/nodes`;
+});
 
 const getGitRepoList = baseApi<
   {},
@@ -839,6 +853,14 @@ const deleteNamespace = baseApi<
   return `/api/projects/${id}/k8s/namespaces/delete`;
 });
 
+const deleteJob = baseApi<
+  { cluster_id: number },
+  { name: string; namespace: string; id: number }
+>("DELETE", (pathParams) => {
+  let { id, name, namespace } = pathParams;
+  return `/api/projects/${id}/k8s/jobs/${namespace}/${name}`;
+});
+
 const stopJob = baseApi<
   {},
   { name: string; namespace: string; id: number; cluster_id: number }
@@ -853,6 +875,7 @@ export default {
   connectECRRegistry,
   connectGCRRegistry,
   createAWSIntegration,
+  overwriteAWSIntegration,
   createDOCR,
   createDOKS,
   createEmailVerification,
@@ -933,5 +956,6 @@ export default {
   updateUser,
   updateConfigMap,
   upgradeChartValues,
+  deleteJob,
   stopJob,
 };

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

@@ -58,3 +58,15 @@ export const pushFiltered = (
     search: newUrlParams.toString(),
   });
 };
+
+export const getQueryParams = (props: any) => {
+  const searchParams = props.location.search;
+  if (searchParams) {
+    return new URLSearchParams(searchParams);
+  }
+};
+
+export const getQueryParam = (props: any, paramName: string) => {
+  const searchParams = getQueryParams(props);
+  return searchParams?.get(paramName);
+};

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

@@ -5,6 +5,7 @@ export interface ClusterType {
   service_account_id: number;
   infra_id?: number;
   service?: string;
+  aws_integration_id?: number;
 }
 
 export interface DetailedClusterType extends ClusterType {

+ 225 - 0
docs/deploy/applications/deploying-from-the-cli.md

@@ -0,0 +1,225 @@
+> 🚧
+> 
+> Deploying applications from the CLI is a `beta` feature at the moment. It may not be entirely stable or work for all possible combinations of builds/deployments. Please bring any issues to the Github or Discord so we can fix them as quickly as possible. 
+
+# Creating a New Application
+
+## Overview
+
+To create a new application via the Porter CLI, you can run:
+
+```sh
+porter create [kind] --app [app-name]
+```
+
+Required args/flags:
+- `kind` can be one of `web`, `worker`, or `job`
+- `app-name` must be a set of lowercase letters or digits separated by `-` 
+
+Each `kind` of application has a set of default values which can be overwritten. For example, `web` applications have the port set to `80`. To overwrite this, for example to port `3000`, create the following file `values.yaml`:
+
+```yaml
+container:
+  port: 3000
+```
+
+And then run the command:
+
+```sh
+porter create web --app web-test --values ./values.yaml
+```
+
+Go to the [common configuration options](#common-configuration-options) section to view `values.yaml` files for common use-cases. You can also view all possible configuration options in the `values.yaml` files of the respective applications: [`web`](https://github.com/porter-dev/porter-charts/blob/master/applications/web/values.yaml), [`worker`](https://github.com/porter-dev/porter-charts/blob/master/applications/worker/values.yaml), and [`job`](https://github.com/porter-dev/porter-charts/blob/master/applications/job/values.yaml).
+
+## Deploying from Local Files 
+
+The default behavior of `porter create` is to use the local filesystem to build, push, and deploy a Docker image. For example, to create a new web application from the current directory, you can simply run:
+
+```sh
+porter create web --app web-test
+```
+
+Porter will look for a `Dockerfile` located at the root of the current directory. If a `Dockerfile` is found, Porter will use the default Docker container registry linked to the Porter project to deploy the application. If a `Dockerfile` is not found, Porter will use a [Cloud-Native Buildpack](https://docs.getporter.dev/docs/auto-deploy-requirements#auto-build-with-cloud-native-buildpacks) to build your application. 
+
+To point to a Dockerfile, you should pass the **relative path** to the Dockerfile from the root directory of the source code:
+
+```sh
+porter create web --name web-test --dockerfile /my/nested/Dockerfile
+```
+
+To use a cloud-native buildpack instead of a Dockerfile, you can specify the method directly:
+
+```sh
+porter create web --app web-test --method pack
+```
+
+## Deploying from Github
+
+By default, Porter will use the local filesystem to build, push, and deploy your application. Alternatively, if you have a local Git repository whose origin is set to a Github repository that matches one linked on Porter, you can pass in the `--source` flag to deploy your app:      
+
+```sh
+porter create web --app web-test --source github
+```
+
+If your local branch is set to track changes from an upstream remote branch, Porter will try to use the connected `remote` and remote branch as the Github repository to link to. Otherwise, Porter will use the remote given by `origin`, and the same branch name as your local branch. 
+
+## Deploying from a Docker Registry 
+
+The CLI also supports deploying directly from a Docker image which is hosted on a [connected Docker registry](https://docs.getporter.dev/docs/linking-an-existing-docker-container-registry). Simply specify `--source registry` and the application image via the `--image` tag:
+
+```sh
+porter create web --app web-test --source registry --image gcr.io/snowflake-12345/web-test:latest
+```
+
+# Updating an Existing Application
+
+## Overview
+
+You can update an existing application that was deployed from either the dashboard or the CLI. The root command for updating an application is:
+
+```sh
+porter update --app [app-name]
+```
+
+Where `app-name` is the name of a web, worker, or job application on the Porter dashboard. The default behavior of this command is to build a new image using the local filesystem, push this image to the connect image repository, and re-deploy the application on the Porter dashboard. However, each of these steps can be configured. 
+
+As with the `porter create` command, you can update the configuration that an application uses by passing in the `--values` flag, which should pass the filepath to a `values.yaml` file. **Note that this command merges the `values.yaml` file with your existing configuration, so you should only specify options that you would like to modify**. For example, the following `values.yaml` file:
+
+```yaml
+container:
+  port: 8080
+```
+
+Would only update the container port to `8080`, while keeping your existing configuration, after running the command: 
+
+```sh
+porter update --app --values ./values.yaml
+```
+
+Go to the [common configuration options](#common-configuration-options) section to view `values.yaml` files for common use-cases. You can also view all possible configuration options in the `values.yaml` files of the respective applications: [`web`](https://github.com/porter-dev/porter-charts/blob/master/applications/web/values.yaml), [`worker`](https://github.com/porter-dev/porter-charts/blob/master/applications/worker/values.yaml), and [`job`](https://github.com/porter-dev/porter-charts/blob/master/applications/job/values.yaml).
+
+## Building from Local Files 
+
+The default behavior of this command will vary depending on if the application already has a Github repository source specified:
+- If this application has a linked Github repository source, it will use the build settings from the linked source. That is, if the Github build settings specify a Dockerfile, this command will use the path to that Dockerfile. 
+- If the application does not have a linked source, this command will default to using a Dockerfile located at the root of the directory, at the path `./Dockerfile`. 
+
+ These default behaviors can be overwritten through a combination of the `--method` flag, the `--dockerfile` flag, and the `--path` flag:
+
+## Building from Github 
+
+If you specify `--local false`, this command will look for a remote Github repository that has been linked to this application. If one is found, the command will download an archive of the Github repository from the latest commit of the linked branch, and will use that as the filesystem to build from. 
+
+## Updating Configuration without Building
+
+If you would only like to update the configuration for your application via a `values.yaml` file (without building a new image), you can do so with the following command:
+
+```sh
+porter update config --app [app-name] --values [values-file]
+```
+
+# Common Configuration Options
+
+## Container Port
+
+```yaml
+container:
+  port: 3000
+```
+
+## Container Start Command
+
+```yaml
+container:
+  command: npm start
+```
+
+## [`web`] Un-exposing a Web Application
+
+This configuration only applies to `web` applications. 
+
+```yaml
+ingress:
+  enabled: false
+```
+
+## [`web`] Exposing a Web Application on a Custom Domain
+
+This configuration only applies to `web` applications. 
+
+```yaml
+ingress:
+  custom_domain: true
+  custom_paths:
+  - my-app.example.com
+```
+
+# Writing Custom Deployment Pipelines
+
+While this will be a subject of a separate guide soon, this section provides an overview of how you might use certain subcommands to build your own deployment pipeline. By default, the command `porter update` performs four steps: gets the environment variables for the application, builds a new Docker container from the source files, pushes a new Docker image to the remote registry, and calls a Porter endpoint to re-deploy the application. However, we designed this command to be modular: if you would like to add intermediate steps in your own build process, you can call different `porter update` sub-commands separately:
+
+- [`porter update get-env`](#porter-update-get-env) - prints the build environment variables to the terminal or a file.  
+- [`porter update build`](#porter-update-build) - builds the Docker container used for deployment.
+- [`porter update push`](#porter-update-push) - pushes the Docker container used for deployment to a remote registry.
+- [`porter update config`](#porter-update-config) - calls a Porter endpoint to re-deploy the application with new configuration. 
+
+### `porter update get-env`
+
+Gets environment variables for a deployment for a specified application given by the `--app` flag. By default, env variables are printed via stdout for use in downstream commands:
+
+```sh
+porter update get-env --app example-app | xargs
+```
+
+Output can also be written to a dotenv file via the `--file` flag, which should specify the destination path for a `.env` file. For example:
+
+```sh
+porter update get-env --app example-app --file .env
+```
+
+### `porter deploy build`
+
+Builds a new version of the application specified by the `--app` flag. Depending on the configured settings, this command may work automatically or will require a specified `--method` flag. 
+
+If you have configured the Dockerfile path and/or a build context for this application, this command will by default use those settings, so you just need to specify the `--app` flag:
+
+```sh
+porter update build --app example-app
+```
+
+If you have not linked the build-time requirements for this application, the command will use a local build. By default, the cloud-native buildpacks builder will automatically be run from the current directory. If you would like to change the build method, you can do so by using the `--method` flag, for example:
+
+```sh
+porter update build --app example-app --method docker
+```
+
+When using `--method docker`, you can specify the path to the Dockerfile using the `--dockerfile` flag. This will also override the Dockerfile path that you may have linked for the application:
+
+```sh
+porter update build --app example-app --method docker --dockerfile ./prod.Dockerfile
+```
+
+### `porter update push`
+
+Pushes a new image for an application specified by the --app flag. This command uses the image repository saved in the application config by default. For example, if an application "nginx" was created from the image repo "gcr.io/snowflake-123456/nginx", the following command would push the image "gcr.io/snowflake-123456/nginx:new-tag":
+
+```sh
+porter update push --app nginx --tag new-tag
+```
+
+This command will not use your pre-saved authentication set up via `docker login`, so if you are using an image registry that was created outside of Porter, make sure that you have linked it via `porter connect`.
+
+### `porter update config`
+
+Updates the configuration for an application specified by the --app flag, using the configuration given by the --values flag. This will trigger a new deployment for the application with new configuration set. Note that this will merge your existing configuration with configuration specified in the --values file. For example:
+
+```sh
+porter update config --app example-app --values my-values.yaml
+```
+
+You can update the configuration with only a new tag with the --tag flag, which will only update
+the image that the application uses if no --values file is specified:
+
+```sh
+porter update config --app example-app --tag custom-tag
+```

+ 0 - 132
docs/reference/cli-porter-deploy.md

@@ -1,132 +0,0 @@
-## `porter deploy`
-
-> 🚧 Beta Notice
-> 
-> **Note:** `porter deploy` was introduced in version `0.4.0` and is currently in `beta`, thus it is subject to change and may not work reliably. If you encounter an error, please [file a bug report](https://github.com/porter-dev/porter/issues/new?assignees=&labels=&template=bug.md). 
-
-Version `0.4.0` of Porter added supported for building and re-deploying an existing application using the Porter CLI. For example, if you have chosen to "Deploy from a Git Repository" on the Porter dashboard, the following command will re-deploy an application called `example-app` from the most recent Github commit in the specified branch/repository:
-
-```sh
-porter deploy --app example-app
-```
-
-If you would like to use a local directory (such as your current working directory) as the directory to build from, go to the [deploying from local source](#deploying-from-local-source) section.
-
-By default, this command performs four steps: gets the environment variables for the application, builds a new Docker container from the source files, pushes a new Docker image to the remote registry, and calls the Porter webhook to re-deploy the application. However, we designed this command to be modular: if you would like to add intermediate steps in your own build process, you can call different `porter deploy` sub-commands separately:
-
-- [`porter deploy get-env`](#porter-deploy-get-env) - prints the build environment variables to the terminal or a file.  
-- [`porter deploy build`](#porter-deploy-build) - builds the Docker container used for deployment.
-- [`porter deploy push`](#porter-deploy-push) - pushes the Docker container used for deployment to a remote registry.
-- [`porter deploy call-webhook`](#porter-deploy-call-webhook) - calls the Porter webhook to trigger a re-deploy of the application. 
-
-To see a working example, check out the [creating a custom CI pipeline]() guide.
-
-## Command Reference
-
-### `porter deploy`
-
-Builds and deploys a specified application given by the `--app` flag. For example:
-
-```sh
-porter deploy --app example-app
-```
-
-If the application has a remote Git repository source configured, this command uses the latest commit from the remote repo and branch to deploy an application. It will use the latest commit as the image tag. 
-
-To build from a local directory, you must specify the `--local` flag. The path can be configured via the `--path` flag. You can also overwrite the tag using the `--tag` flag. For example, to build from the local directory `~/path-to-dir` with the tag `testing`:
-
-```sh
-porter deploy --app remote-git-app --local --path ~/path-to-dir --tag testing
-```
-
-If your application is set up to use a Dockerfile by default, you can use a buildpack via the flag `--method pack`. Conversely, if your application is set up to use a buildpack by default, you can use a Dockerfile by passing the flag `--method docker`. You can specify the relative path to a Dockerfile in your remote Git repository. For example, if a Dockerfile is found at `./docker/prod.Dockerfile`, you can specify it as follows:
-
-```sh
-porter deploy --app remote-git-app --method docker --dockerfile ./docker/prod.Dockerfile
-```
-
-If an application does not have a remote Git repository source, this command will attempt to use a cloud-native buildpack builder and build from the current directory. If this is the desired behavior, you do not need to configure additional flags:
-
-```sh
-porter deploy --app local-app
-```
-
-If you would like to build from a Dockerfile instead, use the flag `--dockerfile` and `--method docker` as documented above. For example:
-
-```sh
-porter deploy --app local-app --method docker --dockerfile ~/porter-test/prod.Dockerfile
-```
-
-### `porter deploy get-env`
-
-Gets environment variables for a deployment for a specified application given by the `--app` flag. By default, env variables are printed via stdout for use in downstream commands:
-
-```sh
-porter deploy get-env --app example-app | xargs
-```
-
-Output can also be written to a dotenv file via the `--file` flag, which should specify the destination path for a `.env` file. For example:
-
-```sh
-porter deploy get-env --app example-app --file .env
-```
-
-### `porter deploy build`
-
-Builds a new version of the application specified by the `--app` flag. Depending on the configured settings, this command may work automatically or will require a specified `--method` flag. 
-
-If you have configured the Dockerfile path and/or a build context for this application, this command will by default use those settings, so you just need to specify the `--app` flag:
-
-```sh
-porter deploy build --app example-app
-```
-
-If you have not linked the build-time requirements for this application, the cloud-native buildpacks builder will automatically be run from the current directory. If you would like to change the build method, you can do so by using the `--method` flag, for example:
-
-```sh
-porter deploy build --app example-app --method docker
-```
-
-When using `--method docker`, you can specify the path to the Dockerfile using the `--dockerfile` flag. This will also override the Dockerfile path that you may have linked for the application:
-
-```sh
-porter deploy build --app example-app --method docker --dockerfile ./prod.Dockerfile
-```
-
-### `porter deploy push`
-
-Pushes a new image for an application specified by the `--app` flag. This command uses the image repository saved in the application config by default. For example, if an application `nginx` was created from the image repo `gcr.io/snowflake-123456/nginx`, the following command would push the image `gcr.io/snowflake-123456/nginx:new-tag`:
-
-```sh
-porter deploy push --app nginx --tag new-tag
-```
-
-This command will not use your pre-saved authentication set up via `docker login`, so if you are using an image registry that was created outside of Porter, make sure that you have linked it via `porter connect`.
-
-### `porter deploy call-webhook`
-
-Calls the webhook for an application specified by the --app flag. This webhook will trigger a new deployment for the application, with the new image set. For example:
-
-```sh
-porter deploy call-webhook --app example-app
-```
-
-This command will by default call the webhook with image tag "latest," but you can specify a different tag with the --tag flag:
-
-```sh
-porter deploy call-webhook --app example-app --tag custom-tag
-```
-
-# Deploying from Local Source
-
-You can choose to deploy from your local filesystem by using the `--local` flag:
-
-```sh
-porter deploy --app example-app --local
-```
-
-This will by default read from the directory that the `porter` command is called from. If you would like to specify a different directory, use the `--path` flag:
-
-```sh
-porter deploy --app example-app --local --path ~/porter/porter-example
-```

+ 9 - 0
internal/forms/integration.go

@@ -66,3 +66,12 @@ func (caf *CreateAWSIntegrationForm) ToAWSIntegration() (*ints.AWSIntegration, e
 		AWSSecretAccessKey: []byte(caf.AWSSecretAccessKey),
 	}, nil
 }
+
+// OverwriteAWSIntegrationForm represents the accepted values for overwriting an
+// AWS Integration
+type OverwriteAWSIntegrationForm struct {
+	UserID             uint   `json:"user_id" form:"required"`
+	ProjectID          uint   `json:"project_id" form:"required"`
+	AWSAccessKeyID     string `json:"aws_access_key_id"`
+	AWSSecretAccessKey string `json:"aws_secret_access_key"`
+}

+ 1002 - 982
internal/kubernetes/agent.go

@@ -1,982 +1,1002 @@
-package kubernetes
-
-import (
-	"bufio"
-	"bytes"
-	"context"
-	"encoding/json"
-	"fmt"
-	"io"
-	"strings"
-
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/ecr"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/eks"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/docr"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/doks"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp"
-	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp/gke"
-	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/models/integrations"
-	"github.com/porter-dev/porter/internal/oauth"
-	"github.com/porter-dev/porter/internal/registry"
-	"github.com/porter-dev/porter/internal/repository"
-	"golang.org/x/oauth2"
-
-	"github.com/gorilla/websocket"
-	"github.com/porter-dev/porter/internal/helm/grapher"
-	appsv1 "k8s.io/api/apps/v1"
-	batchv1 "k8s.io/api/batch/v1"
-	batchv1beta1 "k8s.io/api/batch/v1beta1"
-	v1 "k8s.io/api/core/v1"
-	v1beta1 "k8s.io/api/extensions/v1beta1"
-	"k8s.io/apimachinery/pkg/api/errors"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/runtime"
-	"k8s.io/apimachinery/pkg/runtime/schema"
-	"k8s.io/apimachinery/pkg/types"
-	"k8s.io/cli-runtime/pkg/genericclioptions"
-	"k8s.io/client-go/informers"
-	"k8s.io/client-go/kubernetes"
-	"k8s.io/client-go/rest"
-	"k8s.io/client-go/tools/cache"
-	"k8s.io/client-go/tools/remotecommand"
-
-	"github.com/porter-dev/porter/internal/config"
-)
-
-// Agent is a Kubernetes agent for performing operations that interact with the
-// api server
-type Agent struct {
-	RESTClientGetter genericclioptions.RESTClientGetter
-	Clientset        kubernetes.Interface
-}
-
-type Message struct {
-	EventType string `json:"event_type"`
-	Object    interface{}
-	Kind      string
-}
-
-type ListOptions struct {
-	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{},
-	)
-}
-
-// CreateLinkedSecret creates a secret given the key-value pairs and namespace. Values are
-// base64 encoded
-func (a *Agent) CreateLinkedSecret(name, namespace, cmName string, data map[string][]byte) (*v1.Secret, error) {
-	return a.Clientset.CoreV1().Secrets(namespace).Create(
-		context.TODO(),
-		&v1.Secret{
-			ObjectMeta: metav1.ObjectMeta{
-				Name:      name,
-				Namespace: namespace,
-				Labels: map[string]string{
-					"porter":    "true",
-					"configmap": cmName,
-				},
-			},
-			Data: data,
-		},
-		metav1.CreateOptions{},
-	)
-}
-
-type mergeConfigMapData struct {
-	Data map[string]*string `json:"data"`
-}
-
-// UpdateConfigMap updates the configmap given its name and namespace
-func (a *Agent) UpdateConfigMap(name string, namespace string, configMap map[string]string) error {
-	cmData := make(map[string]*string)
-
-	for key, val := range configMap {
-		valCopy := val
-		cmData[key] = &valCopy
-
-		if len(val) == 0 {
-			cmData[key] = nil
-		}
-	}
-
-	mergeCM := &mergeConfigMapData{
-		Data: cmData,
-	}
-
-	patchBytes, err := json.Marshal(mergeCM)
-
-	if err != nil {
-		return err
-	}
-
-	_, err = a.Clientset.CoreV1().ConfigMaps(namespace).Patch(
-		context.Background(),
-		name,
-		types.MergePatchType,
-		patchBytes,
-		metav1.PatchOptions{},
-	)
-
-	return err
-}
-
-type mergeLinkedSecretData struct {
-	Data map[string]*[]byte `json:"data"`
-}
-
-// UpdateLinkedSecret updates the secret given its name and namespace
-func (a *Agent) UpdateLinkedSecret(name, namespace, cmName string, data map[string][]byte) error {
-	secretData := make(map[string]*[]byte)
-
-	for key, val := range data {
-		valCopy := val
-		secretData[key] = &valCopy
-
-		if len(val) == 0 {
-			secretData[key] = nil
-		}
-	}
-
-	mergeSecret := &mergeLinkedSecretData{
-		Data: secretData,
-	}
-
-	patchBytes, err := json.Marshal(mergeSecret)
-
-	if err != nil {
-		return err
-	}
-
-	_, err = a.Clientset.CoreV1().Secrets(namespace).Patch(
-		context.TODO(),
-		name,
-		types.MergePatchType,
-		patchBytes,
-		metav1.PatchOptions{},
-	)
-
-	return err
-}
-
-// 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{},
-	)
-}
-
-// DeleteLinkedSecret deletes the secret given its name and namespace
-func (a *Agent) DeleteLinkedSecret(name, namespace string) error {
-	return a.Clientset.CoreV1().Secrets(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",
-		},
-	)
-}
-
-// ListEvents lists the events of a given object.
-func (a *Agent) ListEvents(name string, namespace string) (*v1.EventList, error) {
-	return a.Clientset.CoreV1().Events(namespace).List(
-		context.TODO(),
-		metav1.ListOptions{
-			FieldSelector: fmt.Sprintf("involvedObject.name=%s,involvedObject.namespace=%s", name, namespace),
-		},
-	)
-}
-
-// ListNamespaces simply lists namespaces
-func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
-	return a.Clientset.CoreV1().Namespaces().List(
-		context.TODO(),
-		metav1.ListOptions{},
-	)
-}
-
-// CreateNamespace creates a namespace with the given name.
-func (a *Agent) CreateNamespace(name string) (*v1.Namespace, error) {
-	namespace := v1.Namespace{
-		ObjectMeta: metav1.ObjectMeta{
-			Name: name,
-		},
-	}
-
-	return a.Clientset.CoreV1().Namespaces().Create(
-		context.TODO(),
-		&namespace,
-		metav1.CreateOptions{},
-	)
-}
-
-// DeleteNamespace deletes the namespace given the name.
-func (a *Agent) DeleteNamespace(name string) error {
-	return a.Clientset.CoreV1().Namespaces().Delete(
-		context.TODO(),
-		name,
-		metav1.DeleteOptions{},
-	)
-}
-
-// ListJobsByLabel lists jobs in a namespace matching a label
-type Label struct {
-	Key string
-	Val string
-}
-
-func (a *Agent) ListJobsByLabel(namespace string, labels ...Label) ([]batchv1.Job, error) {
-	selectors := make([]string, 0)
-
-	for _, label := range labels {
-		selectors = append(selectors, fmt.Sprintf("%s=%s", label.Key, label.Val))
-	}
-
-	resp, err := a.Clientset.BatchV1().Jobs(namespace).List(
-		context.TODO(),
-		metav1.ListOptions{
-			LabelSelector: strings.Join(selectors, ","),
-		},
-	)
-
-	if err != nil {
-		return nil, err
-	}
-
-	return resp.Items, nil
-}
-
-// GetJobPods lists all pods belonging to a job in a namespace
-func (a *Agent) GetJobPods(namespace, jobName string) ([]v1.Pod, error) {
-	resp, err := a.Clientset.CoreV1().Pods(namespace).List(
-		context.TODO(),
-		metav1.ListOptions{
-			LabelSelector: fmt.Sprintf("%s=%s", "job-name", jobName),
-		},
-	)
-
-	if err != nil {
-		return nil, err
-	}
-
-	return resp.Items, nil
-}
-
-// GetIngress gets ingress given the name and namespace
-func (a *Agent) GetIngress(namespace string, name string) (*v1beta1.Ingress, error) {
-	return a.Clientset.ExtensionsV1beta1().Ingresses(namespace).Get(
-		context.TODO(),
-		name,
-		metav1.GetOptions{},
-	)
-}
-
-// GetDeployment gets the deployment given the name and namespace
-func (a *Agent) GetDeployment(c grapher.Object) (*appsv1.Deployment, error) {
-	return a.Clientset.AppsV1().Deployments(c.Namespace).Get(
-		context.TODO(),
-		c.Name,
-		metav1.GetOptions{},
-	)
-}
-
-// GetStatefulSet gets the statefulset given the name and namespace
-func (a *Agent) GetStatefulSet(c grapher.Object) (*appsv1.StatefulSet, error) {
-	return a.Clientset.AppsV1().StatefulSets(c.Namespace).Get(
-		context.TODO(),
-		c.Name,
-		metav1.GetOptions{},
-	)
-}
-
-// GetReplicaSet gets the replicaset given the name and namespace
-func (a *Agent) GetReplicaSet(c grapher.Object) (*appsv1.ReplicaSet, error) {
-	return a.Clientset.AppsV1().ReplicaSets(c.Namespace).Get(
-		context.TODO(),
-		c.Name,
-		metav1.GetOptions{},
-	)
-}
-
-// GetDaemonSet gets the daemonset by name and namespace
-func (a *Agent) GetDaemonSet(c grapher.Object) (*appsv1.DaemonSet, error) {
-	return a.Clientset.AppsV1().DaemonSets(c.Namespace).Get(
-		context.TODO(),
-		c.Name,
-		metav1.GetOptions{},
-	)
-}
-
-// GetJob gets the job by name and namespace
-func (a *Agent) GetJob(c grapher.Object) (*batchv1.Job, error) {
-	return a.Clientset.BatchV1().Jobs(c.Namespace).Get(
-		context.TODO(),
-		c.Name,
-		metav1.GetOptions{},
-	)
-}
-
-// GetCronJob gets the CronJob by name and namespace
-func (a *Agent) GetCronJob(c grapher.Object) (*batchv1beta1.CronJob, error) {
-	return a.Clientset.BatchV1beta1().CronJobs(c.Namespace).Get(
-		context.TODO(),
-		c.Name,
-		metav1.GetOptions{},
-	)
-}
-
-// GetPodsByLabel retrieves pods with matching labels
-func (a *Agent) GetPodsByLabel(selector string, namespace string) (*v1.PodList, error) {
-	// Search in all namespaces for matching pods
-	return a.Clientset.CoreV1().Pods(namespace).List(
-		context.TODO(),
-		metav1.ListOptions{
-			LabelSelector: selector,
-		},
-	)
-}
-
-// DeletePod deletes a pod by name and namespace
-func (a *Agent) DeletePod(namespace string, name string) error {
-	return a.Clientset.CoreV1().Pods(namespace).Delete(
-		context.TODO(),
-		name,
-		metav1.DeleteOptions{},
-	)
-}
-
-// GetPodLogs streams real-time logs from a given pod.
-func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn) error {
-	// get the pod to read in the list of contains
-	pod, err := a.Clientset.CoreV1().Pods(namespace).Get(
-		context.Background(),
-		name,
-		metav1.GetOptions{},
-	)
-
-	if err != nil {
-		return fmt.Errorf("Cannot get pod %s: %s", name, err.Error())
-	}
-
-	container := pod.Spec.Containers[0].Name
-
-	tails := int64(400)
-
-	// follow logs
-	podLogOpts := v1.PodLogOptions{
-		Follow:    true,
-		TailLines: &tails,
-		Container: container,
-	}
-
-	req := a.Clientset.CoreV1().Pods(namespace).GetLogs(name, &podLogOpts)
-
-	podLogs, err := req.Stream(context.TODO())
-
-	if err != nil {
-		return fmt.Errorf("Cannot open log stream for pod %s: %s", name, err.Error())
-	}
-	defer podLogs.Close()
-
-	r := bufio.NewReader(podLogs)
-	errorchan := make(chan error)
-
-	go func() {
-		// listens for websocket closing handshake
-		for {
-			if _, _, err := conn.ReadMessage(); err != nil {
-				defer conn.Close()
-				errorchan <- nil
-				return
-			}
-		}
-	}()
-
-	go func() {
-		for {
-			select {
-			case <-errorchan:
-				defer close(errorchan)
-				return
-			default:
-			}
-
-			bytes, err := r.ReadBytes('\n')
-			if writeErr := conn.WriteMessage(websocket.TextMessage, bytes); writeErr != nil {
-				errorchan <- writeErr
-				return
-			}
-			if err != nil {
-				if err != io.EOF {
-					errorchan <- err
-					return
-				}
-				errorchan <- nil
-				return
-			}
-		}
-	}()
-
-	for {
-		select {
-		case err = <-errorchan:
-			return err
-		}
-	}
-}
-
-// StopJobWithJobSidecar sends a termination signal to a job running with a sidecar
-func (a *Agent) StopJobWithJobSidecar(namespace, name string) error {
-	jobPods, err := a.GetJobPods(namespace, name)
-
-	if err != nil {
-		return err
-	}
-
-	podName := jobPods[0].ObjectMeta.Name
-
-	restConf, err := a.RESTClientGetter.ToRESTConfig()
-
-	restConf.GroupVersion = &schema.GroupVersion{
-		Group:   "api",
-		Version: "v1",
-	}
-
-	restConf.NegotiatedSerializer = runtime.NewSimpleNegotiatedSerializer(runtime.SerializerInfo{})
-
-	restClient, err := rest.RESTClientFor(restConf)
-
-	if err != nil {
-		return err
-	}
-
-	req := restClient.Post().
-		Resource("pods").
-		Name(podName).
-		Namespace(namespace).
-		SubResource("exec")
-
-	req.Param("command", "./signal.sh")
-	req.Param("container", "sidecar")
-	req.Param("stdin", "true")
-	req.Param("stdout", "false")
-	req.Param("tty", "false")
-
-	exec, err := remotecommand.NewSPDYExecutor(restConf, "POST", req.URL())
-
-	if err != nil {
-		return err
-	}
-
-	return exec.Stream(remotecommand.StreamOptions{
-		Tty:   false,
-		Stdin: strings.NewReader("./signal.sh"),
-	})
-}
-
-// StreamControllerStatus streams controller status. Supports Deployment, StatefulSet, ReplicaSet, and DaemonSet
-// TODO: Support Jobs
-func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string) error {
-	factory := informers.NewSharedInformerFactory(
-		a.Clientset,
-		0,
-	)
-
-	var informer cache.SharedInformer
-
-	// Spins up an informer depending on kind. Convert to lowercase for robustness
-	switch strings.ToLower(kind) {
-	case "deployment":
-		informer = factory.Apps().V1().Deployments().Informer()
-	case "statefulset":
-		informer = factory.Apps().V1().StatefulSets().Informer()
-	case "replicaset":
-		informer = factory.Apps().V1().ReplicaSets().Informer()
-	case "daemonset":
-		informer = factory.Apps().V1().DaemonSets().Informer()
-	case "job":
-		informer = factory.Batch().V1().Jobs().Informer()
-	case "cronjob":
-		informer = factory.Batch().V1beta1().CronJobs().Informer()
-	}
-
-	stopper := make(chan struct{})
-	errorchan := make(chan error)
-	defer close(errorchan)
-
-	informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
-		UpdateFunc: func(oldObj, newObj interface{}) {
-			msg := Message{
-				EventType: "UPDATE",
-				Object:    newObj,
-				Kind:      strings.ToLower(kind),
-			}
-			if writeErr := conn.WriteJSON(msg); writeErr != nil {
-				errorchan <- writeErr
-				return
-			}
-		},
-		AddFunc: func(obj interface{}) {
-			msg := Message{
-				EventType: "ADD",
-				Object:    obj,
-				Kind:      strings.ToLower(kind),
-			}
-
-			if writeErr := conn.WriteJSON(msg); writeErr != nil {
-				errorchan <- writeErr
-				return
-			}
-		},
-		DeleteFunc: func(obj interface{}) {
-			msg := Message{
-				EventType: "DELETE",
-				Object:    obj,
-				Kind:      strings.ToLower(kind),
-			}
-
-			if writeErr := conn.WriteJSON(msg); writeErr != nil {
-				errorchan <- writeErr
-				return
-			}
-		},
-	})
-
-	go func() {
-		// listens for websocket closing handshake
-		for {
-			if _, _, err := conn.ReadMessage(); err != nil {
-				defer conn.Close()
-				defer close(stopper)
-				errorchan <- nil
-				return
-			}
-		}
-	}()
-
-	go informer.Run(stopper)
-
-	for {
-		select {
-		case err := <-errorchan:
-			return err
-		}
-	}
-}
-
-// ProvisionECR spawns a new provisioning pod that creates an ECR instance
-func (a *Agent) ProvisionECR(
-	projectID uint,
-	awsConf *integrations.AWSIntegration,
-	ecrName string,
-	repo repository.Repository,
-	infra *models.Infra,
-	operation provisioner.ProvisionerOperation,
-	pgConf *config.DBConf,
-	redisConf *config.RedisConf,
-	provImageTag string,
-) (*batchv1.Job, error) {
-	id := infra.GetUniqueName()
-	prov := &provisioner.Conf{
-		ID:                  id,
-		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
-		Kind:                provisioner.ECR,
-		Operation:           operation,
-		Redis:               redisConf,
-		Postgres:            pgConf,
-		ProvisionerImageTag: provImageTag,
-		LastApplied:         infra.LastApplied,
-		AWS: &aws.Conf{
-			AWSRegion:          awsConf.AWSRegion,
-			AWSAccessKeyID:     string(awsConf.AWSAccessKeyID),
-			AWSSecretAccessKey: string(awsConf.AWSSecretAccessKey),
-		},
-		ECR: &ecr.Conf{
-			ECRName: ecrName,
-		},
-	}
-
-	return a.provision(prov, infra, repo)
-}
-
-// ProvisionEKS spawns a new provisioning pod that creates an EKS instance
-func (a *Agent) ProvisionEKS(
-	projectID uint,
-	awsConf *integrations.AWSIntegration,
-	eksName, machineType string,
-	repo repository.Repository,
-	infra *models.Infra,
-	operation provisioner.ProvisionerOperation,
-	pgConf *config.DBConf,
-	redisConf *config.RedisConf,
-	provImageTag string,
-) (*batchv1.Job, error) {
-	id := infra.GetUniqueName()
-	prov := &provisioner.Conf{
-		ID:                  id,
-		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
-		Kind:                provisioner.EKS,
-		Operation:           operation,
-		Redis:               redisConf,
-		Postgres:            pgConf,
-		ProvisionerImageTag: provImageTag,
-		LastApplied:         infra.LastApplied,
-		AWS: &aws.Conf{
-			AWSRegion:          awsConf.AWSRegion,
-			AWSAccessKeyID:     string(awsConf.AWSAccessKeyID),
-			AWSSecretAccessKey: string(awsConf.AWSSecretAccessKey),
-		},
-		EKS: &eks.Conf{
-			ClusterName: eksName,
-			MachineType: machineType,
-		},
-	}
-
-	return a.provision(prov, infra, repo)
-}
-
-// ProvisionGCR spawns a new provisioning pod that creates a GCR instance
-func (a *Agent) ProvisionGCR(
-	projectID uint,
-	gcpConf *integrations.GCPIntegration,
-	repo repository.Repository,
-	infra *models.Infra,
-	operation provisioner.ProvisionerOperation,
-	pgConf *config.DBConf,
-	redisConf *config.RedisConf,
-	provImageTag string,
-) (*batchv1.Job, error) {
-	id := infra.GetUniqueName()
-	prov := &provisioner.Conf{
-		ID:                  id,
-		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
-		Kind:                provisioner.GCR,
-		Operation:           operation,
-		Redis:               redisConf,
-		Postgres:            pgConf,
-		ProvisionerImageTag: provImageTag,
-		LastApplied:         infra.LastApplied,
-		GCP: &gcp.Conf{
-			GCPRegion:    gcpConf.GCPRegion,
-			GCPProjectID: gcpConf.GCPProjectID,
-			GCPKeyData:   string(gcpConf.GCPKeyData),
-		},
-	}
-
-	return a.provision(prov, infra, repo)
-}
-
-// ProvisionGKE spawns a new provisioning pod that creates a GKE instance
-func (a *Agent) ProvisionGKE(
-	projectID uint,
-	gcpConf *integrations.GCPIntegration,
-	gkeName string,
-	repo repository.Repository,
-	infra *models.Infra,
-	operation provisioner.ProvisionerOperation,
-	pgConf *config.DBConf,
-	redisConf *config.RedisConf,
-	provImageTag string,
-) (*batchv1.Job, error) {
-	id := infra.GetUniqueName()
-	prov := &provisioner.Conf{
-		ID:                  id,
-		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
-		Kind:                provisioner.GKE,
-		Operation:           operation,
-		Redis:               redisConf,
-		Postgres:            pgConf,
-		ProvisionerImageTag: provImageTag,
-		LastApplied:         infra.LastApplied,
-		GCP: &gcp.Conf{
-			GCPRegion:    gcpConf.GCPRegion,
-			GCPProjectID: gcpConf.GCPProjectID,
-			GCPKeyData:   string(gcpConf.GCPKeyData),
-		},
-		GKE: &gke.Conf{
-			ClusterName: gkeName,
-		},
-	}
-
-	return a.provision(prov, infra, repo)
-}
-
-// ProvisionDOCR spawns a new provisioning pod that creates a DOCR instance
-func (a *Agent) ProvisionDOCR(
-	projectID uint,
-	doConf *integrations.OAuthIntegration,
-	doAuth *oauth2.Config,
-	repo repository.Repository,
-	docrName, docrSubscriptionTier string,
-	infra *models.Infra,
-	operation provisioner.ProvisionerOperation,
-	pgConf *config.DBConf,
-	redisConf *config.RedisConf,
-	provImageTag string,
-) (*batchv1.Job, error) {
-	// get the token
-	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
-		infra.DOIntegrationID,
-	)
-
-	if err != nil {
-		return nil, err
-	}
-
-	tok, _, err := oauth.GetAccessToken(oauthInt, doAuth, repo)
-
-	if err != nil {
-		return nil, err
-	}
-
-	id := infra.GetUniqueName()
-	prov := &provisioner.Conf{
-		ID:                  id,
-		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
-		Kind:                provisioner.DOCR,
-		Operation:           operation,
-		Redis:               redisConf,
-		Postgres:            pgConf,
-		ProvisionerImageTag: provImageTag,
-		LastApplied:         infra.LastApplied,
-		DO: &do.Conf{
-			DOToken: tok,
-		},
-		DOCR: &docr.Conf{
-			DOCRName:             docrName,
-			DOCRSubscriptionTier: docrSubscriptionTier,
-		},
-	}
-
-	return a.provision(prov, infra, repo)
-}
-
-// ProvisionDOKS spawns a new provisioning pod that creates a DOKS instance
-func (a *Agent) ProvisionDOKS(
-	projectID uint,
-	doConf *integrations.OAuthIntegration,
-	doAuth *oauth2.Config,
-	repo repository.Repository,
-	doRegion, doksClusterName string,
-	infra *models.Infra,
-	operation provisioner.ProvisionerOperation,
-	pgConf *config.DBConf,
-	redisConf *config.RedisConf,
-	provImageTag string,
-) (*batchv1.Job, error) {
-	// get the token
-	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
-		infra.DOIntegrationID,
-	)
-
-	if err != nil {
-		return nil, err
-	}
-
-	tok, _, err := oauth.GetAccessToken(oauthInt, doAuth, repo)
-
-	if err != nil {
-		return nil, err
-	}
-
-	id := infra.GetUniqueName()
-	prov := &provisioner.Conf{
-		ID:                  id,
-		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
-		Kind:                provisioner.DOKS,
-		Operation:           operation,
-		Redis:               redisConf,
-		Postgres:            pgConf,
-		LastApplied:         infra.LastApplied,
-		ProvisionerImageTag: provImageTag,
-		DO: &do.Conf{
-			DOToken: tok,
-		},
-		DOKS: &doks.Conf{
-			DORegion:        doRegion,
-			DOKSClusterName: doksClusterName,
-		},
-	}
-
-	return a.provision(prov, infra, repo)
-}
-
-// ProvisionTest spawns a new provisioning pod that tests provisioning
-func (a *Agent) ProvisionTest(
-	projectID uint,
-	infra *models.Infra,
-	repo repository.Repository,
-	operation provisioner.ProvisionerOperation,
-	pgConf *config.DBConf,
-	redisConf *config.RedisConf,
-	provImageTag string,
-) (*batchv1.Job, error) {
-	id := infra.GetUniqueName()
-
-	prov := &provisioner.Conf{
-		ID:                  id,
-		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
-		Operation:           operation,
-		Kind:                provisioner.Test,
-		Redis:               redisConf,
-		Postgres:            pgConf,
-		ProvisionerImageTag: provImageTag,
-	}
-
-	return a.provision(prov, infra, repo)
-}
-
-func (a *Agent) provision(
-	prov *provisioner.Conf,
-	infra *models.Infra,
-	repo repository.Repository,
-) (*batchv1.Job, error) {
-	prov.Namespace = "default"
-
-	job, err := prov.GetProvisionerJobTemplate()
-
-	if err != nil {
-		return nil, err
-	}
-
-	job, err = a.Clientset.BatchV1().Jobs(prov.Namespace).Create(
-		context.TODO(),
-		job,
-		metav1.CreateOptions{},
-	)
-
-	if err != nil {
-		return nil, err
-	}
-
-	infra.LastApplied = prov.LastApplied
-	infra, err = repo.Infra.UpdateInfra(infra)
-
-	if err != nil {
-		return nil, err
-	}
-
-	return job, nil
-}
-
-// CreateImagePullSecrets will create the required image pull secrets and
-// return a map from the registry name to the name of the secret.
-func (a *Agent) CreateImagePullSecrets(
-	repo repository.Repository,
-	namespace string,
-	linkedRegs map[string]*models.Registry,
-	doAuth *oauth2.Config,
-) (map[string]string, error) {
-	res := make(map[string]string)
-
-	for key, val := range linkedRegs {
-		_reg := registry.Registry(*val)
-
-		data, err := _reg.GetDockerConfigJSON(repo, doAuth)
-
-		if err != nil {
-			return nil, err
-		}
-
-		secretName := fmt.Sprintf("porter-%s-%d", val.Externalize().Service, val.ID)
-
-		secret, err := a.Clientset.CoreV1().Secrets(namespace).Get(
-			context.TODO(),
-			secretName,
-			metav1.GetOptions{},
-		)
-
-		// if not found, create the secret
-		if err != nil && errors.IsNotFound(err) {
-			_, err = a.Clientset.CoreV1().Secrets(namespace).Create(
-				context.TODO(),
-				&v1.Secret{
-					ObjectMeta: metav1.ObjectMeta{
-						Name: secretName,
-					},
-					Data: map[string][]byte{
-						string(v1.DockerConfigJsonKey): data,
-					},
-					Type: v1.SecretTypeDockerConfigJson,
-				},
-				metav1.CreateOptions{},
-			)
-
-			if err != nil {
-				return nil, err
-			}
-
-			// add secret name to the map
-			res[key] = secretName
-
-			continue
-		} else if err != nil {
-			return nil, err
-		}
-
-		// otherwise, check that the secret contains the correct data: if
-		// if doesn't, update it
-		if !bytes.Equal(secret.Data[v1.DockerConfigJsonKey], data) {
-			_, err := a.Clientset.CoreV1().Secrets(namespace).Update(
-				context.TODO(),
-				&v1.Secret{
-					ObjectMeta: metav1.ObjectMeta{
-						Name: secretName,
-					},
-					Data: map[string][]byte{
-						string(v1.DockerConfigJsonKey): data,
-					},
-					Type: v1.SecretTypeDockerConfigJson,
-				},
-				metav1.UpdateOptions{},
-			)
-
-			if err != nil {
-				return nil, err
-			}
-		}
-
-		// add secret name to the map
-		res[key] = secretName
-	}
-
-	return res, nil
-}
+package kubernetes
+
+import (
+	"bufio"
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/ecr"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/eks"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/docr"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/doks"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp/gke"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/oauth"
+	"github.com/porter-dev/porter/internal/registry"
+	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/oauth2"
+
+	"github.com/gorilla/websocket"
+	"github.com/porter-dev/porter/internal/helm/grapher"
+	appsv1 "k8s.io/api/apps/v1"
+	batchv1 "k8s.io/api/batch/v1"
+	batchv1beta1 "k8s.io/api/batch/v1beta1"
+	v1 "k8s.io/api/core/v1"
+	v1beta1 "k8s.io/api/extensions/v1beta1"
+	"k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/cli-runtime/pkg/genericclioptions"
+	"k8s.io/client-go/informers"
+	"k8s.io/client-go/kubernetes"
+	"k8s.io/client-go/rest"
+	"k8s.io/client-go/tools/cache"
+	"k8s.io/client-go/tools/remotecommand"
+
+	"github.com/porter-dev/porter/internal/config"
+)
+
+// Agent is a Kubernetes agent for performing operations that interact with the
+// api server
+type Agent struct {
+	RESTClientGetter genericclioptions.RESTClientGetter
+	Clientset        kubernetes.Interface
+}
+
+type Message struct {
+	EventType string `json:"event_type"`
+	Object    interface{}
+	Kind      string
+}
+
+type ListOptions struct {
+	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{},
+	)
+}
+
+// CreateLinkedSecret creates a secret given the key-value pairs and namespace. Values are
+// base64 encoded
+func (a *Agent) CreateLinkedSecret(name, namespace, cmName string, data map[string][]byte) (*v1.Secret, error) {
+	return a.Clientset.CoreV1().Secrets(namespace).Create(
+		context.TODO(),
+		&v1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      name,
+				Namespace: namespace,
+				Labels: map[string]string{
+					"porter":    "true",
+					"configmap": cmName,
+				},
+			},
+			Data: data,
+		},
+		metav1.CreateOptions{},
+	)
+}
+
+type mergeConfigMapData struct {
+	Data map[string]*string `json:"data"`
+}
+
+// UpdateConfigMap updates the configmap given its name and namespace
+func (a *Agent) UpdateConfigMap(name string, namespace string, configMap map[string]string) error {
+	cmData := make(map[string]*string)
+
+	for key, val := range configMap {
+		valCopy := val
+		cmData[key] = &valCopy
+
+		if len(val) == 0 {
+			cmData[key] = nil
+		}
+	}
+
+	mergeCM := &mergeConfigMapData{
+		Data: cmData,
+	}
+
+	patchBytes, err := json.Marshal(mergeCM)
+
+	if err != nil {
+		return err
+	}
+
+	_, err = a.Clientset.CoreV1().ConfigMaps(namespace).Patch(
+		context.Background(),
+		name,
+		types.MergePatchType,
+		patchBytes,
+		metav1.PatchOptions{},
+	)
+
+	return err
+}
+
+type mergeLinkedSecretData struct {
+	Data map[string]*[]byte `json:"data"`
+}
+
+// UpdateLinkedSecret updates the secret given its name and namespace
+func (a *Agent) UpdateLinkedSecret(name, namespace, cmName string, data map[string][]byte) error {
+	secretData := make(map[string]*[]byte)
+
+	for key, val := range data {
+		valCopy := val
+		secretData[key] = &valCopy
+
+		if len(val) == 0 {
+			secretData[key] = nil
+		}
+	}
+
+	mergeSecret := &mergeLinkedSecretData{
+		Data: secretData,
+	}
+
+	patchBytes, err := json.Marshal(mergeSecret)
+
+	if err != nil {
+		return err
+	}
+
+	_, err = a.Clientset.CoreV1().Secrets(namespace).Patch(
+		context.TODO(),
+		name,
+		types.MergePatchType,
+		patchBytes,
+		metav1.PatchOptions{},
+	)
+
+	return err
+}
+
+// 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{},
+	)
+}
+
+// DeleteLinkedSecret deletes the secret given its name and namespace
+func (a *Agent) DeleteLinkedSecret(name, namespace string) error {
+	return a.Clientset.CoreV1().Secrets(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",
+		},
+	)
+}
+
+// ListEvents lists the events of a given object.
+func (a *Agent) ListEvents(name string, namespace string) (*v1.EventList, error) {
+	return a.Clientset.CoreV1().Events(namespace).List(
+		context.TODO(),
+		metav1.ListOptions{
+			FieldSelector: fmt.Sprintf("involvedObject.name=%s,involvedObject.namespace=%s", name, namespace),
+		},
+	)
+}
+
+// ListNamespaces simply lists namespaces
+func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
+	return a.Clientset.CoreV1().Namespaces().List(
+		context.TODO(),
+		metav1.ListOptions{},
+	)
+}
+
+// CreateNamespace creates a namespace with the given name.
+func (a *Agent) CreateNamespace(name string) (*v1.Namespace, error) {
+	namespace := v1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: name,
+		},
+	}
+
+	return a.Clientset.CoreV1().Namespaces().Create(
+		context.TODO(),
+		&namespace,
+		metav1.CreateOptions{},
+	)
+}
+
+// DeleteNamespace deletes the namespace given the name.
+func (a *Agent) DeleteNamespace(name string) error {
+	return a.Clientset.CoreV1().Namespaces().Delete(
+		context.TODO(),
+		name,
+		metav1.DeleteOptions{},
+	)
+}
+
+// ListJobsByLabel lists jobs in a namespace matching a label
+type Label struct {
+	Key string
+	Val string
+}
+
+func (a *Agent) ListJobsByLabel(namespace string, labels ...Label) ([]batchv1.Job, error) {
+	selectors := make([]string, 0)
+
+	for _, label := range labels {
+		selectors = append(selectors, fmt.Sprintf("%s=%s", label.Key, label.Val))
+	}
+
+	resp, err := a.Clientset.BatchV1().Jobs(namespace).List(
+		context.TODO(),
+		metav1.ListOptions{
+			LabelSelector: strings.Join(selectors, ","),
+		},
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return resp.Items, nil
+}
+
+// DeleteJob deletes the job in the given name and namespace.
+func (a *Agent) DeleteJob(name, namespace string) error {
+	return a.Clientset.BatchV1().Jobs(namespace).Delete(
+		context.TODO(),
+		name,
+		metav1.DeleteOptions{},
+	)
+}
+
+// GetJobPods lists all pods belonging to a job in a namespace
+func (a *Agent) GetJobPods(namespace, jobName string) ([]v1.Pod, error) {
+	resp, err := a.Clientset.CoreV1().Pods(namespace).List(
+		context.TODO(),
+		metav1.ListOptions{
+			LabelSelector: fmt.Sprintf("%s=%s", "job-name", jobName),
+		},
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return resp.Items, nil
+}
+
+// GetIngress gets ingress given the name and namespace
+func (a *Agent) GetIngress(namespace string, name string) (*v1beta1.Ingress, error) {
+	return a.Clientset.ExtensionsV1beta1().Ingresses(namespace).Get(
+		context.TODO(),
+		name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetDeployment gets the deployment given the name and namespace
+func (a *Agent) GetDeployment(c grapher.Object) (*appsv1.Deployment, error) {
+	return a.Clientset.AppsV1().Deployments(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetStatefulSet gets the statefulset given the name and namespace
+func (a *Agent) GetStatefulSet(c grapher.Object) (*appsv1.StatefulSet, error) {
+	return a.Clientset.AppsV1().StatefulSets(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetReplicaSet gets the replicaset given the name and namespace
+func (a *Agent) GetReplicaSet(c grapher.Object) (*appsv1.ReplicaSet, error) {
+	return a.Clientset.AppsV1().ReplicaSets(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetDaemonSet gets the daemonset by name and namespace
+func (a *Agent) GetDaemonSet(c grapher.Object) (*appsv1.DaemonSet, error) {
+	return a.Clientset.AppsV1().DaemonSets(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetJob gets the job by name and namespace
+func (a *Agent) GetJob(c grapher.Object) (*batchv1.Job, error) {
+	return a.Clientset.BatchV1().Jobs(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetCronJob gets the CronJob by name and namespace
+func (a *Agent) GetCronJob(c grapher.Object) (*batchv1beta1.CronJob, error) {
+	return a.Clientset.BatchV1beta1().CronJobs(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetPodsByLabel retrieves pods with matching labels
+func (a *Agent) GetPodsByLabel(selector string, namespace string) (*v1.PodList, error) {
+	// Search in all namespaces for matching pods
+	return a.Clientset.CoreV1().Pods(namespace).List(
+		context.TODO(),
+		metav1.ListOptions{
+			LabelSelector: selector,
+		},
+	)
+}
+
+// DeletePod deletes a pod by name and namespace
+func (a *Agent) DeletePod(namespace string, name string) error {
+	return a.Clientset.CoreV1().Pods(namespace).Delete(
+		context.TODO(),
+		name,
+		metav1.DeleteOptions{},
+	)
+}
+
+// GetPodLogs streams real-time logs from a given pod.
+func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn) error {
+	// get the pod to read in the list of contains
+	pod, err := a.Clientset.CoreV1().Pods(namespace).Get(
+		context.Background(),
+		name,
+		metav1.GetOptions{},
+	)
+
+	if err != nil {
+		return fmt.Errorf("Cannot get pod %s: %s", name, err.Error())
+	}
+
+	container := pod.Spec.Containers[0].Name
+
+	tails := int64(400)
+
+	// follow logs
+	podLogOpts := v1.PodLogOptions{
+		Follow:    true,
+		TailLines: &tails,
+		Container: container,
+	}
+
+	req := a.Clientset.CoreV1().Pods(namespace).GetLogs(name, &podLogOpts)
+
+	podLogs, err := req.Stream(context.TODO())
+
+	if err != nil {
+		return fmt.Errorf("Cannot open log stream for pod %s: %s", name, err.Error())
+	}
+	defer podLogs.Close()
+
+	r := bufio.NewReader(podLogs)
+	errorchan := make(chan error)
+
+	go func() {
+		// listens for websocket closing handshake
+		for {
+			if _, _, err := conn.ReadMessage(); err != nil {
+				defer conn.Close()
+				errorchan <- nil
+				return
+			}
+		}
+	}()
+
+	go func() {
+		for {
+			select {
+			case <-errorchan:
+				defer close(errorchan)
+				return
+			default:
+			}
+
+			bytes, err := r.ReadBytes('\n')
+			if writeErr := conn.WriteMessage(websocket.TextMessage, bytes); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+			if err != nil {
+				if err != io.EOF {
+					errorchan <- err
+					return
+				}
+				errorchan <- nil
+				return
+			}
+		}
+	}()
+
+	for {
+		select {
+		case err = <-errorchan:
+			return err
+		}
+	}
+}
+
+// StopJobWithJobSidecar sends a termination signal to a job running with a sidecar
+func (a *Agent) StopJobWithJobSidecar(namespace, name string) error {
+	jobPods, err := a.GetJobPods(namespace, name)
+
+	if err != nil {
+		return err
+	}
+
+	podName := jobPods[0].ObjectMeta.Name
+
+	restConf, err := a.RESTClientGetter.ToRESTConfig()
+
+	restConf.GroupVersion = &schema.GroupVersion{
+		Group:   "api",
+		Version: "v1",
+	}
+
+	restConf.NegotiatedSerializer = runtime.NewSimpleNegotiatedSerializer(runtime.SerializerInfo{})
+
+	restClient, err := rest.RESTClientFor(restConf)
+
+	if err != nil {
+		return err
+	}
+
+	req := restClient.Post().
+		Resource("pods").
+		Name(podName).
+		Namespace(namespace).
+		SubResource("exec")
+
+	req.Param("command", "./signal.sh")
+	req.Param("container", "sidecar")
+	req.Param("stdin", "true")
+	req.Param("stdout", "false")
+	req.Param("tty", "false")
+
+	exec, err := remotecommand.NewSPDYExecutor(restConf, "POST", req.URL())
+
+	if err != nil {
+		return err
+	}
+
+	return exec.Stream(remotecommand.StreamOptions{
+		Tty:   false,
+		Stdin: strings.NewReader("./signal.sh"),
+	})
+}
+
+// StreamControllerStatus streams controller status. Supports Deployment, StatefulSet, ReplicaSet, and DaemonSet
+// TODO: Support Jobs
+func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string, selectors string) error {
+	// selectors is an array of max length 1. StreamControllerStatus accepts calls without the selectors argument.
+	// selectors argument is a single string with comma separated key=value pairs. (e.g. "app=porter,porter=true")
+	tweakListOptionsFunc := func(options *metav1.ListOptions) {
+		options.LabelSelector = selectors
+	}
+
+	factory := informers.NewSharedInformerFactoryWithOptions(
+		a.Clientset,
+		0,
+		informers.WithTweakListOptions(tweakListOptionsFunc),
+	)
+
+	var informer cache.SharedInformer
+
+	// Spins up an informer depending on kind. Convert to lowercase for robustness
+	switch strings.ToLower(kind) {
+	case "deployment":
+		informer = factory.Apps().V1().Deployments().Informer()
+	case "statefulset":
+		informer = factory.Apps().V1().StatefulSets().Informer()
+	case "replicaset":
+		informer = factory.Apps().V1().ReplicaSets().Informer()
+	case "daemonset":
+		informer = factory.Apps().V1().DaemonSets().Informer()
+	case "job":
+		informer = factory.Batch().V1().Jobs().Informer()
+	case "cronjob":
+		informer = factory.Batch().V1beta1().CronJobs().Informer()
+	case "namespace":
+		informer = factory.Core().V1().Namespaces().Informer()
+	case "pod":
+		informer = factory.Core().V1().Pods().Informer()
+	}
+
+	stopper := make(chan struct{})
+	errorchan := make(chan error)
+	defer close(errorchan)
+
+	informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
+		UpdateFunc: func(oldObj, newObj interface{}) {
+			msg := Message{
+				EventType: "UPDATE",
+				Object:    newObj,
+				Kind:      strings.ToLower(kind),
+			}
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		},
+		AddFunc: func(obj interface{}) {
+			msg := Message{
+				EventType: "ADD",
+				Object:    obj,
+				Kind:      strings.ToLower(kind),
+			}
+
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		},
+		DeleteFunc: func(obj interface{}) {
+			msg := Message{
+				EventType: "DELETE",
+				Object:    obj,
+				Kind:      strings.ToLower(kind),
+			}
+
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		},
+	})
+
+	go func() {
+		// listens for websocket closing handshake
+		for {
+			if _, _, err := conn.ReadMessage(); err != nil {
+				defer conn.Close()
+				defer close(stopper)
+				errorchan <- nil
+				return
+			}
+		}
+	}()
+
+	go informer.Run(stopper)
+
+	for {
+		select {
+		case err := <-errorchan:
+			return err
+		}
+	}
+}
+
+// ProvisionECR spawns a new provisioning pod that creates an ECR instance
+func (a *Agent) ProvisionECR(
+	projectID uint,
+	awsConf *integrations.AWSIntegration,
+	ecrName string,
+	repo repository.Repository,
+	infra *models.Infra,
+	operation provisioner.ProvisionerOperation,
+	pgConf *config.DBConf,
+	redisConf *config.RedisConf,
+	provImageTag string,
+) (*batchv1.Job, error) {
+	id := infra.GetUniqueName()
+	prov := &provisioner.Conf{
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Kind:                provisioner.ECR,
+		Operation:           operation,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		ProvisionerImageTag: provImageTag,
+		LastApplied:         infra.LastApplied,
+		AWS: &aws.Conf{
+			AWSRegion:          awsConf.AWSRegion,
+			AWSAccessKeyID:     string(awsConf.AWSAccessKeyID),
+			AWSSecretAccessKey: string(awsConf.AWSSecretAccessKey),
+		},
+		ECR: &ecr.Conf{
+			ECRName: ecrName,
+		},
+	}
+
+	return a.provision(prov, infra, repo)
+}
+
+// ProvisionEKS spawns a new provisioning pod that creates an EKS instance
+func (a *Agent) ProvisionEKS(
+	projectID uint,
+	awsConf *integrations.AWSIntegration,
+	eksName, machineType string,
+	repo repository.Repository,
+	infra *models.Infra,
+	operation provisioner.ProvisionerOperation,
+	pgConf *config.DBConf,
+	redisConf *config.RedisConf,
+	provImageTag string,
+) (*batchv1.Job, error) {
+	id := infra.GetUniqueName()
+	prov := &provisioner.Conf{
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Kind:                provisioner.EKS,
+		Operation:           operation,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		ProvisionerImageTag: provImageTag,
+		LastApplied:         infra.LastApplied,
+		AWS: &aws.Conf{
+			AWSRegion:          awsConf.AWSRegion,
+			AWSAccessKeyID:     string(awsConf.AWSAccessKeyID),
+			AWSSecretAccessKey: string(awsConf.AWSSecretAccessKey),
+		},
+		EKS: &eks.Conf{
+			ClusterName: eksName,
+			MachineType: machineType,
+		},
+	}
+
+	return a.provision(prov, infra, repo)
+}
+
+// ProvisionGCR spawns a new provisioning pod that creates a GCR instance
+func (a *Agent) ProvisionGCR(
+	projectID uint,
+	gcpConf *integrations.GCPIntegration,
+	repo repository.Repository,
+	infra *models.Infra,
+	operation provisioner.ProvisionerOperation,
+	pgConf *config.DBConf,
+	redisConf *config.RedisConf,
+	provImageTag string,
+) (*batchv1.Job, error) {
+	id := infra.GetUniqueName()
+	prov := &provisioner.Conf{
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Kind:                provisioner.GCR,
+		Operation:           operation,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		ProvisionerImageTag: provImageTag,
+		LastApplied:         infra.LastApplied,
+		GCP: &gcp.Conf{
+			GCPRegion:    gcpConf.GCPRegion,
+			GCPProjectID: gcpConf.GCPProjectID,
+			GCPKeyData:   string(gcpConf.GCPKeyData),
+		},
+	}
+
+	return a.provision(prov, infra, repo)
+}
+
+// ProvisionGKE spawns a new provisioning pod that creates a GKE instance
+func (a *Agent) ProvisionGKE(
+	projectID uint,
+	gcpConf *integrations.GCPIntegration,
+	gkeName string,
+	repo repository.Repository,
+	infra *models.Infra,
+	operation provisioner.ProvisionerOperation,
+	pgConf *config.DBConf,
+	redisConf *config.RedisConf,
+	provImageTag string,
+) (*batchv1.Job, error) {
+	id := infra.GetUniqueName()
+	prov := &provisioner.Conf{
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Kind:                provisioner.GKE,
+		Operation:           operation,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		ProvisionerImageTag: provImageTag,
+		LastApplied:         infra.LastApplied,
+		GCP: &gcp.Conf{
+			GCPRegion:    gcpConf.GCPRegion,
+			GCPProjectID: gcpConf.GCPProjectID,
+			GCPKeyData:   string(gcpConf.GCPKeyData),
+		},
+		GKE: &gke.Conf{
+			ClusterName: gkeName,
+		},
+	}
+
+	return a.provision(prov, infra, repo)
+}
+
+// ProvisionDOCR spawns a new provisioning pod that creates a DOCR instance
+func (a *Agent) ProvisionDOCR(
+	projectID uint,
+	doConf *integrations.OAuthIntegration,
+	doAuth *oauth2.Config,
+	repo repository.Repository,
+	docrName, docrSubscriptionTier string,
+	infra *models.Infra,
+	operation provisioner.ProvisionerOperation,
+	pgConf *config.DBConf,
+	redisConf *config.RedisConf,
+	provImageTag string,
+) (*batchv1.Job, error) {
+	// get the token
+	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
+		infra.DOIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	tok, _, err := oauth.GetAccessToken(oauthInt, doAuth, repo)
+
+	if err != nil {
+		return nil, err
+	}
+
+	id := infra.GetUniqueName()
+	prov := &provisioner.Conf{
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Kind:                provisioner.DOCR,
+		Operation:           operation,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		ProvisionerImageTag: provImageTag,
+		LastApplied:         infra.LastApplied,
+		DO: &do.Conf{
+			DOToken: tok,
+		},
+		DOCR: &docr.Conf{
+			DOCRName:             docrName,
+			DOCRSubscriptionTier: docrSubscriptionTier,
+		},
+	}
+
+	return a.provision(prov, infra, repo)
+}
+
+// ProvisionDOKS spawns a new provisioning pod that creates a DOKS instance
+func (a *Agent) ProvisionDOKS(
+	projectID uint,
+	doConf *integrations.OAuthIntegration,
+	doAuth *oauth2.Config,
+	repo repository.Repository,
+	doRegion, doksClusterName string,
+	infra *models.Infra,
+	operation provisioner.ProvisionerOperation,
+	pgConf *config.DBConf,
+	redisConf *config.RedisConf,
+	provImageTag string,
+) (*batchv1.Job, error) {
+	// get the token
+	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
+		infra.DOIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	tok, _, err := oauth.GetAccessToken(oauthInt, doAuth, repo)
+
+	if err != nil {
+		return nil, err
+	}
+
+	id := infra.GetUniqueName()
+	prov := &provisioner.Conf{
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Kind:                provisioner.DOKS,
+		Operation:           operation,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		LastApplied:         infra.LastApplied,
+		ProvisionerImageTag: provImageTag,
+		DO: &do.Conf{
+			DOToken: tok,
+		},
+		DOKS: &doks.Conf{
+			DORegion:        doRegion,
+			DOKSClusterName: doksClusterName,
+		},
+	}
+
+	return a.provision(prov, infra, repo)
+}
+
+// ProvisionTest spawns a new provisioning pod that tests provisioning
+func (a *Agent) ProvisionTest(
+	projectID uint,
+	infra *models.Infra,
+	repo repository.Repository,
+	operation provisioner.ProvisionerOperation,
+	pgConf *config.DBConf,
+	redisConf *config.RedisConf,
+	provImageTag string,
+) (*batchv1.Job, error) {
+	id := infra.GetUniqueName()
+
+	prov := &provisioner.Conf{
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Operation:           operation,
+		Kind:                provisioner.Test,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		ProvisionerImageTag: provImageTag,
+	}
+
+	return a.provision(prov, infra, repo)
+}
+
+func (a *Agent) provision(
+	prov *provisioner.Conf,
+	infra *models.Infra,
+	repo repository.Repository,
+) (*batchv1.Job, error) {
+	prov.Namespace = "default"
+
+	job, err := prov.GetProvisionerJobTemplate()
+
+	if err != nil {
+		return nil, err
+	}
+
+	job, err = a.Clientset.BatchV1().Jobs(prov.Namespace).Create(
+		context.TODO(),
+		job,
+		metav1.CreateOptions{},
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	infra.LastApplied = prov.LastApplied
+	infra, err = repo.Infra.UpdateInfra(infra)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return job, nil
+}
+
+// CreateImagePullSecrets will create the required image pull secrets and
+// return a map from the registry name to the name of the secret.
+func (a *Agent) CreateImagePullSecrets(
+	repo repository.Repository,
+	namespace string,
+	linkedRegs map[string]*models.Registry,
+	doAuth *oauth2.Config,
+) (map[string]string, error) {
+	res := make(map[string]string)
+
+	for key, val := range linkedRegs {
+		_reg := registry.Registry(*val)
+
+		data, err := _reg.GetDockerConfigJSON(repo, doAuth)
+
+		if err != nil {
+			return nil, err
+		}
+
+		secretName := fmt.Sprintf("porter-%s-%d", val.Externalize().Service, val.ID)
+
+		secret, err := a.Clientset.CoreV1().Secrets(namespace).Get(
+			context.TODO(),
+			secretName,
+			metav1.GetOptions{},
+		)
+
+		// if not found, create the secret
+		if err != nil && errors.IsNotFound(err) {
+			_, err = a.Clientset.CoreV1().Secrets(namespace).Create(
+				context.TODO(),
+				&v1.Secret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: secretName,
+					},
+					Data: map[string][]byte{
+						string(v1.DockerConfigJsonKey): data,
+					},
+					Type: v1.SecretTypeDockerConfigJson,
+				},
+				metav1.CreateOptions{},
+			)
+
+			if err != nil {
+				return nil, err
+			}
+
+			// add secret name to the map
+			res[key] = secretName
+
+			continue
+		} else if err != nil {
+			return nil, err
+		}
+
+		// otherwise, check that the secret contains the correct data: if
+		// if doesn't, update it
+		if !bytes.Equal(secret.Data[v1.DockerConfigJsonKey], data) {
+			_, err := a.Clientset.CoreV1().Secrets(namespace).Update(
+				context.TODO(),
+				&v1.Secret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: secretName,
+					},
+					Data: map[string][]byte{
+						string(v1.DockerConfigJsonKey): data,
+					},
+					Type: v1.SecretTypeDockerConfigJson,
+				},
+				metav1.UpdateOptions{},
+			)
+
+			if err != nil {
+				return nil, err
+			}
+		}
+
+		// add secret name to the map
+		res[key] = secretName
+	}
+
+	return res, nil
+}

+ 10 - 6
internal/models/cluster.go

@@ -86,6 +86,9 @@ type ClusterExternal struct {
 
 	// The infra id, if cluster was provisioned with Porter
 	InfraID uint `json:"infra_id"`
+
+	// (optional) The aws integration id, if available
+	AWSIntegrationID uint `json:"aws_integration_id"`
 }
 
 // Externalize generates an external Cluster to be shared over REST
@@ -101,12 +104,13 @@ func (c *Cluster) Externalize() *ClusterExternal {
 	}
 
 	return &ClusterExternal{
-		ID:        c.ID,
-		ProjectID: c.ProjectID,
-		Name:      c.Name,
-		Server:    c.Server,
-		Service:   serv,
-		InfraID:   c.InfraID,
+		ID:               c.ID,
+		ProjectID:        c.ProjectID,
+		Name:             c.Name,
+		Server:           c.Server,
+		Service:          serv,
+		InfraID:          c.InfraID,
+		AWSIntegrationID: c.AWSIntegrationID,
 	}
 }
 

+ 17 - 0
internal/repository/gorm/auth.go

@@ -936,6 +936,23 @@ func (repo *AWSIntegrationRepository) CreateAWSIntegration(
 	return am, nil
 }
 
+// UpdateCluster modifies an existing Cluster in the database
+func (repo *AWSIntegrationRepository) OverwriteAWSIntegration(
+	am *ints.AWSIntegration,
+) (*ints.AWSIntegration, error) {
+	err := repo.EncryptAWSIntegrationData(am, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if err := repo.db.Save(am).Error; err != nil {
+		return nil, err
+	}
+
+	return am, nil
+}
+
 // ReadAWSIntegration finds a aws auth mechanism by id
 func (repo *AWSIntegrationRepository) ReadAWSIntegration(
 	id uint,

+ 51 - 0
internal/repository/gorm/auth_test.go

@@ -499,6 +499,57 @@ func TestCreateAWSIntegration(t *testing.T) {
 	}
 }
 
+func TestOverwriteAWSIntegration(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_overwrite_aws.db",
+	}
+
+	setupTestEnv(tester, t)
+	initUser(tester, t)
+	initProject(tester, t)
+	initAWSIntegration(tester, t)
+	defer cleanup(tester, t)
+
+	aws, err := tester.repo.AWSIntegration.ReadAWSIntegration(1)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	aws.AWSAccessKeyID = []byte("accesskey2")
+	aws.AWSSecretAccessKey = []byte("secret2")
+
+	aws, err = tester.repo.AWSIntegration.OverwriteAWSIntegration(aws)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	gotAWS, err := tester.repo.AWSIntegration.ReadAWSIntegration(1)
+
+	expAWS := &ints.AWSIntegration{
+		ProjectID:          tester.initProjects[0].ID,
+		UserID:             tester.initUsers[0].ID,
+		AWSClusterID:       []byte("example-cluster-0"),
+		AWSAccessKeyID:     []byte("accesskey2"),
+		AWSSecretAccessKey: []byte("secret2"),
+		AWSSessionToken:    []byte("optional"),
+	}
+
+	// make sure id is 1
+	if gotAWS.Model.ID != 1 {
+		t.Errorf("incorrect aws integration ID: expected %d, got %d\n", 1, gotAWS.Model.ID)
+	}
+
+	// reset fields for deep.Equal
+	gotAWS.Model = orm.Model{}
+
+	if diff := deep.Equal(expAWS, gotAWS); diff != nil {
+		t.Errorf("incorrect aws integration")
+		t.Error(diff)
+	}
+}
+
 func TestListAWSIntegrationsByProjectID(t *testing.T) {
 	tester := &tester{
 		dbFileName: "./porter_list_awss.db",

+ 1 - 0
internal/repository/integrations.go

@@ -41,6 +41,7 @@ type OAuthIntegrationRepository interface {
 // mechanism
 type AWSIntegrationRepository interface {
 	CreateAWSIntegration(am *ints.AWSIntegration) (*ints.AWSIntegration, error)
+	OverwriteAWSIntegration(am *ints.AWSIntegration) (*ints.AWSIntegration, error)
 	ReadAWSIntegration(id uint) (*ints.AWSIntegration, error)
 	ListAWSIntegrationsByProjectID(projectID uint) ([]*ints.AWSIntegration, error)
 }

+ 17 - 0
internal/repository/memory/auth.go

@@ -311,6 +311,23 @@ func (repo *AWSIntegrationRepository) CreateAWSIntegration(
 	return am, nil
 }
 
+func (repo *AWSIntegrationRepository) OverwriteAWSIntegration(
+	am *ints.AWSIntegration,
+) (*ints.AWSIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(am.ID-1) >= len(repo.awsIntegrations) || repo.awsIntegrations[am.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(am.ID - 1)
+	repo.awsIntegrations[index] = am
+
+	return am, nil
+}
+
 // ReadAWSIntegration finds a aws auth mechanism by id
 func (repo *AWSIntegrationRepository) ReadAWSIntegration(
 	id uint,

+ 101 - 0
server/api/integration_handler.go

@@ -3,6 +3,7 @@ package api
 import (
 	"encoding/json"
 	"net/http"
+	"net/url"
 	"strconv"
 
 	"github.com/go-chi/chi"
@@ -186,6 +187,106 @@ func (app *App) HandleCreateAWSIntegration(w http.ResponseWriter, r *http.Reques
 	}
 }
 
+// HandleOverwriteAWSIntegration overwrites the ID of an AWS integration in the DB
+func (app *App) HandleOverwriteAWSIntegration(w http.ResponseWriter, r *http.Request) {
+	userID, err := app.getUserIDFromRequest(r)
+
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	awsIntegrationID, err := strconv.ParseUint(chi.URLParam(r, "aws_integration_id"), 0, 64)
+
+	if err != nil || awsIntegrationID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	form := &forms.OverwriteAWSIntegrationForm{
+		UserID:    userID,
+		ProjectID: uint(projID),
+	}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// read the aws integration by ID and overwrite the access id/secret
+	awsIntegration, err := app.Repo.AWSIntegration.ReadAWSIntegration(uint(awsIntegrationID))
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	awsIntegration.AWSAccessKeyID = []byte(form.AWSAccessKeyID)
+	awsIntegration.AWSSecretAccessKey = []byte(form.AWSSecretAccessKey)
+
+	// handle write to the database
+	awsIntegration, err = app.Repo.AWSIntegration.OverwriteAWSIntegration(awsIntegration)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	// clear the cluster token cache if cluster_id exists
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	if len(vals["cluster_id"]) > 0 {
+		clusterID, err := strconv.ParseUint(vals["cluster_id"][0], 10, 64)
+
+		if err != nil {
+			app.handleErrorDataWrite(err, w)
+			return
+		}
+
+		cluster, err := app.Repo.Cluster.ReadCluster(uint(clusterID))
+
+		// clear the token
+		cluster.TokenCache.Token = []byte("")
+
+		cluster, err = app.Repo.Cluster.UpdateClusterTokenCache(&cluster.TokenCache)
+
+		if err != nil {
+			app.handleErrorDataWrite(err, w)
+			return
+		}
+	}
+
+	app.Logger.Info().Msgf("AWS integration overwritten: %d", awsIntegration.ID)
+
+	w.WriteHeader(http.StatusCreated)
+
+	awsExt := awsIntegration.Externalize()
+
+	if err := json.NewEncoder(w).Encode(awsExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
 // HandleCreateBasicAuthIntegration creates a new basic auth integration in the DB
 func (app *App) HandleCreateBasicAuthIntegration(w http.ResponseWriter, r *http.Request) {
 	userID, err := app.getUserIDFromRequest(r)

+ 63 - 11
server/api/k8s_handler.go

@@ -172,11 +172,8 @@ func (app *App) HandleDeleteNamespace(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	namespace := &forms.NamespaceForm{}
-
-	if err := json.NewDecoder(r.Body).Decode(namespace); err != nil {
-		app.handleErrorFormDecoding(err, ErrUserDecode, w)
-		return
+	namespace := &forms.NamespaceForm{
+		Name: vals.Get("name"),
 	}
 
 	err = agent.DeleteNamespace(namespace.Name)
@@ -858,6 +855,54 @@ func (app *App) HandleListJobsByChart(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// HandleDeleteConfigMap deletes the pod given the name and namespace.
+func (app *App) HandleDeleteJob(w http.ResponseWriter, r *http.Request) {
+	// get path parameters
+	namespace := chi.URLParam(r, "namespace")
+	name := chi.URLParam(r, "name")
+
+	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.DeleteJob(name, namespace)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
 // HandleStopJob stops a running job
 func (app *App) HandleStopJob(w http.ResponseWriter, r *http.Request) {
 	// get path parameters
@@ -899,7 +944,10 @@ func (app *App) HandleStopJob(w http.ResponseWriter, r *http.Request) {
 	err = agent.StopJobWithJobSidecar(namespace, name)
 
 	if err != nil {
-		app.handleErrorInternal(err, w)
+		app.sendExternalError(err, 500, HTTPError{
+			Code:   500,
+			Errors: []string{err.Error()},
+		}, w)
 		return
 	}
 
@@ -961,16 +1009,15 @@ func (app *App) HandleListJobPods(w http.ResponseWriter, r *http.Request) {
 // HandleStreamControllerStatus test calls
 // TODO: Refactor repeated calls.
 func (app *App) HandleStreamControllerStatus(w http.ResponseWriter, r *http.Request) {
-
-	// get session to retrieve correct kubeconfig
-	_, err := app.Store.Get(r, app.ServerConf.CookieName)
+	vals, err := url.ParseQuery(r.URL.RawQuery)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
 		return
 	}
 
-	vals, err := url.ParseQuery(r.URL.RawQuery)
+	// get session to retrieve correct kubeconfig
+	_, err = app.Store.Get(r, app.ServerConf.CookieName)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
@@ -1013,7 +1060,12 @@ func (app *App) HandleStreamControllerStatus(w http.ResponseWriter, r *http.Requ
 
 	// get path parameters
 	kind := chi.URLParam(r, "kind")
-	err = agent.StreamControllerStatus(conn, kind)
+
+	selectors := ""
+	if vals["selectors"] != nil {
+		selectors = vals["selectors"][0]
+	} 
+	err = agent.StreamControllerStatus(conn, kind, selectors)
 
 	if err != nil {
 		app.handleErrorWebsocketWrite(err, w)

+ 33 - 0
server/router/router.go

@@ -693,6 +693,25 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"POST",
+				"/projects/{project_id}/integrations/aws/{aws_integration_id}/overwrite",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						auth.DoesUserHaveAWSIntegrationAccess(
+							requestlog.NewHandler(a.HandleOverwriteAWSIntegration, l),
+							mw.URLParam,
+							mw.URLParam,
+							false,
+						),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
 			r.Method(
 				"POST",
 				"/projects/{project_id}/integrations/basic",
@@ -1379,6 +1398,20 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"DELETE",
+				"/projects/{project_id}/k8s/jobs/{namespace}/{name}",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleDeleteJob, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
 			r.Method(
 				"POST",
 				"/projects/{project_id}/k8s/jobs/{namespace}/{name}/stop",