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

Merge branch 'master' into 0.6.0-897-path-cli-arg

abelanger5 4 лет назад
Родитель
Сommit
5f56d74664
45 измененных файлов с 2624 добавлено и 2257 удалено
  1. 1 0
      .gitignore
  2. 47 19
      dashboard/src/components/repo-selector/RepoList.tsx
  3. 19 1
      dashboard/src/components/values-form/KeyValueArray.tsx
  4. 3 3
      dashboard/src/main/auth/VerifyEmail.tsx
  5. 44 0
      dashboard/src/main/home/Home.tsx
  6. 453 558
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  7. 0 17
      dashboard/src/main/home/integrations/IntegrationList.tsx
  8. 1 1
      dashboard/src/main/home/integrations/Integrations.tsx
  9. 2 4
      dashboard/src/main/home/launch/launch-flow/SourcePage.tsx
  10. 11 12
      dashboard/src/main/home/modals/AccountSettingsModal.tsx
  11. 0 11
      dashboard/src/shared/api.tsx
  12. 1 1
      dashboard/src/shared/auth/AuthContext.tsx
  13. 1 0
      docker-compose.dev.yaml
  14. 45 0
      docs/developing/analytics.md
  15. 4 1
      go.mod
  16. 8 0
      go.sum
  17. 48 0
      internal/analytics/identifiers.go
  18. 77 0
      internal/analytics/segment.go
  19. 8 0
      internal/analytics/track_events.go
  20. 68 0
      internal/analytics/tracks.go
  21. 2 0
      internal/config/config.go
  22. 8 7
      internal/forms/git_action.go
  23. 54 21
      internal/integrations/ci/actions/actions.go
  24. 1217 1217
      internal/kubernetes/agent.go
  25. 1 1
      internal/kubernetes/config.go
  26. 6 3
      internal/models/gitrepo.go
  27. 5 0
      internal/models/integrations/oauth.go
  28. 48 12
      internal/oauth/config.go
  29. 5 5
      internal/registry/registry.go
  30. 12 0
      internal/repository/gorm/auth.go
  31. 4 4
      internal/repository/gorm/git_action_config_test.go
  32. 1 0
      internal/repository/integrations.go
  33. 15 0
      internal/repository/memory/auth.go
  34. 23 17
      server/api/api.go
  35. 23 19
      server/api/deploy_handler.go
  36. 17 23
      server/api/git_action_handler.go
  37. 89 66
      server/api/git_repo_handler.go
  38. 20 6
      server/api/integration_handler.go
  39. 13 42
      server/api/oauth_github_handler.go
  40. 4 18
      server/api/oauth_google_handler.go
  41. 1 1
      server/api/registry_handler.go
  42. 47 48
      server/api/release_handler.go
  43. 44 59
      server/api/user_handler.go
  44. 98 27
      server/middleware/auth.go
  45. 26 33
      server/router/router.go

+ 1 - 0
.gitignore

@@ -1,6 +1,7 @@
 .DS_Store
 .env
 docker/.env
+docker/github_app_private_key.pem
 app
 *.db
 test.yaml

+ 47 - 19
dashboard/src/components/repo-selector/RepoList.tsx

@@ -8,6 +8,13 @@ import { Context } from "shared/Context";
 
 import Loading from "../Loading";
 import SearchBar from "../SearchBar";
+import Helper from "../values-form/Helper";
+
+interface GithubAppAccessData {
+  has_access: boolean;
+  username?: string;
+  accounts?: string[];
+}
 
 type Props = {
   actionConfig: ActionConfigType | null;
@@ -23,13 +30,29 @@ const RepoList: React.FC<Props> = ({
   readOnly,
 }) => {
   const [repos, setRepos] = useState<RepoType[]>([]);
-  const [loading, setLoading] = useState(true);
-  const [error, setError] = useState(false);
+  const [repoLoading, setRepoLoading] = useState(true);
+  const [repoError, setRepoError] = useState(false);
+  const [accessLoading, setAccessLoading] = useState(true);
+  const [accessError, setAccessError] = useState(false);
+  const [accessData, setAccessData] = useState<GithubAppAccessData>({
+    has_access: false,
+  });
   const [searchFilter, setSearchFilter] = useState(null);
   const { currentProject } = useContext(Context);
 
   // TODO: Try to unhook before unmount
   useEffect(() => {
+    api
+      .getGithubAccess("<token>", {}, {})
+      .then(({ data }) => {
+        setAccessData(data);
+        setAccessLoading(false);
+      })
+      .catch(() => {
+        setAccessError(true);
+        setAccessLoading(false);
+      });
+
     // load git repo ids, and then repo names from that
     // this only happens once during the lifecycle
     new Promise((resolve, reject) => {
@@ -37,13 +60,13 @@ const RepoList: React.FC<Props> = ({
         api
           .getGitRepos("<token>", {}, { project_id: currentProject.id })
           .then(async (res) => {
-            resolve(res.data.map((gitrepo: any) => gitrepo.id));
+            resolve(res.data);
           })
-          .catch((err) => {
-            reject(err);
+          .catch(() => {
+            resolve([]);
           });
       } else {
-        resolve([userId]);
+        reject(null);
       }
     })
       .then((ids: number[]) => {
@@ -86,16 +109,16 @@ const RepoList: React.FC<Props> = ({
                   }
                 }, [])
             );
-            setLoading(false);
+            setRepoLoading(false);
           })
           .catch((_) => {
-            setLoading(false);
-            setError(true);
+            setRepoLoading(false);
+            setRepoError(true);
           });
       })
       .catch((_) => {
-        setLoading(false);
-        setError(true);
+        setRepoLoading(false);
+        setRepoError(true);
       });
   }, []);
 
@@ -107,25 +130,30 @@ const RepoList: React.FC<Props> = ({
   };
 
   const renderRepoList = () => {
-    if (loading) {
+    if (repoLoading || accessLoading) {
       return (
         <LoadingWrapper>
           <Loading />
         </LoadingWrapper>
       );
-    } else if (error) {
+    } else if (repoError || accessError) {
       return <LoadingWrapper>Error loading repos.</LoadingWrapper>;
     } else if (repos.length == 0) {
-      return (
+      return accessData.has_access ? (
         <LoadingWrapper>
           No connected Github repos found. You can
-          <A
-            href={`/api/oauth/projects/${currentProject.id}/github?redirected=true`}
-          >
-            log in with GitHub
+          <A href={"/api/integrations/github-app/install"}>
+            Install Porter in more repositories
           </A>
           .
         </LoadingWrapper>
+      ) : (
+        <LoadingWrapper>
+          No connected Github repos found.
+          <A href={"/api/integrations/github-app/oauth"}>
+            Authorize Porter to view your repositories.
+          </A>
+        </LoadingWrapper>
       );
     }
 
@@ -167,7 +195,7 @@ const RepoList: React.FC<Props> = ({
         <>
           <SearchBar
             setSearchFilter={setSearchFilter}
-            disabled={error || loading}
+            disabled={repoError || repoLoading || accessError || accessLoading}
             prompt={"Search repos..."}
           />
           <RepoListWrapper>

+ 19 - 1
dashboard/src/components/values-form/KeyValueArray.tsx

@@ -50,8 +50,26 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
 
   valuesToObject = () => {
     let obj = {} as any;
+    const rg = /(?:^|[^\\])(\\n)/g;
+    const fixNewlines = (s: string) => {
+      while (rg.test(s)) {
+        s = s.replace(rg, (str) => {
+          if (str.length == 2) return "\n";
+          if (str[0] != "\\") return str[0] + "\n";
+          return "\\n";
+        });
+      }
+      return s;
+    };
+    const isNumber = (s: string) => {
+      return !isNaN(!s ? NaN : Number(String(s).trim()));
+    };
     this.state.values.forEach((entry: any, i: number) => {
-      obj[entry.key] = entry.value;
+      if (isNumber(entry.value)) {
+        obj[entry.key] = entry.value;
+      } else {
+        obj[entry.key] = fixNewlines(entry.value);
+      }
     });
     return obj;
   };

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

@@ -36,13 +36,13 @@ export default class VerifyEmail extends Component<PropsType, StateType> {
     let formSection = (
       <div>
         <InputWrapper>
-          <StatusText>A verification email will be sent to</StatusText>
+          <StatusText>A verification email should have been sent to</StatusText>
           <Email>{this.context.user?.email}</Email>
         </InputWrapper>
         <StatusText>
-          Proceed below to verify your email and finish setting up your profile
+          Didn't get it?
         </StatusText>
-        <Button onClick={this.handleSendEmail}>Send Verification Email</Button>
+        <Button onClick={this.handleSendEmail}>Resend Verification Email</Button>
       </div>
     );
 

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

@@ -30,6 +30,7 @@ import { fakeGuardedRoute } from "shared/auth/RouteGuard";
 import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
 import EditInviteOrCollaboratorModal from "./modals/EditInviteOrCollaboratorModal";
 import AccountSettingsModal from "./modals/AccountSettingsModal";
+import discordLogo from "../../assets/discord.svg";
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
   "get",
@@ -378,6 +379,15 @@ class Home extends Component<PropsType, StateType> {
           }
         />
       );
+    } else {
+      return (
+        <>
+          <DiscordButton href="https://discord.gg/34n7NN7FJ7" target="_blank">
+            <Icon src={discordLogo} />
+            Join Our Discord
+          </DiscordButton>
+        </>
+      );
     }
   };
 
@@ -633,3 +643,37 @@ const StyledHome = styled.div`
     }
   }
 `;
+
+const DiscordButton = styled.a`
+  position: absolute;
+  z-index: 100;
+  text-decoration: none;
+  bottom: 17px;
+  display: flex;
+  align-items: center;
+  width: 170px;
+  left: 15px;
+  border: 2px solid #ffffff44;
+  border-radius: 3px;
+  color: #ffffff44;
+  height: 40px;
+  font-family: Work Sans, sans-serif;
+  font-size: 14px;
+  font-weight: bold;
+  cursor: pointer;
+  :hover {
+    > img {
+      opacity: 60%;
+    }
+    color: #ffffff88;
+    border-color: #ffffff88;
+  }
+`;
+
+const Icon = styled.img`
+  height: 25px;
+  width: 25px;
+  opacity: 30%;
+  margin-left: 7px;
+  margin-right: 5px;
+`;

+ 453 - 558
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -1,9 +1,16 @@
-import React, { Component } from "react";
+import React, {
+  useContext,
+  useState,
+  useEffect,
+  useRef,
+  useCallback,
+  useMemo,
+} from "react";
 import styled from "styled-components";
 import yaml from "js-yaml";
 import close from "assets/close.png";
 import _ from "lodash";
-import loading from "assets/loading.gif";
+import loadingSrc from "assets/loading.gif";
 
 import {
   ResourceType,
@@ -25,10 +32,10 @@ import MetricsSection from "./metrics/MetricsSection";
 import ListSection from "./ListSection";
 import StatusSection from "./status/StatusSection";
 import SettingsSection from "./SettingsSection";
-import ChartList from "../chart/ChartList";
-import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
+import { useWebsockets } from "shared/hooks/useWebsockets";
+import useAuth from "shared/auth/useAuth";
 
-type PropsType = WithAuthProps & {
+type Props = {
   namespace: string;
   currentChart: ChartType;
   currentCluster: ClusterType;
@@ -37,193 +44,159 @@ type PropsType = WithAuthProps & {
   isMetricsInstalled: boolean;
 };
 
-type StateType = {
-  currentChart: ChartType;
-  loading: boolean;
-  showRevisions: boolean;
-  components: ResourceType[];
-  podSelectors: string[];
-  isPreview: boolean;
-  isUpdatingChart: boolean;
-  devOpsMode: boolean;
-  tabOptions: any[];
-  saveValuesStatus: string | null;
-  forceRefreshRevisions: boolean; // Update revisions after upgrading values
-  controllers: Record<string, Record<string, any>>;
-  websockets: Record<string, any>;
-  url: string | null;
-  showDeleteOverlay: boolean;
-  deleting: boolean;
-  formData: any;
-  imageIsPlaceholder: boolean;
-  newestImage: string;
+const getReadableDate = (s: string) => {
+  let ts = new Date(s);
+  let date = ts.toLocaleDateString();
+  let time = ts.toLocaleTimeString([], {
+    hour: "numeric",
+    minute: "2-digit",
+  });
+  return `${time} on ${date}`;
 };
 
-class ExpandedChart extends Component<PropsType, StateType> {
-  state = {
-    currentChart: this.props.currentChart,
-    loading: true,
-    showRevisions: false,
-    components: [] as ResourceType[],
-    podSelectors: [] as string[],
-    isPreview: false,
-    isUpdatingChart: false,
-    devOpsMode: localStorage.getItem("devOpsMode") === "true",
-    tabOptions: [] as any[],
-    saveValuesStatus: null as string | null,
-    forceRefreshRevisions: false,
-    controllers: {} as Record<string, Record<string, any>>,
-    websockets: {} as Record<string, any>,
-    url: null as string | null,
-    showDeleteOverlay: false,
-    deleting: false,
-    formData: {} as any,
-    imageIsPlaceholder: false,
-    newestImage: null as string,
-  };
+const ExpandedChart: React.FC<Props> = (props) => {
+  const [currentChart, setCurrentChart] = useState<ChartType>(
+    props.currentChart
+  );
+  const [showRevisions, setShowRevisions] = useState<boolean>(false);
+  const [components, setComponents] = useState<ResourceType[]>([]);
+  const [isPreview, setIsPreview] = useState<boolean>(false);
+  const [devOpsMode, setDevOpsMode] = useState<boolean>(
+    localStorage.getItem("devOpsMode") === "true"
+  );
+  const [tabOptions, setTabOptions] = useState<any[]>([]);
+  const [saveValuesStatus, setSaveValueStatus] = useState<string>(null);
+  const [forceRefreshRevisions, setForceRefreshRevisions] = useState<boolean>(
+    false
+  );
+  const [controllers, setControllers] = useState<
+    Record<string, Record<string, any>>
+  >({});
+  const [url, setUrl] = useState<string>(null);
+  const [showDeleteOverlay, setShowDeleteOverlay] = useState<boolean>(false);
+  const [deleting, setDeleting] = useState<boolean>(false);
+  const [imageIsPlaceholder, setImageIsPlaceholer] = useState<boolean>(false);
+  const [newestImage, setNewestImage] = useState<string>(null);
+  const [isLoadingChartData, setIsLoadingChartData] = useState<boolean>(true);
+
+  const [isAuthorized] = useAuth();
+
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeAllWebsockets,
+    closeWebsocket,
+  } = useWebsockets();
+
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
 
   // Retrieve full chart data (includes form and values)
-  getChartData = (chart: ChartType) => {
-    let { currentProject } = this.context;
-    let { currentCluster, currentChart } = this.props;
+  const getChartData = async (chart: ChartType) => {
+    setIsLoadingChartData(true);
+    const res = await api.getChart(
+      "<token>",
+      {
+        namespace: chart.namespace,
+        cluster_id: currentCluster.id,
+        storage: StorageType.Secret,
+      },
+      {
+        name: chart.name,
+        revision: chart.version,
+        id: currentProject.id,
+      }
+    );
+    const image = res.data?.config?.image?.repository;
+    const tag = res.data?.config?.image?.tag?.toString();
+    const newNewestImage = tag ? image + ":" + tag : image;
+    let imageIsPlaceholder = false;
+    if (
+      (image === "porterdev/hello-porter" ||
+        image === "public.ecr.aws/o1j4x7p4/hello-porter") &&
+      !newestImage
+    ) {
+      imageIsPlaceholder = true;
+    }
+    setImageIsPlaceholer(imageIsPlaceholder);
+    setNewestImage(newNewestImage);
 
-    this.setState({ loading: true });
-    api
-      .getChart(
+    setCurrentChart(res.data);
+
+    updateComponents(res.data).finally(() => setIsLoadingChartData(false));
+  };
+
+  const getControllers = async (chart: ChartType) => {
+    // don't retrieve controllers for chart that failed to even deploy.
+    if (chart.info.status == "failed") return;
+
+    try {
+      const { data: chartControllers } = await api.getChartControllers(
         "<token>",
         {
-          namespace: currentChart.namespace,
+          namespace: chart.namespace,
           cluster_id: currentCluster.id,
           storage: StorageType.Secret,
         },
         {
+          id: currentProject.id,
           name: chart.name,
           revision: chart.version,
-          id: currentProject.id,
-        }
-      )
-      .then((res) => {
-        let image = res.data?.config?.image?.repository;
-        let tag = res.data?.config?.image?.tag?.toString();
-        let newestImage = tag ? image + ":" + tag : image;
-        let imageIsPlaceholder = false;
-        if (
-          (image === "porterdev/hello-porter" ||
-            image === "public.ecr.aws/o1j4x7p4/hello-porter") &&
-          !this.state.newestImage
-        ) {
-          imageIsPlaceholder = true;
         }
-        this.updateComponents(
-          {
-            currentChart: res.data,
-            loading: false,
-            imageIsPlaceholder,
-            newestImage,
-          },
-          res.data
-        );
-      })
-      .catch(console.log);
-  };
+      );
 
-  getControllers = async (chart: ChartType) => {
-    let { currentCluster, currentProject, setCurrentError } = this.context;
+      chartControllers.forEach((c: any) => {
+        c.metadata.kind = c.kind;
 
-    // don't retrieve controllers for chart that failed to even deploy.
-    if (chart.info.status == "failed") return;
+        setControllers((oldControllers) => ({
+          ...oldControllers,
+          [c.metadata.kind]: c,
+        }));
+      });
 
-    // TODO: properly promisify
-    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) => {
-          res.data?.forEach(async (c: any) => {
-            await new Promise((nextController: (res?: any) => void) => {
-              c.metadata.kind = c.kind;
-              this.setState(
-                {
-                  controllers: {
-                    ...this.state.controllers,
-                    [c.metadata.uid]: c,
-                  },
-                },
-                () => {
-                  nextController();
-                }
-              );
-            });
-          });
-          next();
-        })
-        .catch((err) => setCurrentError(JSON.stringify(err)));
-    });
+      return;
+    } catch (error) {
+      if (typeof error !== "string") {
+        setCurrentError(JSON.stringify(error));
+      }
+      setCurrentError(error);
+    }
   };
 
-  setupWebsocket = (kind: string, chart: ChartType) => {
-    let { currentCluster, currentProject } = this.context;
-    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
-    let ws = new WebSocket(
-      `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`
-    );
-    ws.onopen = () => {
-      console.log("connected to websocket");
-    };
-
-    ws.onmessage = (evt: MessageEvent) => {
-      let event = JSON.parse(evt.data);
+  const setupWebsocket = (kind: string) => {
+    const apiEndpoint = `/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
 
-      if (event.event_type == "UPDATE") {
-        let object = event.Object;
-        object.metadata.kind = event.Kind;
+    const wsConfig = {
+      onmessage(evt: MessageEvent) {
+        const event = JSON.parse(evt.data);
 
-        if (!this.state.controllers[object.metadata.uid]) return;
+        if (event.event_type == "UPDATE") {
+          let object = event.Object;
+          object.metadata.kind = event.Kind;
 
-        this.setState({
-          controllers: {
-            ...this.state.controllers,
-            [object.metadata.uid]: object,
-          },
-        });
-      }
-    };
-
-    ws.onclose = () => {
-      console.log("closing websocket");
-    };
-
-    ws.onerror = (err: ErrorEvent) => {
-      console.log(err);
-      ws.close();
+          setControllers((oldControllers) => {
+            if (oldControllers[object.metadata.uid]) {
+              return oldControllers;
+            }
+            return {
+              ...oldControllers,
+              [object.metadata.uid]: object,
+            };
+          });
+        }
+      },
+      onerror() {
+        closeWebsocket(kind);
+      },
     };
 
-    return ws;
+    newWebsocket(kind, apiEndpoint, wsConfig);
   };
 
-  setControllerWebsockets = (controller_types: any[], chart: ChartType) => {
-    let websockets = controller_types.map((kind: string) => {
-      return this.setupWebsocket(kind, chart);
-    });
-    this.setState({ websockets });
-  };
-
-  updateComponents = (state: any, currentChart: ChartType) => {
-    let { currentCluster, currentProject } = this.context;
-
-    api
-      .getChartComponents(
+  const updateComponents = async (currentChart: ChartType) => {
+    try {
+      const res = await api.getChartComponents(
         "<token>",
         {
           namespace: currentChart.namespace,
@@ -235,35 +208,25 @@ class ExpandedChart extends Component<PropsType, StateType> {
           name: currentChart.name,
           revision: currentChart.version,
         }
-      )
-      .then((res) => {
-        let newState = state || {};
-
-        newState.components = res.data.Objects;
-        newState.podSelectors = res.data.PodSelectors;
-
-        this.setState(newState);
-        this.updateTabs();
-      })
-      .catch(console.log);
+      );
+      setComponents(res.data.Objects);
+    } catch (error) {
+      console.log(error);
+    }
   };
 
-  refreshChart = () => this.getChartData(this.state.currentChart);
-
-  onSubmit = (rawValues: any) => {
-    let { currentProject, currentCluster, setCurrentError } = this.context;
-
+  const onSubmit = async (rawValues: any) => {
     // Convert dotted keys to nested objects
     let values = {};
 
     // Weave in preexisting values and convert to yaml
-    if (this.props.currentChart.config) {
-      values = this.props.currentChart.config;
+    if (props.currentChart.config) {
+      values = props.currentChart.config;
     }
 
     // Override config from currentChart prop if we have it on the current state
-    if (this.state.currentChart.config) {
-      values = this.state.currentChart.config;
+    if (currentChart.config) {
+      values = currentChart.config;
     }
 
     for (let key in rawValues) {
@@ -274,98 +237,87 @@ class ExpandedChart extends Component<PropsType, StateType> {
       ...values,
     });
 
-    this.setState({ saveValuesStatus: "loading" });
-    this.refreshChart();
-
-    api
-      .upgradeChartValues(
+    setSaveValueStatus("loading");
+    getChartData(currentChart);
+    try {
+      await api.upgradeChartValues(
         "<token>",
         {
-          namespace: this.state.currentChart.namespace,
+          namespace: currentChart.namespace,
           storage: StorageType.Secret,
           values: valuesYaml,
         },
         {
           id: currentProject.id,
-          name: this.state.currentChart.name,
+          name: currentChart.name,
           cluster_id: currentCluster.id,
         }
-      )
-      .then((res) => {
-        this.setState({
-          saveValuesStatus: "successful",
-          forceRefreshRevisions: true,
-        });
+      );
 
-        window.analytics.track("Chart Upgraded", {
-          chart: this.state.currentChart.name,
-          values: valuesYaml,
-        });
-      })
-      .catch((err) => {
-        let parsedErr =
-          err?.response?.data?.errors && err.response.data.errors[0];
+      setSaveValueStatus("successful");
+      setForceRefreshRevisions(true);
 
-        if (parsedErr) {
-          err = parsedErr;
-        }
+      window.analytics.track("Chart Upgraded", {
+        chart: currentChart.name,
+        values: valuesYaml,
+      });
+    } catch (err) {
+      const parsedErr =
+        err?.response?.data?.errors && err.response.data.errors[0];
 
-        this.setState({
-          saveValuesStatus: err,
-        });
+      if (parsedErr) {
+        err = parsedErr;
+      }
 
-        setCurrentError(parsedErr);
+      setSaveValueStatus(err);
 
-        window.analytics.track("Failed to Upgrade Chart", {
-          chart: this.state.currentChart.name,
-          values: valuesYaml,
-          error: err,
-        });
+      setCurrentError(parsedErr);
+
+      window.analytics.track("Failed to Upgrade Chart", {
+        chart: currentChart.name,
+        values: valuesYaml,
+        error: err,
       });
+    }
   };
 
-  handleUpgradeVersion = (version: string, cb: () => void) => {
-    let { currentProject, currentCluster, setCurrentError } = this.context;
-
-    // convert current values to yaml
-    let values = this.props.currentChart.config;
+  const handleUpgradeVersion = useCallback(
+    async (version: string, cb: () => void) => {
+      // convert current values to yaml
+      let values = currentChart.config;
 
-    let valuesYaml = yaml.dump({
-      ...values,
-    });
+      let valuesYaml = yaml.dump({
+        ...values,
+      });
 
-    this.setState({ saveValuesStatus: "loading" });
-    this.refreshChart();
+      setSaveValueStatus("loading");
+      getChartData(currentChart);
 
-    api
-      .upgradeChartValues(
-        "<token>",
-        {
-          namespace: this.state.currentChart.namespace,
-          storage: StorageType.Secret,
-          values: valuesYaml,
-          version: version,
-        },
-        {
-          id: currentProject.id,
-          name: this.state.currentChart.name,
-          cluster_id: currentCluster.id,
-        }
-      )
-      .then((res) => {
-        this.setState({
-          saveValuesStatus: "successful",
-          forceRefreshRevisions: true,
-        });
+      try {
+        await api.upgradeChartValues(
+          "<token>",
+          {
+            namespace: currentChart.namespace,
+            storage: StorageType.Secret,
+            values: valuesYaml,
+            version: version,
+          },
+          {
+            id: currentProject.id,
+            name: currentChart.name,
+            cluster_id: currentCluster.id,
+          }
+        );
+        setSaveValueStatus("successful");
+        setForceRefreshRevisions(true);
 
         window.analytics.track("Chart Upgraded", {
-          chart: this.state.currentChart.name,
+          chart: currentChart.name,
           values: valuesYaml,
         });
 
         cb && cb();
-      })
-      .catch((err) => {
+      } catch (err) {
         let parsedErr =
           err?.response?.data?.errors && err.response.data.errors[0];
 
@@ -373,38 +325,45 @@ class ExpandedChart extends Component<PropsType, StateType> {
           err = parsedErr;
         }
 
-        this.setState({
-          saveValuesStatus: err,
-          loading: false,
-        });
-
+        setSaveValueStatus(err);
         setCurrentError(parsedErr);
 
         window.analytics.track("Failed to Upgrade Chart", {
-          chart: this.state.currentChart.name,
+          chart: currentChart.name,
           values: valuesYaml,
           error: err,
         });
-      });
-  };
+      }
+    },
+    [currentChart]
+  );
 
-  renderTabContents = (currentTab: string) => {
-    let { components, showRevisions, imageIsPlaceholder } = this.state;
-    let { setSidebar } = this.props;
-    let { currentChart } = this.state;
+  const renderTabContents = (currentTab: string) => {
+    let { setSidebar } = props;
     let chart = currentChart;
 
     switch (currentTab) {
       case "metrics":
         return <MetricsSection currentChart={chart} />;
       case "status":
+        if (isLoadingChartData) {
+          return (
+            <Placeholder>
+              <TextWrap>
+                <Header>
+                  <Spinner src={loadingSrc} />
+                </Header>
+              </TextWrap>
+            </Placeholder>
+          );
+        }
         if (imageIsPlaceholder) {
           return (
             <Placeholder>
               <TextWrap>
                 <Header>
-                  <Spinner src={loading} /> This application is currently being
-                  deployed
+                  <Spinner src={loadingSrc} /> This application is currently
+                  being deployed
                 </Header>
                 Navigate to the "Actions" tab of your GitHub repo to view live
                 build logs.
@@ -418,10 +377,8 @@ class ExpandedChart extends Component<PropsType, StateType> {
         return (
           <SettingsSection
             currentChart={chart}
-            refreshChart={this.refreshChart}
-            setShowDeleteOverlay={(x: boolean) =>
-              this.setState({ showDeleteOverlay: x })
-            }
+            refreshChart={() => getChartData(currentChart)}
+            setShowDeleteOverlay={(x: boolean) => setShowDeleteOverlay(x)}
           />
         );
       case "graph":
@@ -447,33 +404,26 @@ class ExpandedChart extends Component<PropsType, StateType> {
         return (
           <ValuesYaml
             currentChart={chart}
-            refreshChart={this.refreshChart}
-            disabled={
-              !this.props.isAuthorized("application", "", ["get", "update"])
-            }
+            refreshChart={() => getChartData(currentChart)}
+            disabled={!isAuthorized("application", "", ["get", "update"])}
           />
         );
       default:
     }
   };
 
-  updateTabs() {
-    let formData = this.state.currentChart.form;
-    if (formData) {
-      this.setState({ formData });
-    }
-
+  const updateTabs = () => {
     // Collate non-form tabs
     let tabOptions = [] as any[];
     tabOptions.push({ label: "Status", value: "status" });
 
-    if (this.props.isMetricsInstalled) {
+    if (props.isMetricsInstalled) {
       tabOptions.push({ label: "Metrics", value: "metrics" });
     }
 
     tabOptions.push({ label: "Chart Overview", value: "graph" });
 
-    if (this.state.devOpsMode) {
+    if (devOpsMode) {
       tabOptions.push(
         { label: "Manifests", value: "list" },
         { label: "Helm Values", value: "values" }
@@ -481,44 +431,32 @@ class ExpandedChart extends Component<PropsType, StateType> {
     }
 
     // Settings tab is always last
-    if (this.props.isAuthorized("application", "", ["get", "delete"])) {
+    if (isAuthorized("application", "", ["get", "delete"])) {
       tabOptions.push({ label: "Settings", value: "settings" });
     }
 
     // Filter tabs if previewing an old revision or updating the chart version
-    if (this.state.isPreview || this.state.isUpdatingChart) {
+    if (isPreview) {
       let liveTabs = ["status", "settings", "deploy", "metrics"];
       tabOptions = tabOptions.filter(
         (tab: any) => !liveTabs.includes(tab.value)
       );
     }
 
-    this.setState({ tabOptions });
-  }
+    setTabOptions(tabOptions);
+  };
 
-  setRevision = (chart: ChartType, isCurrent?: boolean) => {
-    this.setState({ isPreview: !isCurrent });
-    this.getChartData(chart);
+  const setRevision = (chart: ChartType, isCurrent?: boolean) => {
+    setIsPreview(!isCurrent);
+    getChartData(chart);
   };
 
   // TODO: consolidate with pop + push in refreshTabs
-  toggleDevOpsMode = () => {
-    if (this.state.devOpsMode) {
-      this.setState({ devOpsMode: false }, () => {
-        this.updateTabs();
-        localStorage.setItem("devOpsMode", "false");
-      });
-    } else {
-      this.setState({ devOpsMode: true }, () => {
-        this.updateTabs();
-        localStorage.setItem("devOpsMode", "true");
-      });
-    }
+  const toggleDevOpsMode = () => {
+    setDevOpsMode(!devOpsMode);
   };
 
-  renderIcon = () => {
-    let { currentChart } = this.state;
-
+  const renderIcon = () => {
     if (
       currentChart.chart.metadata.icon &&
       currentChart.chart.metadata.icon !== ""
@@ -529,34 +467,36 @@ class ExpandedChart extends Component<PropsType, StateType> {
     }
   };
 
-  readableDate = (s: string) => {
-    let ts = new Date(s);
-    let date = ts.toLocaleDateString();
-    let time = ts.toLocaleTimeString([], {
-      hour: "numeric",
-      minute: "2-digit",
-    });
-    return `${time} on ${date}`;
-  };
+  const chartStatus = useMemo(() => {
+    const getAvailability = (kind: string, c: any) => {
+      switch (kind?.toLowerCase()) {
+        case "deployment":
+        case "replicaset":
+          return c.status.availableReplicas == c.status.replicas;
+        case "statefulset":
+          return c.status.readyReplicas == c.status.replicas;
+        case "daemonset":
+          return c.status.numberAvailable == c.status.desiredNumberScheduled;
+      }
+    };
+
+    const chartStatus = currentChart.info.status;
 
-  getChartStatus = (chartStatus: string) => {
     if (chartStatus === "deployed") {
-      for (var uid in this.state.controllers) {
-        let value = this.state.controllers[uid];
-        let available = this.getAvailability(value.metadata.kind, value);
+      for (var uid in controllers) {
+        let value = controllers[uid];
+        let available = getAvailability(value.metadata.kind, value);
         let progressing = true;
 
-        this.state.controllers[uid]?.status?.conditions?.forEach(
-          (condition: any) => {
-            if (
-              condition.type == "Progressing" &&
-              condition.status == "False" &&
-              condition.reason == "ProgressDeadlineExceeded"
-            ) {
-              progressing = false;
-            }
+        controllers[uid]?.status?.conditions?.forEach((condition: any) => {
+          if (
+            condition.type == "Progressing" &&
+            condition.status == "False" &&
+            condition.reason == "ProgressDeadlineExceeded"
+          ) {
+            progressing = false;
           }
-        );
+        });
 
         if (!available && progressing) {
           return "loading";
@@ -567,269 +507,224 @@ class ExpandedChart extends Component<PropsType, StateType> {
       return "deployed";
     }
     return chartStatus;
-  };
+  }, [currentChart, controllers]);
 
-  getAvailability = (kind: string, c: any) => {
-    switch (kind?.toLowerCase()) {
-      case "deployment":
-      case "replicaset":
-        return c.status.availableReplicas == c.status.replicas;
-      case "statefulset":
-        return c.status.readyReplicas == c.status.replicas;
-      case "daemonset":
-        return c.status.numberAvailable == c.status.desiredNumberScheduled;
+  const renderUrl = () => {
+    if (url) {
+      return (
+        <Url href={url} target="_blank">
+          <i className="material-icons">link</i>
+          {url}
+        </Url>
+      );
     }
-  };
 
-  componentDidMount() {
-    let { currentCluster, currentProject } = this.context;
-    let { currentChart } = this.state;
-
-    window.analytics.track("Opened Chart", {
-      chart: currentChart.name,
+    const service: any = components?.find((c) => {
+      return c.Kind === "Service";
     });
 
-    this.getChartData(currentChart);
-    this.getControllers(currentChart);
-    this.setControllerWebsockets(
-      ["deployment", "statefulset", "daemonset", "replicaset"],
-      currentChart
+    if (!service?.Name || !service?.Namespace) {
+      return (
+        <Url>
+          <Bolded>Loading...</Bolded>
+        </Url>
+      );
+    }
+
+    return (
+      <Url>
+        <Bolded>Internal URI:</Bolded>
+        {`${service.Name}.${service.Namespace}.svc.cluster.local`}
+      </Url>
     );
+  };
 
-    api
-      .getChartComponents(
+  const handleUninstallChart = async () => {
+    setDeleting(true);
+    try {
+      await api.uninstallTemplate(
         "<token>",
+        {},
         {
           namespace: currentChart.namespace,
-          cluster_id: currentCluster.id,
           storage: StorageType.Secret,
-        },
-        {
-          id: currentProject.id,
           name: currentChart.name,
-          revision: currentChart.version,
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
         }
-      )
-      .then((res) =>
-        this.setState({ components: res.data.Objects }, () => {
-          let ingressName = null;
-          for (var i = 0; i < this.state.components.length; i++) {
-            if (this.state.components[i].Kind === "Ingress") {
-              ingressName = this.state.components[i].Name;
-            }
-          }
+      );
+      setShowDeleteOverlay(false);
+      props.closeChart();
+    } catch (error) {
+      console.log(error);
+      setCurrentError("Couldn't uninstall chart, please try again");
+    }
+  };
 
-          api
-            .getIngress(
-              "<token>",
-              {
-                cluster_id: currentCluster.id,
-              },
-              {
-                id: currentProject.id,
-                name: ingressName,
-                namespace: `${this.state.currentChart.namespace}`,
-              }
-            )
-            .then((res) => {
-              if (res.data?.spec?.rules && res.data?.spec?.rules[0]?.host) {
-                this.setState({
-                  url: `https://${res.data?.spec?.rules[0]?.host}`,
-                });
-                return;
-              }
-
-              if (res.data?.status?.loadBalancer?.ingress) {
-                this.setState({
-                  url: `http://${res.data?.status?.loadBalancer?.ingress[0]?.hostname}`,
-                });
-                return;
-              }
-            })
-            .catch(console.log);
-        })
-      )
-      .catch(console.log);
-  }
+  useEffect(() => {
+    window.analytics.track("Opened Chart", {
+      chart: currentChart.name,
+    });
 
-  componentWillUnmount() {
-    if (this.state.websockets?.length > 0) {
-      this.state.websockets?.forEach((ws: WebSocket) => {
-        ws.close();
+    getChartData(currentChart).then(() => {
+      getControllers(currentChart).then(() => {
+        ["deployment", "statefulset", "daemonset", "replicaset"]
+          .map((kind) => {
+            setupWebsocket(kind);
+            return kind;
+          })
+          .forEach((kind) => {
+            openWebsocket(kind);
+          });
       });
-    }
-  }
+    });
 
-  renderUrl = () => {
-    if (this.state.url) {
-      return (
-        <Url href={this.state.url} target="_blank">
-          <i className="material-icons">link</i>
-          {this.state.url}
-        </Url>
-      );
-    } else {
-      let serviceName = null as string;
-      let serviceNamespace = null as string;
+    return () => {
+      closeAllWebsockets();
+    };
+  }, []);
 
-      this.state.components?.forEach((c: any) => {
-        if (c.Kind == "Service") {
-          serviceName = c.Name;
-          serviceNamespace = c.Namespace;
-        }
-      });
+  useEffect(() => {
+    updateTabs();
+    localStorage.setItem("devOpsMode", devOpsMode.toString());
+  }, [devOpsMode, currentChart?.form, isPreview]);
 
-      if (!serviceName || !serviceNamespace) {
-        return;
-      }
+  useEffect(() => {
+    let isSubscribed = true;
 
-      return (
-        <Url>
-          <Bolded>Internal URI:</Bolded>
-          {`${serviceName}.${serviceNamespace}.svc.cluster.local`}
-        </Url>
-      );
-    }
-  };
+    const ingressComponent = components?.find((c) => c.Kind === "Ingress");
+
+    const ingressName = ingressComponent?.Name;
+
+    if (!ingressName) return;
 
-  handleUninstallChart = () => {
-    let { currentProject, currentCluster } = this.context;
-    let { currentChart } = this.state;
-    this.setState({ deleting: true });
     api
-      .uninstallTemplate(
+      .getIngress(
         "<token>",
-        {},
         {
-          namespace: currentChart.namespace,
-          storage: StorageType.Secret,
-          name: currentChart.name,
-          id: currentProject.id,
           cluster_id: currentCluster.id,
+        },
+        {
+          id: currentProject.id,
+          name: ingressName,
+          namespace: `${currentChart.namespace}`,
         }
       )
       .then((res) => {
-        this.setState({ showDeleteOverlay: false });
-        this.props.closeChart();
+        if (!isSubscribed) {
+          return;
+        }
+        if (res.data?.spec?.rules && res.data?.spec?.rules[0]?.host) {
+          setUrl(`https://${res.data?.spec?.rules[0]?.host}`);
+          return;
+        }
+
+        if (res.data?.status?.loadBalancer?.ingress) {
+          setUrl(
+            `http://${res.data?.status?.loadBalancer?.ingress[0]?.hostname}`
+          );
+          return;
+        }
       })
       .catch(console.log);
-  };
-
-  renderDeleteOverlay = () => {
-    if (this.state.deleting) {
-      return (
-        <DeleteOverlay>
-          <Loading />
-        </DeleteOverlay>
-      );
-    }
-  };
-
-  render() {
-    let { closeChart } = this.props;
-    let { currentChart } = this.state;
-    let chart = currentChart;
-    let status = this.getChartStatus(chart.info.status);
-
-    return (
-      <>
-        <CloseOverlay onClick={closeChart} />
-        <StyledExpandedChart>
-          <ConfirmOverlay
-            show={this.state.showDeleteOverlay}
-            message={`Are you sure you want to delete ${currentChart.name}?`}
-            onYes={this.handleUninstallChart}
-            onNo={() => this.setState({ showDeleteOverlay: false })}
+    return () => (isSubscribed = false);
+  }, [components, currentCluster, currentProject, currentChart]);
+
+  return (
+    <>
+      <CloseOverlay onClick={props.closeChart} />
+      <StyledExpandedChart>
+        <ConfirmOverlay
+          show={showDeleteOverlay}
+          message={`Are you sure you want to delete ${currentChart.name}?`}
+          onYes={handleUninstallChart}
+          onNo={() => setShowDeleteOverlay(false)}
+        />
+        {deleting && (
+          <DeleteOverlay>
+            <Loading />
+          </DeleteOverlay>
+        )}
+        <HeaderWrapper>
+          <TitleSection>
+            <Title>
+              <IconWrapper>{renderIcon()}</IconWrapper>
+              {currentChart.name}
+            </Title>
+            {currentChart.chart.metadata.name != "worker" &&
+              currentChart.chart.metadata.name != "job" &&
+              renderUrl()}
+            <InfoWrapper>
+              <StatusIndicator
+                controllers={controllers}
+                status={currentChart.info.status}
+                margin_left={"0px"}
+              />
+              <LastDeployed>
+                <Dot>•</Dot>Last deployed
+                {" " + getReadableDate(currentChart.info.last_deployed)}
+              </LastDeployed>
+            </InfoWrapper>
+
+            <TagWrapper>
+              Namespace <NamespaceTag>{currentChart.namespace}</NamespaceTag>
+            </TagWrapper>
+          </TitleSection>
+
+          <CloseButton onClick={props.closeChart}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
+
+          <RevisionSection
+            showRevisions={showRevisions}
+            toggleShowRevisions={() => {
+              setShowRevisions(!showRevisions);
+            }}
+            chart={currentChart}
+            refreshChart={() => getChartData(currentChart)}
+            setRevision={setRevision}
+            forceRefreshRevisions={forceRefreshRevisions}
+            refreshRevisionsOff={() => setForceRefreshRevisions(false)}
+            status={chartStatus}
+            shouldUpdate={
+              currentChart.latest_version &&
+              currentChart.latest_version !==
+                currentChart.chart.metadata.version
+            }
+            latestVersion={currentChart.latest_version}
+            upgradeVersion={handleUpgradeVersion}
           />
-          {this.renderDeleteOverlay()}
-
-          <HeaderWrapper>
-            <TitleSection>
-              <Title>
-                <IconWrapper>{this.renderIcon()}</IconWrapper>
-                {chart.name}
-              </Title>
-              {chart.chart.metadata.name != "worker" &&
-                chart.chart.metadata.name != "job" &&
-                this.renderUrl()}
-              <InfoWrapper>
-                <StatusIndicator
-                  controllers={this.state.controllers}
-                  status={chart.info.status}
-                  margin_left={"0px"}
-                />
-                <LastDeployed>
-                  <Dot>•</Dot>Last deployed
-                  {" " + this.readableDate(chart.info.last_deployed)}
-                </LastDeployed>
-              </InfoWrapper>
-
-              <TagWrapper>
-                Namespace <NamespaceTag>{chart.namespace}</NamespaceTag>
-              </TagWrapper>
-            </TitleSection>
-
-            <CloseButton onClick={closeChart}>
-              <CloseButtonImg src={close} />
-            </CloseButton>
-
-            <RevisionSection
-              showRevisions={this.state.showRevisions}
-              toggleShowRevisions={() => {
-                this.setState({ showRevisions: !this.state.showRevisions });
-              }}
-              chart={chart}
-              refreshChart={this.refreshChart}
-              setRevision={this.setRevision}
-              forceRefreshRevisions={this.state.forceRefreshRevisions}
-              refreshRevisionsOff={() =>
-                this.setState({ forceRefreshRevisions: false })
-              }
-              status={status}
-              shouldUpdate={
-                chart.latest_version &&
-                chart.latest_version !== chart.chart.metadata.version
-              }
-              latestVersion={chart.latest_version}
-              upgradeVersion={this.handleUpgradeVersion}
-            />
-          </HeaderWrapper>
-          <BodyWrapper>
-            <FormWrapper
-              isReadOnly={
-                this.state.imageIsPlaceholder ||
-                !this.props.isAuthorized("application", "", ["get", "update"])
-              }
-              formData={this.state.formData}
-              tabOptions={this.state.tabOptions}
-              isInModal={true}
-              renderTabContents={this.renderTabContents}
-              onSubmit={this.onSubmit}
-              saveValuesStatus={this.state.saveValuesStatus}
-              externalValues={{
-                namespace: this.props.namespace,
-                clusterId: this.context.currentCluster.id,
-              }}
-              color={this.state.isPreview ? "#f5cb42" : null}
-              addendum={
-                <TabButton
-                  onClick={this.toggleDevOpsMode}
-                  devOpsMode={this.state.devOpsMode}
-                >
-                  <i className="material-icons">offline_bolt</i> DevOps Mode
-                </TabButton>
-              }
-            />
-          </BodyWrapper>
-        </StyledExpandedChart>
-      </>
-    );
-  }
-}
-
-ExpandedChart.contextType = Context;
+        </HeaderWrapper>
+        <BodyWrapper>
+          <FormWrapper
+            isReadOnly={
+              imageIsPlaceholder ||
+              !isAuthorized("application", "", ["get", "update"])
+            }
+            formData={currentChart.form}
+            tabOptions={tabOptions}
+            isInModal={true}
+            renderTabContents={renderTabContents}
+            onSubmit={onSubmit}
+            saveValuesStatus={saveValuesStatus}
+            externalValues={{
+              namespace: props.namespace,
+              clusterId: currentCluster.id,
+            }}
+            color={isPreview ? "#f5cb42" : null}
+            addendum={
+              <TabButton onClick={toggleDevOpsMode} devOpsMode={devOpsMode}>
+                <i className="material-icons">offline_bolt</i> DevOps Mode
+              </TabButton>
+            }
+          />
+        </BodyWrapper>
+      </StyledExpandedChart>
+    </>
+  );
+};
 
-export default withAuth(ExpandedChart);
+export default ExpandedChart;
 
 const TextWrap = styled.div``;
 

+ 0 - 17
dashboard/src/main/home/integrations/IntegrationList.tsx

@@ -92,23 +92,6 @@ export default class IntegrationList extends Component<PropsType, StateType> {
         .catch((err) => {
           this.context.setCurrentError(err);
         });
-    } else if (this.props.currentCategory === "repo") {
-      api
-        .deleteGitRepoIntegration(
-          "<token>",
-          {},
-          {
-            project_id: currentProject.id,
-            git_repo_id: this.state.deleteID,
-          }
-        )
-        .then(() => {
-          this.setState({ isDelete: false });
-          this.props.updateIntegrationList();
-        })
-        .catch((err) => {
-          this.context.setCurrentError(err);
-        });
     }
   };
 

+ 1 - 1
dashboard/src/main/home/integrations/Integrations.tsx

@@ -83,7 +83,7 @@ class Integrations extends Component<PropsType, StateType> {
 
             <IntegrationList
               currentCategory={""}
-              integrations={["kubernetes", "registry", "repo"]}
+              integrations={["kubernetes", "registry"]}
               setCurrent={(x) =>
                 pushFiltered(this.props, `/integrations/${x}`, ["project_id"])
               }

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

@@ -56,7 +56,7 @@ const defaultActionConfig: ActionConfigType = {
 
 class SourcePage extends Component<PropsType, StateType> {
   renderSourceSelector = () => {
-    let { capabilities } = this.context;
+    let { capabilities, setCurrentModal } = this.context;
     let { sourceType, setSourceType } = this.props;
 
     if (sourceType === "") {
@@ -151,9 +151,7 @@ class SourcePage extends Component<PropsType, StateType> {
         <Subtitle>
           Provide a repo folder to use as source.
           <Highlight
-            onClick={() =>
-              pushFiltered(this.props, "/integrations/repo", ["project_id"])
-            }
+            onClick={() => setCurrentModal("AccountSettingsModal", {})}
           >
             Manage Git repos
           </Highlight>

+ 11 - 12
dashboard/src/main/home/modals/AccountSettingsModal.tsx

@@ -51,16 +51,12 @@ const AccountSettingsModal = () => {
       >
         <CloseButtonImg src={close} />
       </CloseButton>
-      <ModalTitle>
-        Account Settings
-      </ModalTitle>
+      <ModalTitle>Account Settings</ModalTitle>
 
       <TabSelector
         options={tabOptions}
         currentTab={currentTab}
-        setCurrentTab={(value: string) =>
-          setCurrentTab(value)
-        }
+        setCurrentTab={(value: string) => setCurrentTab(value)}
       />
 
       <Heading>
@@ -79,7 +75,8 @@ const AccountSettingsModal = () => {
           {accessData.has_access ? (
             <Placeholder>
               <User>
-                You are currently authorized as <B>{accessData.username}</B> and have access to:
+                You are currently authorized as <B>{accessData.username}</B> and
+                have access to:
               </User>
               {!accessData.accounts || accessData.accounts?.length == 0 ? (
                 <ListWrapper>
@@ -96,7 +93,9 @@ const AccountSettingsModal = () => {
                     {accessData.accounts.map((name, i) => {
                       return (
                         <React.Fragment key={i}>
-                          <Row isLastItem={i === accessData.accounts.length - 1}>
+                          <Row
+                            isLastItem={i === accessData.accounts.length - 1}
+                          >
                             <i className="material-icons">bookmark</i>
                             {name}
                           </Row>
@@ -115,9 +114,9 @@ const AccountSettingsModal = () => {
           ) : (
             <ListWrapper>
               <Helper>
-                No github integration detected. You can
-                <A href={"/api/integrations/github-app/authorize"}>
-                  connect your GitHub account
+                No connected repositories found.
+                <A href={"/api/integrations/github-app/oauth"}>
+                  Authorize Porter to view your repositories.
                 </A>
               </Helper>
             </ListWrapper>
@@ -167,7 +166,7 @@ const Row = styled.div<{ isLastItem?: boolean }>`
   color: #ffffff55;
   display: flex;
   align-items: center;
-  border-bottom: ${props => props.isLastItem ? "" : "1px solid #ffffff44"};
+  border-bottom: ${(props) => (props.isLastItem ? "" : "1px solid #ffffff44")};
   > i {
     font-size: 17px;
     margin-left: 10px;

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

@@ -219,16 +219,6 @@ const deleteCluster = baseApi<
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}`;
 });
 
-const deleteGitRepoIntegration = baseApi<
-  {},
-  {
-    project_id: number;
-    git_repo_id: number;
-  }
->("DELETE", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}`;
-});
-
 const deleteInvite = baseApi<{}, { id: number; invId: number }>(
   "DELETE",
   (pathParams) => {
@@ -982,7 +972,6 @@ export default {
   createConfigMap,
   deleteCluster,
   deleteConfigMap,
-  deleteGitRepoIntegration,
   deleteInvite,
   deleteNamespace,
   deletePod,

+ 1 - 1
dashboard/src/shared/auth/AuthContext.tsx

@@ -16,7 +16,7 @@ const AuthProvider: React.FC = ({ children }) => {
 
   useEffect(() => {
     let isSubscribed = true;
-    if (!user) {
+    if (!user || !currentProject?.id) {
       setCurrentPolicy(null);
     } else {
       api

+ 1 - 0
docker-compose.dev.yaml

@@ -26,6 +26,7 @@ services:
       - ./server:/porter/server
       - ./api:/porter/api
       - ./docker/kubeconfig.yaml:/porter/kubeconfig.yaml
+      - ./docker/github_app_private_key.pem:/porter/docker/github_app_private_key.pem
   postgres:
     image: postgres:latest
     container_name: postgres

+ 45 - 0
docs/developing/analytics.md

@@ -0,0 +1,45 @@
+# How the analytics package works
+
+The analytics package is entirely dependant over segment, to use it you should add
+a config key SEGMENT_CLIENT_KEY on `docker/.env` file.
+To find the segment client key check [this link](https://segment.com/docs/connections/find-writekey/).
+
+This package is divided in four files:
+
+- segment.go
+
+  The _segment.go_ file exports a function to initialize the analytics client, and two superset of the original segment client functions Track and Identify. This functions will handle cases when the segment client is not initialized and will return an error if the client failed enqueueing a certain track/identify.
+
+- tracks.go
+
+  _tracks.go_ will export an interface `SegmentTrack` that all the tracks should follow, this helps when trying to standardize the analytics package. The idea behind this is to always use a constructor for the track that we're trying to use instead of having different implementations all over the app.
+
+- track_events.go
+
+  Enum of events that can be used on tracks, those will be implemented on the tracks.go so they shouldn't appear in any other part of the application.
+
+- identifiers.go
+
+  Similar as the tracks.go, although this is more specialized as it should only be used on user register/login/update parts of the application.
+
+## How to add new analytics to the app
+
+### Adding new segment spec objects
+
+The current implementation only uses [Tracks](https://segment.com/docs/connections/spec/track/) and [Identifiers](https://segment.com/docs/connections/spec/identify/) specs from the segment package, in order to add a new spec you should follow this steps:
+
+- Add the spec function that you want to use on the `internal/analytics/segment.go` file, it should always receive an interface that will get the necessary data for the segment spec function that you want to add.
+- Create a new file on the same `internal/analytics` folder with the name on plural of the spec you want to add.
+- In this spec file, you should declare the interface that the analyticsClient spec function will receive, and after that the correspondant structs that will refer to the different metrics you want to add. For more examples on how to implement this you can use as reference the `internal/analytics/tracks.go` file.
+- Update this file with the correspondant documentation about the implementation
+
+### Adding new objects to current implemented specs
+
+In order to add new metrics to the current implementation the process should be simple:
+
+- Look for the segment spec file in `internal/analytics` folder that you want to use
+- Add a new struct that accomplish the interface defined at the start of the file with the data that you need for that metric
+- Write a constructor for the struct.
+- You're done to use!
+
+For any doubts about this document or how to improve the analytics you can reach us on discord!

+ 4 - 1
go.mod

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

+ 8 - 0
go.sum

@@ -165,6 +165,8 @@ github.com/blang/semver v3.5.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnweb
 github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
 github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
 github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
+github.com/bradleyfalzon/ghinstallation v1.1.1 h1:pmBXkxgM1WeF8QYvDLT5kuQiHMcmf+X015GI0KM/E3I=
+github.com/bradleyfalzon/ghinstallation v1.1.1/go.mod h1:vyCmHTciHx/uuyN82Zc3rXN3X2KTK8nUTCrTMwAhcug=
 github.com/briandowns/spinner v1.11.1/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX3FScO+3/ZPQ=
 github.com/bshuster-repo/logrus-logstash-hook v0.4.1 h1:pgAtgj+A31JBVtEHu2uHuEx0n+2ukqUJnS2vVe5pQNA=
 github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
@@ -517,11 +519,16 @@ github.com/google/go-containerregistry v0.5.1 h1:/+mFTs4AlwsJ/mJe8NDtKb7BxLtbZFp
 github.com/google/go-containerregistry v0.5.1/go.mod h1:Ct15B4yir3PLOP5jsy0GNeYVaIZs/MK/Jz5any1wFW0=
 github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY=
 github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
+github.com/google/go-github/v29 v29.0.2/go.mod h1:CHKiKKPHJ0REzfwc14QMklvtHwCveD0PxlMjLlzAM5E=
+github.com/google/go-github/v29 v29.0.3 h1:IktKCTwU//aFHnpA+2SLIi7Oo9uhAzgsdZNbcAqhgdc=
+github.com/google/go-github/v29 v29.0.3/go.mod h1:CHKiKKPHJ0REzfwc14QMklvtHwCveD0PxlMjLlzAM5E=
 github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8=
 github.com/google/go-github/v33 v33.0.0 h1:qAf9yP0qc54ufQxzwv+u9H0tiVOnPJxo0lI/JXqw3ZM=
 github.com/google/go-github/v33 v33.0.0/go.mod h1:GMdDnVZY/2TsWgp/lkYnpSAh6TrzhANBBwm6k6TTEXg=
 github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
 github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
+github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
+github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
 github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g=
@@ -1404,6 +1411,7 @@ golang.org/x/sys v0.0.0-20210319071255-635bc2c9138d/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=

+ 48 - 0
internal/analytics/identifiers.go

@@ -0,0 +1,48 @@
+package analytics
+
+import (
+	"fmt"
+
+	"github.com/porter-dev/porter/internal/models"
+	segment "gopkg.in/segmentio/analytics-go.v3"
+)
+
+type segmentIdentifier interface {
+	getUserId() string
+	getTraits() segment.Traits
+}
+
+type segmentIdentifyNewUser struct {
+	userId    string
+	userEmail string
+	isGithub  bool
+}
+
+// Creates a segment Identifier struct for new users. As we handle registration with github, it also accepts a param
+// to check if the new user has registered with github or not.
+func CreateSegmentIdentifyNewUser(user *models.User, registeredViaGithub bool) *segmentIdentifyNewUser {
+	userId := fmt.Sprintf("%v", user.ID)
+	return &segmentIdentifyNewUser{
+		userId:    userId,
+		userEmail: user.Email,
+		isGithub:  registeredViaGithub,
+	}
+}
+
+func (i segmentIdentifyNewUser) getUserId() string {
+	return i.userId
+}
+
+func (i segmentIdentifyNewUser) getTraits() segment.Traits {
+	var githubTrait string
+
+	if i.isGithub {
+		githubTrait = "true"
+	} else {
+		githubTrait = "false"
+	}
+
+	return segment.NewTraits().
+		SetEmail(i.userEmail).
+		Set("github", githubTrait)
+}

+ 77 - 0
internal/analytics/segment.go

@@ -0,0 +1,77 @@
+package analytics
+
+import (
+	"github.com/porter-dev/porter/internal/logger"
+	segment "gopkg.in/segmentio/analytics-go.v3"
+)
+
+type AnalyticsSegmentClient interface {
+	Identify(segmentIdentifier) error
+	Track(segmentTrack) error
+}
+
+type AnalyticsSegment struct {
+	segment.Client
+	isEnabled bool
+	logger    *logger.Logger
+}
+
+// Initialize the segment client and return a superset of it, the AnalyticsSegmentClient will handle cases when
+// the segment client failed on initialization or not enabled
+func InitializeAnalyticsSegmentClient(segmentClientKey string, logger *logger.Logger) AnalyticsSegmentClient {
+	if segmentClientKey != "" {
+
+		client := segment.New(segmentClientKey)
+
+		if client == nil {
+			return &AnalyticsSegment{
+				isEnabled: false,
+				logger:    logger,
+			}
+		}
+
+		return &AnalyticsSegment{
+			Client:    client,
+			isEnabled: true,
+			logger:    logger,
+		}
+	}
+
+	return &AnalyticsSegment{
+		isEnabled: false,
+		logger:    logger,
+	}
+}
+
+//	Superset of segment client identify function, this will accept analytics defined identifiers only
+//	and will log an error if the client is not initialized
+
+func (c *AnalyticsSegment) Identify(identifier segmentIdentifier) error {
+	if !c.isEnabled {
+		c.logger.Error().Msg("Analytics not enabled")
+		return nil
+	}
+
+	err := c.Enqueue(segment.Identify{
+		UserId: identifier.getUserId(),
+		Traits: identifier.getTraits(),
+	})
+	return err
+}
+
+//	Superset of segment client track function, this will accept analytics defined tracks only
+//	and will log an error if the client is not initialized
+func (c *AnalyticsSegment) Track(track segmentTrack) error {
+	if !c.isEnabled {
+		c.logger.Error().Msg("Analytics not enabled")
+		return nil
+	}
+
+	err := c.Enqueue(segment.Track{
+		UserId:     track.getUserId(),
+		Event:      string(track.getEvent()),
+		Properties: track.getProperties(),
+	})
+
+	return err
+}

+ 8 - 0
internal/analytics/track_events.go

@@ -0,0 +1,8 @@
+package analytics
+
+type SegmentEvent string
+
+const (
+	NewUser            SegmentEvent = "New User"
+	RedeployViaWebhook SegmentEvent = "Triggered Re-deploy via Webhook"
+)

+ 68 - 0
internal/analytics/tracks.go

@@ -0,0 +1,68 @@
+package analytics
+
+import (
+	"fmt"
+
+	"github.com/porter-dev/porter/internal/models"
+	segment "gopkg.in/segmentio/analytics-go.v3"
+)
+
+type segmentTrack interface {
+	getUserId() string
+	getEvent() SegmentEvent
+	getProperties() segment.Properties
+}
+
+type segmentNewUserTrack struct {
+	userId    string
+	userEmail string
+}
+
+// Constructor for track of type "New User"
+// Tracks when a user has registered
+func CreateSegmentNewUserTrack(user *models.User) *segmentNewUserTrack {
+	userId := fmt.Sprintf("%v", user.ID)
+
+	return &segmentNewUserTrack{
+		userId:    userId,
+		userEmail: user.Email,
+	}
+}
+
+func (t *segmentNewUserTrack) getUserId() string {
+	return t.userId
+}
+
+func (t *segmentNewUserTrack) getEvent() SegmentEvent {
+	return NewUser
+}
+
+func (t *segmentNewUserTrack) getProperties() segment.Properties {
+	return segment.NewProperties().Set("email", t.userEmail)
+}
+
+type segmentRedeployViaWebhookTrack struct {
+	userId     string
+	repository string
+}
+
+// Constructor for track of type "Triggered Re-deploy via Webhook"
+// tracks whenever a repository is redeployed via webhook call
+func CreateSegmentRedeployViaWebhookTrack(userId string, repository string) *segmentRedeployViaWebhookTrack {
+	return &segmentRedeployViaWebhookTrack{
+		userId:     userId,
+		repository: repository,
+	}
+}
+
+func (t *segmentRedeployViaWebhookTrack) getUserId() string {
+	return t.userId
+}
+
+func (t *segmentRedeployViaWebhookTrack) getEvent() SegmentEvent {
+	return RedeployViaWebhook
+}
+
+func (t *segmentRedeployViaWebhookTrack) getProperties() segment.Properties {
+	return segment.NewProperties().Set("repository", t.repository)
+}

+ 2 - 0
internal/config/config.go

@@ -45,6 +45,8 @@ type ServerConf struct {
 	GithubAppClientSecret  string `env:"GITHUB_APP_CLIENT_SECRET"`
 	GithubAppName          string `env:"GITHUB_APP_NAME"`
 	GithubAppWebhookSecret string `env:"GITHUB_APP_WEBHOOK_SECRET"`
+	GithubAppID            string `env:"GITHUB_APP_ID"`
+	GithubAppSecretPath    string `env:"GITHUB_APP_SECRET_PATH"`
 
 	GoogleClientID         string `env:"GOOGLE_CLIENT_ID"`
 	GoogleClientSecret     string `env:"GOOGLE_CLIENT_SECRET"`

+ 8 - 7
internal/forms/git_action.go

@@ -21,13 +21,14 @@ type CreateGitAction struct {
 // ToGitActionConfig converts the form to a gorm git action config model
 func (ca *CreateGitAction) ToGitActionConfig() (*models.GitActionConfig, error) {
 	return &models.GitActionConfig{
-		ReleaseID:      ca.ReleaseID,
-		GitRepo:        ca.GitRepo,
-		GitBranch:      ca.GitBranch,
-		ImageRepoURI:   ca.ImageRepoURI,
-		DockerfilePath: ca.DockerfilePath,
-		FolderPath:     ca.FolderPath,
-		GitRepoID:      ca.GitRepoID,
+		ReleaseID:            ca.ReleaseID,
+		GitRepo:              ca.GitRepo,
+		GitBranch:            ca.GitBranch,
+		ImageRepoURI:         ca.ImageRepoURI,
+		DockerfilePath:       ca.DockerfilePath,
+		FolderPath:           ca.FolderPath,
+		GithubInstallationID: ca.GitRepoID,
+		IsInstallation:       true,
 	}, nil
 }
 

+ 54 - 21
internal/integrations/ci/actions/actions.go

@@ -4,12 +4,14 @@ import (
 	"context"
 	"encoding/base64"
 	"fmt"
-
+	"github.com/bradleyfalzon/ghinstallation"
 	"github.com/google/go-github/v33/github"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/repository"
 	"golang.org/x/crypto/nacl/box"
 	"golang.org/x/oauth2"
+	"net/http"
 
 	"strings"
 
@@ -19,12 +21,14 @@ import (
 type GithubActions struct {
 	ServerURL string
 
-	GitIntegration *models.GitRepo
-	GitRepoName    string
-	GitRepoOwner   string
-	Repo           repository.Repository
+	GithubOAuthIntegration *models.GitRepo
+	GitRepoName            string
+	GitRepoOwner           string
+	Repo                   repository.Repository
 
-	GithubConf *oauth2.Config
+	GithubConf           *oauth2.Config // one of these will let us authenticate
+	GithubAppID          int64
+	GithubInstallationID uint
 
 	WebhookToken string
 	PorterToken  string
@@ -197,22 +201,45 @@ func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
 }
 
 func (g *GithubActions) getClient() (*github.Client, error) {
-	// get the oauth integration
-	oauthInt, err := g.Repo.OAuthIntegration.ReadOAuthIntegration(g.GitIntegration.OAuthIntegrationID)
 
-	if err != nil {
-		return nil, err
-	}
+	// in the case that this still uses the oauth integration
+	if g.GithubOAuthIntegration != nil {
+
+		// get the oauth integration
+		oauthInt, err := g.Repo.OAuthIntegration.ReadOAuthIntegration(g.GithubOAuthIntegration.OAuthIntegrationID)
 
-	tok := &oauth2.Token{
-		AccessToken:  string(oauthInt.AccessToken),
-		RefreshToken: string(oauthInt.RefreshToken),
-		TokenType:    "Bearer",
+		if err != nil {
+			return nil, err
+		}
+
+		_, _, err = oauth.GetAccessToken(oauthInt.SharedOAuthModel, g.GithubConf, oauth.MakeUpdateOAuthIntegrationTokenFunction(oauthInt, g.Repo))
+
+		if err != nil {
+			return nil, err
+		}
+
+		client := github.NewClient(g.GithubConf.Client(oauth2.NoContext, &oauth2.Token{
+			AccessToken:  string(oauthInt.AccessToken),
+			RefreshToken: string(oauthInt.RefreshToken),
+			Expiry:       oauthInt.Expiry,
+			TokenType:    "Bearer",
+		}))
+
+		return client, nil
 	}
 
-	client := github.NewClient(g.GithubConf.Client(oauth2.NoContext, tok))
+	// authenticate as github app installation
+	itr, err := ghinstallation.NewKeyFromFile(
+		http.DefaultTransport,
+		g.GithubAppID,
+		int64(g.GithubInstallationID),
+		"/porter/docker/github_app_private_key.pem")
 
-	return client, nil
+	if err != nil {
+		return nil, err
+	}
+
+	return github.NewClient(&http.Client{Transport: itr}), nil
 }
 
 func (g *GithubActions) createGithubSecret(
@@ -352,10 +379,13 @@ func (g *GithubActions) commitGithubFile(
 		Content: contents,
 		Branch:  github.String(branch),
 		SHA:     &sha,
-		Committer: &github.CommitAuthor{
+	}
+
+	if g.GithubOAuthIntegration != nil {
+		opts.Committer = &github.CommitAuthor{
 			Name:  github.String("Porter Bot"),
 			Email: github.String("contact@getporter.dev"),
-		},
+		}
 	}
 
 	resp, _, err := client.Repositories.UpdateFile(
@@ -397,10 +427,13 @@ func (g *GithubActions) deleteGithubFile(
 		Message: github.String(fmt.Sprintf("Delete %s file", filename)),
 		Branch:  github.String(g.defaultBranch),
 		SHA:     &sha,
-		Committer: &github.CommitAuthor{
+	}
+
+	if g.GithubOAuthIntegration != nil {
+		opts.Committer = &github.CommitAuthor{
 			Name:  github.String("Porter Bot"),
 			Email: github.String("contact@getporter.dev"),
-		},
+		}
 	}
 
 	_, _, err := client.Repositories.DeleteFile(

+ 1217 - 1217
internal/kubernetes/agent.go

@@ -1,1217 +1,1217 @@
-package kubernetes
-
-import (
-	"bufio"
-	"bytes"
-	"compress/gzip"
-	"context"
-	"encoding/base64"
-	"encoding/json"
-	"fmt"
-	"io"
-	"io/ioutil"
-	"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"
-
-	rspb "helm.sh/helm/v3/pkg/release"
-)
-
-// 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(stopper)
-
-	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 {
-				conn.Close()
-				errorchan <- nil
-				return
-			}
-		}
-	}()
-
-	go informer.Run(stopper)
-
-	for {
-		select {
-		case err := <-errorchan:
-			return err
-		}
-	}
-}
-
-var b64 = base64.StdEncoding
-
-var magicGzip = []byte{0x1f, 0x8b, 0x08}
-
-func decodeRelease(data string) (*rspb.Release, error) {
-	// base64 decode string
-	b, err := b64.DecodeString(data)
-	if err != nil {
-		return nil, err
-	}
-
-	// For backwards compatibility with releases that were stored before
-	// compression was introduced we skip decompression if the
-	// gzip magic header is not found
-	if bytes.Equal(b[0:3], magicGzip) {
-		r, err := gzip.NewReader(bytes.NewReader(b))
-		if err != nil {
-			return nil, err
-		}
-		defer r.Close()
-		b2, err := ioutil.ReadAll(r)
-		if err != nil {
-			return nil, err
-		}
-		b = b2
-	}
-
-	var rls rspb.Release
-	// unmarshal release object bytes
-	if err := json.Unmarshal(b, &rls); err != nil {
-		return nil, err
-	}
-	return &rls, nil
-}
-
-func contains(s []string, str string) bool {
-	for _, v := range s {
-		if v == str {
-			return true
-		}
-	}
-
-	return false
-}
-
-func parseSecretToHelmRelease(secret v1.Secret, chartList []string) (*rspb.Release, bool, error) {
-	if secret.Type != "helm.sh/release.v1" {
-		return nil, true, nil
-	}
-
-	releaseData, ok := secret.Data["release"]
-
-	if !ok {
-		return nil, true, fmt.Errorf("release field not found")
-	}
-
-	helm_object, err := decodeRelease(string(releaseData))
-
-	if err != nil {
-		return nil, true, err
-	}
-
-	if len(chartList) > 0 && !contains(chartList, helm_object.Name) {
-		return nil, true, nil
-	}
-
-	return helm_object, false, nil
-}
-
-func (a *Agent) StreamHelmReleases(conn *websocket.Conn, chartList []string, selectors string) error {
-	tweakListOptionsFunc := func(options *metav1.ListOptions) {
-		options.LabelSelector = selectors
-	}
-
-	factory := informers.NewSharedInformerFactoryWithOptions(
-		a.Clientset,
-		0,
-		informers.WithTweakListOptions(tweakListOptionsFunc),
-	)
-
-	informer := factory.Core().V1().Secrets().Informer()
-
-	stopper := make(chan struct{})
-	errorchan := make(chan error)
-	defer close(stopper)
-
-	informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
-		UpdateFunc: func(oldObj, newObj interface{}) {
-			secretObj, ok := newObj.(*v1.Secret)
-
-			if !ok {
-				errorchan <- fmt.Errorf("could not cast to secret")
-				return
-			}
-
-			helm_object, isNotHelmRelease, err := parseSecretToHelmRelease(*secretObj, chartList)
-
-			if isNotHelmRelease && err == nil {
-				return
-			}
-
-			if err != nil {
-				errorchan <- err
-				return
-			}
-
-			msg := Message{
-				EventType: "UPDATE",
-				Object:    helm_object,
-			}
-
-			if writeErr := conn.WriteJSON(msg); writeErr != nil {
-				errorchan <- writeErr
-				return
-			}
-		},
-		AddFunc: func(obj interface{}) {
-			secretObj, ok := obj.(*v1.Secret)
-
-			if !ok {
-				errorchan <- fmt.Errorf("could not cast to secret")
-				return
-			}
-
-			helm_object, isNotHelmRelease, err := parseSecretToHelmRelease(*secretObj, chartList)
-
-			if isNotHelmRelease && err == nil {
-				return
-			}
-
-			if err != nil {
-				errorchan <- err
-				return
-			}
-
-			msg := Message{
-				EventType: "ADD",
-				Object:    helm_object,
-			}
-
-			if writeErr := conn.WriteJSON(msg); writeErr != nil {
-				errorchan <- writeErr
-				return
-			}
-		},
-		DeleteFunc: func(obj interface{}) {
-			secretObj, ok := obj.(*v1.Secret)
-
-			if !ok {
-				errorchan <- fmt.Errorf("could not cast to secret")
-				return
-			}
-
-			helm_object, isNotHelmRelease, err := parseSecretToHelmRelease(*secretObj, chartList)
-
-			if isNotHelmRelease && err == nil {
-				return
-			}
-
-			if err != nil {
-				errorchan <- err
-				return
-			}
-
-			msg := Message{
-				EventType: "DELETE",
-				Object:    helm_object,
-			}
-
-			if writeErr := conn.WriteJSON(msg); writeErr != nil {
-				errorchan <- writeErr
-				return
-			}
-		},
-	})
-
-	go func() {
-		// listens for websocket closing handshake
-		for {
-			if _, _, err := conn.ReadMessage(); err != nil {
-				conn.Close()
-				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,
-	provImagePullSecret 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,
-		ImagePullSecret:     provImagePullSecret,
-		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,
-	provImagePullSecret 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,
-		ImagePullSecret:     provImagePullSecret,
-		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,
-	provImagePullSecret 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,
-		ImagePullSecret:     provImagePullSecret,
-		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,
-	provImagePullSecret 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,
-		ImagePullSecret:     provImagePullSecret,
-		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,
-	provImagePullSecret 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,
-		ImagePullSecret:     provImagePullSecret,
-		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,
-	provImagePullSecret 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,
-		ImagePullSecret:     provImagePullSecret,
-		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,
-	provImagePullSecret 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,
-		ImagePullSecret:     provImagePullSecret,
-	}
-
-	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"
+	"compress/gzip"
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"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"
+
+	rspb "helm.sh/helm/v3/pkg/release"
+)
+
+// 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(stopper)
+
+	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 {
+				conn.Close()
+				errorchan <- nil
+				return
+			}
+		}
+	}()
+
+	go informer.Run(stopper)
+
+	for {
+		select {
+		case err := <-errorchan:
+			return err
+		}
+	}
+}
+
+var b64 = base64.StdEncoding
+
+var magicGzip = []byte{0x1f, 0x8b, 0x08}
+
+func decodeRelease(data string) (*rspb.Release, error) {
+	// base64 decode string
+	b, err := b64.DecodeString(data)
+	if err != nil {
+		return nil, err
+	}
+
+	// For backwards compatibility with releases that were stored before
+	// compression was introduced we skip decompression if the
+	// gzip magic header is not found
+	if bytes.Equal(b[0:3], magicGzip) {
+		r, err := gzip.NewReader(bytes.NewReader(b))
+		if err != nil {
+			return nil, err
+		}
+		defer r.Close()
+		b2, err := ioutil.ReadAll(r)
+		if err != nil {
+			return nil, err
+		}
+		b = b2
+	}
+
+	var rls rspb.Release
+	// unmarshal release object bytes
+	if err := json.Unmarshal(b, &rls); err != nil {
+		return nil, err
+	}
+	return &rls, nil
+}
+
+func contains(s []string, str string) bool {
+	for _, v := range s {
+		if v == str {
+			return true
+		}
+	}
+
+	return false
+}
+
+func parseSecretToHelmRelease(secret v1.Secret, chartList []string) (*rspb.Release, bool, error) {
+	if secret.Type != "helm.sh/release.v1" {
+		return nil, true, nil
+	}
+
+	releaseData, ok := secret.Data["release"]
+
+	if !ok {
+		return nil, true, fmt.Errorf("release field not found")
+	}
+
+	helm_object, err := decodeRelease(string(releaseData))
+
+	if err != nil {
+		return nil, true, err
+	}
+
+	if len(chartList) > 0 && !contains(chartList, helm_object.Name) {
+		return nil, true, nil
+	}
+
+	return helm_object, false, nil
+}
+
+func (a *Agent) StreamHelmReleases(conn *websocket.Conn, chartList []string, selectors string) error {
+	tweakListOptionsFunc := func(options *metav1.ListOptions) {
+		options.LabelSelector = selectors
+	}
+
+	factory := informers.NewSharedInformerFactoryWithOptions(
+		a.Clientset,
+		0,
+		informers.WithTweakListOptions(tweakListOptionsFunc),
+	)
+
+	informer := factory.Core().V1().Secrets().Informer()
+
+	stopper := make(chan struct{})
+	errorchan := make(chan error)
+	defer close(stopper)
+
+	informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
+		UpdateFunc: func(oldObj, newObj interface{}) {
+			secretObj, ok := newObj.(*v1.Secret)
+
+			if !ok {
+				errorchan <- fmt.Errorf("could not cast to secret")
+				return
+			}
+
+			helm_object, isNotHelmRelease, err := parseSecretToHelmRelease(*secretObj, chartList)
+
+			if isNotHelmRelease && err == nil {
+				return
+			}
+
+			if err != nil {
+				errorchan <- err
+				return
+			}
+
+			msg := Message{
+				EventType: "UPDATE",
+				Object:    helm_object,
+			}
+
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		},
+		AddFunc: func(obj interface{}) {
+			secretObj, ok := obj.(*v1.Secret)
+
+			if !ok {
+				errorchan <- fmt.Errorf("could not cast to secret")
+				return
+			}
+
+			helm_object, isNotHelmRelease, err := parseSecretToHelmRelease(*secretObj, chartList)
+
+			if isNotHelmRelease && err == nil {
+				return
+			}
+
+			if err != nil {
+				errorchan <- err
+				return
+			}
+
+			msg := Message{
+				EventType: "ADD",
+				Object:    helm_object,
+			}
+
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		},
+		DeleteFunc: func(obj interface{}) {
+			secretObj, ok := obj.(*v1.Secret)
+
+			if !ok {
+				errorchan <- fmt.Errorf("could not cast to secret")
+				return
+			}
+
+			helm_object, isNotHelmRelease, err := parseSecretToHelmRelease(*secretObj, chartList)
+
+			if isNotHelmRelease && err == nil {
+				return
+			}
+
+			if err != nil {
+				errorchan <- err
+				return
+			}
+
+			msg := Message{
+				EventType: "DELETE",
+				Object:    helm_object,
+			}
+
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		},
+	})
+
+	go func() {
+		// listens for websocket closing handshake
+		for {
+			if _, _, err := conn.ReadMessage(); err != nil {
+				conn.Close()
+				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,
+	provImagePullSecret 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,
+		ImagePullSecret:     provImagePullSecret,
+		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,
+	provImagePullSecret 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,
+		ImagePullSecret:     provImagePullSecret,
+		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,
+	provImagePullSecret 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,
+		ImagePullSecret:     provImagePullSecret,
+		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,
+	provImagePullSecret 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,
+		ImagePullSecret:     provImagePullSecret,
+		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,
+	provImagePullSecret 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.SharedOAuthModel, doAuth, oauth.MakeUpdateOAuthIntegrationTokenFunction(oauthInt, 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,
+		ImagePullSecret:     provImagePullSecret,
+		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,
+	provImagePullSecret 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.SharedOAuthModel, doAuth, oauth.MakeUpdateOAuthIntegrationTokenFunction(oauthInt, 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,
+		ImagePullSecret:     provImagePullSecret,
+		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,
+	provImagePullSecret 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,
+		ImagePullSecret:     provImagePullSecret,
+	}
+
+	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
+}

+ 1 - 1
internal/kubernetes/config.go

@@ -339,7 +339,7 @@ func (conf *OutOfClusterConfig) CreateRawConfigFromCluster() (*api.Config, error
 			return nil, err
 		}
 
-		tok, _, err := oauth.GetAccessToken(oauthInt, conf.DigitalOceanOAuth, *conf.Repo)
+		tok, _, err := oauth.GetAccessToken(oauthInt.SharedOAuthModel, conf.DigitalOceanOAuth, oauth.MakeUpdateOAuthIntegrationTokenFunction(oauthInt, *conf.Repo))
 
 		if err != nil {
 			return nil, err

+ 6 - 3
internal/models/gitrepo.go

@@ -61,14 +61,17 @@ type GitActionConfig struct {
 	// The complete image repository uri to pull from
 	ImageRepoURI string `json:"image_repo_uri"`
 
-	// The git integration id
-	GitRepoID uint `json:"git_repo_id"`
+	// The git installation ID
+	GithubInstallationID uint `json:"git_repo_id"`
 
 	// The path to the dockerfile in the git repo
 	DockerfilePath string `json:"dockerfile_path"`
 
 	// The build context
 	FolderPath string `json:"folder_path"`
+
+	// Determines on how authentication is performed on this action
+	IsInstallation bool `json:"is_installation"`
 }
 
 // GitActionConfigExternal is an external GitActionConfig to be shared over REST
@@ -98,7 +101,7 @@ func (r *GitActionConfig) Externalize() *GitActionConfigExternal {
 		GitRepo:        r.GitRepo,
 		GitBranch:      r.GitBranch,
 		ImageRepoURI:   r.ImageRepoURI,
-		GitRepoID:      r.GitRepoID,
+		GitRepoID:      r.GithubInstallationID,
 		DockerfilePath: r.DockerfilePath,
 		FolderPath:     r.FolderPath,
 	}

+ 5 - 0
internal/models/integrations/oauth.go

@@ -2,6 +2,7 @@ package integrations
 
 import (
 	"gorm.io/gorm"
+	"time"
 )
 
 // OAuthIntegrationClient is the name of an OAuth mechanism client
@@ -24,6 +25,10 @@ type SharedOAuthModel struct {
 
 	// The end-user's refresh token
 	RefreshToken []byte `json:"refresh-token"`
+
+	// Time token expires and needs to be refreshed.
+	// If 0, token will never refresh
+	Expiry time.Time
 }
 
 // OAuthIntegration is an auth mechanism that uses oauth

+ 48 - 12
internal/oauth/config.go

@@ -4,10 +4,11 @@ import (
 	"context"
 	"crypto/rand"
 	"encoding/base64"
-	"time"
-
+	"fmt"
 	"github.com/porter-dev/porter/internal/models/integrations"
 	"github.com/porter-dev/porter/internal/repository"
+	"time"
+
 	"golang.org/x/oauth2"
 )
 
@@ -22,6 +23,8 @@ type Config struct {
 type GithubAppConf struct {
 	AppName       string
 	WebhookSecret string
+	SecretPath    string
+	AppID         int64
 	oauth2.Config
 }
 
@@ -38,10 +41,12 @@ func NewGithubClient(cfg *Config) *oauth2.Config {
 	}
 }
 
-func NewGithubAppClient(cfg *Config, name string, secret string) *GithubAppConf {
+func NewGithubAppClient(cfg *Config, name string, secret string, secretPath string, appID int64) *GithubAppConf {
 	return &GithubAppConf{
 		AppName:       name,
 		WebhookSecret: secret,
+		SecretPath:    secretPath,
+		AppID:         appID,
 		Config: oauth2.Config{
 			ClientID:     cfg.ClientID,
 			ClientSecret: cfg.ClientSecret,
@@ -90,17 +95,50 @@ func CreateRandomState() string {
 	return state
 }
 
+// MakeUpdateOAuthIntegrationTokenFunction creates a function to be passed to GetAccessToken that updates the OauthIntegration
+// if it needs to be updated
+func MakeUpdateOAuthIntegrationTokenFunction(
+	o *integrations.OAuthIntegration,
+	repo repository.Repository) func(accessToken []byte, refreshToken []byte, expiry time.Time) error {
+	return func(accessToken []byte, refreshToken []byte, expiry time.Time) error {
+		o.AccessToken = accessToken
+		o.RefreshToken = refreshToken
+		o.Expiry = expiry
+
+		_, err := repo.OAuthIntegration.UpdateOAuthIntegration(o)
+
+		return err
+	}
+}
+
+// MakeUpdateGithubAppOauthIntegrationFunction creates a function to be passed to GetAccessToken that updates the GithubAppOauthIntegration
+// if it needs to be updated
+func MakeUpdateGithubAppOauthIntegrationFunction(
+	o *integrations.GithubAppOAuthIntegration,
+	repo repository.Repository) func(accessToken []byte, refreshToken []byte, expiry time.Time) error {
+	return func(accessToken []byte, refreshToken []byte, expiry time.Time) error {
+		o.AccessToken = accessToken
+		o.RefreshToken = refreshToken
+		o.Expiry = expiry
+
+		_, err := repo.GithubAppOAuthIntegration.UpdateGithubAppOauthIntegration(o)
+
+		return err
+	}
+}
+
 // GetAccessToken retrieves an access token for a given client. It updates the
 // access token in the DB if necessary
 func GetAccessToken(
-	o *integrations.OAuthIntegration,
+	prevToken integrations.SharedOAuthModel,
 	conf *oauth2.Config,
-	repo repository.Repository,
+	updateToken func(accessToken []byte, refreshToken []byte, expiry time.Time) error,
 ) (string, *time.Time, error) {
 	tokSource := conf.TokenSource(context.TODO(), &oauth2.Token{
-		AccessToken:  string(o.AccessToken),
-		RefreshToken: string(o.RefreshToken),
+		AccessToken:  string(prevToken.AccessToken),
+		RefreshToken: string(prevToken.RefreshToken),
 		TokenType:    "Bearer",
+		Expiry:       prevToken.Expiry,
 	})
 
 	token, err := tokSource.Token()
@@ -109,11 +147,9 @@ func GetAccessToken(
 		return "", nil, err
 	}
 
-	if token.AccessToken != string(o.AccessToken) {
-		o.AccessToken = []byte(token.AccessToken)
-		o.RefreshToken = []byte(token.RefreshToken)
-
-		o, err = repo.OAuthIntegration.UpdateOAuthIntegration(o)
+	if token.AccessToken != string(prevToken.AccessToken) {
+		fmt.Println("access happening...")
+		err := updateToken([]byte(token.AccessToken), []byte(token.RefreshToken), token.Expiry)
 
 		if err != nil {
 			return "", nil, err

+ 5 - 5
internal/registry/registry.go

@@ -9,7 +9,7 @@ import (
 	"net/url"
 	"strings"
 	"time"
-	
+
 	"github.com/aws/aws-sdk-go/aws/awserr"
 	"github.com/aws/aws-sdk-go/service/ecr"
 	"github.com/porter-dev/porter/internal/models"
@@ -226,7 +226,7 @@ func (r *Registry) listDOCRRepositories(
 		return nil, err
 	}
 
-	tok, _, err := oauth.GetAccessToken(oauthInt, doAuth, repo)
+	tok, _, err := oauth.GetAccessToken(oauthInt.SharedOAuthModel, doAuth, oauth.MakeUpdateOAuthIntegrationTokenFunction(oauthInt, repo))
 
 	if err != nil {
 		return nil, err
@@ -598,7 +598,7 @@ func (r *Registry) listDOCRImages(
 		return nil, err
 	}
 
-	tok, _, err := oauth.GetAccessToken(oauthInt, doAuth, repo)
+	tok, _, err := oauth.GetAccessToken(oauthInt.SharedOAuthModel, doAuth, oauth.MakeUpdateOAuthIntegrationTokenFunction(oauthInt, repo))
 
 	if err != nil {
 		return nil, err
@@ -720,7 +720,7 @@ func (r *Registry) listDockerHubImages(repoName string, repo repository.Reposito
 	// first, make a request for the access token
 
 	data, err := json.Marshal(&dockerHubLoginReq{
-		Username: string(basic.Username), 
+		Username: string(basic.Username),
 		Password: string(basic.Password),
 	})
 
@@ -919,7 +919,7 @@ func (r *Registry) getDOCRDockerConfigFile(
 		return nil, err
 	}
 
-	tok, _, err := oauth.GetAccessToken(oauthInt, doAuth, repo)
+	tok, _, err := oauth.GetAccessToken(oauthInt.SharedOAuthModel, doAuth, oauth.MakeUpdateOAuthIntegrationTokenFunction(oauthInt, repo))
 
 	if err != nil {
 		return nil, err

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

@@ -1179,3 +1179,15 @@ func (repo *GithubAppOAuthIntegrationRepository) ReadGithubAppOauthIntegration(i
 
 	return ret, nil
 }
+
+// UpdateGithubAppOauthIntegration updates a GithubAppOauthIntegration
+func (repo *GithubAppOAuthIntegrationRepository) UpdateGithubAppOauthIntegration(am *ints.GithubAppOAuthIntegration) (*ints.GithubAppOAuthIntegration, error) {
+
+	err := repo.db.Save(am).Error
+
+	if err != nil {
+		return nil, err
+	}
+
+	return am, nil
+}

+ 4 - 4
internal/repository/gorm/git_action_config_test.go

@@ -20,10 +20,10 @@ func TestCreateGitActionConfig(t *testing.T) {
 	defer cleanup(tester, t)
 
 	ga := &models.GitActionConfig{
-		ReleaseID:    1,
-		GitRepo:      "porter-dev/porter",
-		ImageRepoURI: "gcr.io/project-123456/nginx",
-		GitRepoID:    1,
+		ReleaseID:            1,
+		GitRepo:              "porter-dev/porter",
+		ImageRepoURI:         "gcr.io/project-123456/nginx",
+		GithubInstallationID: 1,
 	}
 
 	expGA := *ga

+ 1 - 0
internal/repository/integrations.go

@@ -42,6 +42,7 @@ type OAuthIntegrationRepository interface {
 type GithubAppOAuthIntegrationRepository interface {
 	CreateGithubAppOAuthIntegration(am *ints.GithubAppOAuthIntegration) (*ints.GithubAppOAuthIntegration, error)
 	ReadGithubAppOauthIntegration(id uint) (*ints.GithubAppOAuthIntegration, error)
+	UpdateGithubAppOauthIntegration(am *ints.GithubAppOAuthIntegration) (*ints.GithubAppOAuthIntegration, error)
 }
 
 // AWSIntegrationRepository represents the set of queries on the AWS auth

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

@@ -546,3 +546,18 @@ func (repo *GithubAppOAuthIntegrationRepository) ReadGithubAppOauthIntegration(i
 
 	return repo.githubAppOauthIntegrations[int(id-1)], nil
 }
+
+func (repo *GithubAppOAuthIntegrationRepository) UpdateGithubAppOauthIntegration(am *ints.GithubAppOAuthIntegration) (*ints.GithubAppOAuthIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(am.ID-1) >= len(repo.githubAppOauthIntegrations) || repo.githubAppOauthIntegrations[am.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(am.ID - 1)
+	repo.githubAppOauthIntegrations[index] = am
+
+	return am, nil
+}

+ 23 - 17
server/api/api.go

@@ -3,6 +3,7 @@ package api
 import (
 	"fmt"
 	"net/http"
+	"strconv"
 	"strings"
 
 	"github.com/go-playground/locales/en"
@@ -22,9 +23,9 @@ import (
 	"github.com/porter-dev/porter/internal/repository"
 	memory "github.com/porter-dev/porter/internal/repository/memory"
 	"github.com/porter-dev/porter/internal/validator"
-	segment "gopkg.in/segmentio/analytics-go.v3"
 	"helm.sh/helm/v3/pkg/storage"
 
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/config"
 )
 
@@ -87,11 +88,11 @@ type App struct {
 	DOConf            *oauth2.Config
 	GoogleUserConf    *oauth2.Config
 
-	db            *gorm.DB
-	validator     *vr.Validate
-	translator    *ut.Translator
-	tokenConf     *token.TokenGeneratorConf
-	segmentClient *segment.Client
+	db              *gorm.DB
+	validator       *vr.Validate
+	translator      *ut.Translator
+	tokenConf       *token.TokenGeneratorConf
+	analyticsClient analytics.AnalyticsSegmentClient
 }
 
 type AppCapabilities struct {
@@ -170,13 +171,20 @@ func New(conf *AppConfig) (*App, error) {
 		app.Capabilities.GithubLogin = sc.GithubLoginEnabled
 	}
 
-	if sc.GithubAppClientID != "" && sc.GithubAppClientSecret != "" && sc.GithubAppName != "" && sc.GithubAppWebhookSecret != "" {
-		app.GithubAppConf = oauth.NewGithubAppClient(&oauth.Config{
-			ClientID:     sc.GithubAppClientID,
-			ClientSecret: sc.GithubAppClientSecret,
-			Scopes:       []string{"read:user"},
-			BaseURL:      sc.ServerURL,
-		}, sc.GithubAppName, sc.GithubAppWebhookSecret)
+	if sc.GithubAppClientID != "" &&
+		sc.GithubAppClientSecret != "" &&
+		sc.GithubAppName != "" &&
+		sc.GithubAppWebhookSecret != "" &&
+		sc.GithubAppSecretPath != "" &&
+		sc.GithubAppID != "" {
+		if AppID, err := strconv.ParseInt(sc.GithubAppID, 10, 64); err == nil {
+			app.GithubAppConf = oauth.NewGithubAppClient(&oauth.Config{
+				ClientID:     sc.GithubAppClientID,
+				ClientSecret: sc.GithubAppClientSecret,
+				Scopes:       []string{"read:user"},
+				BaseURL:      sc.ServerURL,
+			}, sc.GithubAppName, sc.GithubAppWebhookSecret, sc.GithubAppSecretPath, AppID)
+		}
 	}
 
 	if sc.GoogleClientID != "" && sc.GoogleClientSecret != "" {
@@ -211,10 +219,8 @@ func New(conf *AppConfig) (*App, error) {
 		TokenSecret: conf.ServerConf.TokenGeneratorSecret,
 	}
 
-	if sc := conf.ServerConf; sc.SegmentClientKey != "" {
-		client := segment.New(sc.SegmentClientKey)
-		app.segmentClient = &client
-	}
+	newSegmentClient := analytics.InitializeAnalyticsSegmentClient(sc.SegmentClientKey, app.Logger)
+	app.analyticsClient = newSegmentClient
 
 	return app, nil
 }

+ 23 - 19
server/api/deploy_handler.go

@@ -3,6 +3,7 @@ package api
 import (
 	"encoding/json"
 	"fmt"
+	"gorm.io/gorm"
 	"net/http"
 	"net/url"
 	"strconv"
@@ -351,13 +352,14 @@ func (app *App) HandleUninstallTemplate(w http.ResponseWriter, r *http.Request)
 
 				yaml.Unmarshal(rawValues, cEnv)
 
-				gr, err := app.Repo.GitRepo.ReadGitRepo(gitAction.GitRepoID)
+				gr, err := app.Repo.GitRepo.ReadGitRepo(gitAction.GithubInstallationID)
 
 				if err != nil {
-					app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
-						Code:   ErrReleaseReadData,
-						Errors: []string{"github repo integration not found"},
-					}, w)
+					if err != gorm.ErrRecordNotFound {
+						app.handleErrorInternal(err, w)
+						return
+					}
+					gr = nil
 				}
 
 				repoSplit := strings.Split(gitAction.GitRepo, "/")
@@ -370,20 +372,22 @@ func (app *App) HandleUninstallTemplate(w http.ResponseWriter, r *http.Request)
 				}
 
 				gaRunner := &actions.GithubActions{
-					ServerURL:      app.ServerConf.ServerURL,
-					GitIntegration: gr,
-					GitRepoName:    repoSplit[1],
-					GitRepoOwner:   repoSplit[0],
-					Repo:           *app.Repo,
-					GithubConf:     app.GithubProjectConf,
-					WebhookToken:   release.WebhookToken,
-					ProjectID:      uint(projID),
-					ReleaseName:    name,
-					GitBranch:      gitAction.GitBranch,
-					DockerFilePath: gitAction.DockerfilePath,
-					FolderPath:     gitAction.FolderPath,
-					ImageRepoURL:   gitAction.ImageRepoURI,
-					BuildEnv:       cEnv.Container.Env.Normal,
+					ServerURL:              app.ServerConf.ServerURL,
+					GithubOAuthIntegration: gr,
+					GithubAppID:            app.GithubAppConf.AppID,
+					GithubInstallationID:   gitAction.GithubInstallationID,
+					GitRepoName:            repoSplit[1],
+					GitRepoOwner:           repoSplit[0],
+					Repo:                   *app.Repo,
+					GithubConf:             app.GithubProjectConf,
+					WebhookToken:           release.WebhookToken,
+					ProjectID:              uint(projID),
+					ReleaseName:            name,
+					GitBranch:              gitAction.GitBranch,
+					DockerFilePath:         gitAction.DockerfilePath,
+					FolderPath:             gitAction.FolderPath,
+					ImageRepoURL:           gitAction.ImageRepoURI,
+					BuildEnv:               cEnv.Container.Env.Normal,
 				}
 
 				err = gaRunner.Cleanup()

+ 17 - 23
server/api/git_action_handler.go

@@ -115,14 +115,6 @@ func (app *App) createGitActionFromForm(
 		return nil
 	}
 
-	// read the git repo
-	gr, err := app.Repo.GitRepo.ReadGitRepo(gitAction.GitRepoID)
-
-	if err != nil {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-		return nil
-	}
-
 	repoSplit := strings.Split(gitAction.GitRepo, "/")
 
 	if len(repoSplit) != 2 {
@@ -164,21 +156,23 @@ func (app *App) createGitActionFromForm(
 
 	// create the commit in the git repo
 	gaRunner := &actions.GithubActions{
-		ServerURL:      app.ServerConf.ServerURL,
-		GitIntegration: gr,
-		GitRepoName:    repoSplit[1],
-		GitRepoOwner:   repoSplit[0],
-		Repo:           *app.Repo,
-		GithubConf:     app.GithubProjectConf,
-		WebhookToken:   release.WebhookToken,
-		ProjectID:      uint(projID),
-		ReleaseName:    name,
-		GitBranch:      gitAction.GitBranch,
-		DockerFilePath: gitAction.DockerfilePath,
-		FolderPath:     gitAction.FolderPath,
-		ImageRepoURL:   gitAction.ImageRepoURI,
-		PorterToken:    encoded,
-		BuildEnv:       form.BuildEnv,
+		ServerURL:              app.ServerConf.ServerURL,
+		GithubOAuthIntegration: nil,
+		GithubAppID:            app.GithubAppConf.AppID,
+		GithubInstallationID:   form.GitRepoID,
+		GitRepoName:            repoSplit[1],
+		GitRepoOwner:           repoSplit[0],
+		Repo:                   *app.Repo,
+		GithubConf:             app.GithubProjectConf,
+		WebhookToken:           release.WebhookToken,
+		ProjectID:              uint(projID),
+		ReleaseName:            name,
+		GitBranch:              gitAction.GitBranch,
+		DockerFilePath:         gitAction.DockerfilePath,
+		FolderPath:             gitAction.FolderPath,
+		ImageRepoURL:           gitAction.ImageRepoURI,
+		PorterToken:            encoded,
+		BuildEnv:               form.BuildEnv,
 	}
 
 	_, err = gaRunner.Setup()

+ 89 - 66
server/api/git_repo_handler.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"github.com/porter-dev/porter/internal/models"
 	"golang.org/x/oauth2"
 	"net/http"
 	"net/url"
@@ -12,39 +13,73 @@ import (
 	"strings"
 	"sync"
 
+	"github.com/bradleyfalzon/ghinstallation"
 	"github.com/go-chi/chi"
 	"github.com/google/go-github/github"
-	"github.com/porter-dev/porter/internal/models"
 )
 
 // HandleListProjectGitRepos returns a list of git repos for a project
 func (app *App) HandleListProjectGitRepos(w http.ResponseWriter, r *http.Request) {
-	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 
-	if err != nil || projID == 0 {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+	tok, err := app.getGithubAppOauthTokenFromRequest(r)
+
+	if err != nil {
+		json.NewEncoder(w).Encode(make([]*models.GitRepoExternal, 0))
 		return
 	}
 
-	grs, err := app.Repo.GitRepo.ListGitReposByProjectID(uint(projID))
+	client := github.NewClient(app.GithubProjectConf.Client(oauth2.NoContext, tok))
+
+	accountIds := make([]int64, 0)
+
+	AuthUser, _, err := client.Users.Get(context.Background(), "")
 
 	if err != nil {
-		app.handleErrorRead(err, ErrProjectDataRead, w)
+		app.handleErrorInternal(err, w)
 		return
 	}
 
-	extGRs := make([]*models.GitRepoExternal, 0)
+	accountIds = append(accountIds, *AuthUser.ID)
+
+	opts := &github.ListOptions{
+		PerPage: 100,
+		Page:    1,
+	}
+
+	for {
+		orgs, pages, err := client.Organizations.List(context.Background(), "", opts)
 
-	for _, gr := range grs {
-		extGRs = append(extGRs, gr.Externalize())
+		if err != nil {
+			res := HandleListGithubAppAccessResp{
+				HasAccess: false,
+			}
+			json.NewEncoder(w).Encode(res)
+			return
+		}
+
+		for _, org := range orgs {
+			accountIds = append(accountIds, *org.ID)
+		}
+
+		if pages.NextPage == 0 {
+			break
+		}
 	}
 
-	w.WriteHeader(http.StatusOK)
+	installationData, err := app.Repo.GithubAppInstallation.ReadGithubAppInstallationByAccountIDs(accountIds)
 
-	if err := json.NewEncoder(w).Encode(extGRs); err != nil {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+	if err != nil {
+		app.handleErrorInternal(err, w)
 		return
 	}
+
+	installationIds := make([]int64, 0)
+
+	for _, v := range installationData {
+		installationIds = append(installationIds, v.InstallationID)
+	}
+
+	json.NewEncoder(w).Encode(installationIds)
 }
 
 // Repo represents a GitHub or Gitab repository
@@ -67,24 +102,20 @@ type AutoBuildpack struct {
 
 // HandleListRepos retrieves a list of repo names
 func (app *App) HandleListRepos(w http.ResponseWriter, r *http.Request) {
-	tok, err := app.githubTokenFromRequest(r)
+
+	client, err := app.githubAppClientFromRequest(r)
 
 	if err != nil {
 		app.handleErrorInternal(err, w)
 		return
 	}
 
-	client := github.NewClient(app.GithubProjectConf.Client(oauth2.NoContext, tok))
-
 	// figure out number of repositories
-	opt := &github.RepositoryListOptions{
-		ListOptions: github.ListOptions{
-			PerPage: 100,
-		},
-		Sort: "updated",
+	opt := &github.ListOptions{
+		PerPage: 100,
 	}
 
-	allRepos, resp, err := client.Repositories.List(context.Background(), "", opt)
+	allRepos, resp, err := client.Apps.ListRepos(context.Background(), opt)
 
 	if err != nil {
 		app.handleErrorInternal(err, w)
@@ -102,15 +133,12 @@ func (app *App) HandleListRepos(w http.ResponseWriter, r *http.Request) {
 		defer wg.Done()
 
 		for cp < numPages {
-			cur_opt := &github.RepositoryListOptions{
-				ListOptions: github.ListOptions{
-					Page:    cp,
-					PerPage: 100,
-				},
-				Sort: "updated",
+			cur_opt := &github.ListOptions{
+				Page:    cp,
+				PerPage: 100,
 			}
 
-			repos, _, err := client.Repositories.List(context.Background(), "", cur_opt)
+			repos, _, err := client.Apps.ListRepos(context.Background(), cur_opt)
 
 			if err != nil {
 				mu.Lock()
@@ -160,35 +188,10 @@ func (app *App) HandleListRepos(w http.ResponseWriter, r *http.Request) {
 	json.NewEncoder(w).Encode(res)
 }
 
-// HandleDeleteProjectGitRepo handles the deletion of a Github Repo via the git repo ID
-func (app *App) HandleDeleteProjectGitRepo(w http.ResponseWriter, r *http.Request) {
-	id, err := strconv.ParseUint(chi.URLParam(r, "git_repo_id"), 0, 64)
-
-	if err != nil || id == 0 {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-		return
-	}
-
-	repo, err := app.Repo.GitRepo.ReadGitRepo(uint(id))
-
-	if err != nil {
-		app.handleErrorRead(err, ErrProjectDataRead, w)
-		return
-	}
-
-	err = app.Repo.GitRepo.DeleteGitRepo(repo)
-
-	if err != nil {
-		app.handleErrorRead(err, ErrProjectDataRead, w)
-		return
-	}
-
-	w.WriteHeader(http.StatusOK)
-}
-
 // HandleGetBranches retrieves a list of branch names for a specified repo
 func (app *App) HandleGetBranches(w http.ResponseWriter, r *http.Request) {
-	tok, err := app.githubTokenFromRequest(r)
+
+	client, err := app.githubAppClientFromRequest(r)
 
 	if err != nil {
 		app.handleErrorInternal(err, w)
@@ -198,8 +201,6 @@ func (app *App) HandleGetBranches(w http.ResponseWriter, r *http.Request) {
 	owner := chi.URLParam(r, "owner")
 	name := chi.URLParam(r, "name")
 
-	client := github.NewClient(app.GithubProjectConf.Client(oauth2.NoContext, tok))
-
 	// List all branches for a specified repo
 	allBranches, resp, err := client.Repositories.ListBranches(context.Background(), owner, name, &github.ListOptions{
 		PerPage: 100,
@@ -274,7 +275,8 @@ func (app *App) HandleGetBranches(w http.ResponseWriter, r *http.Request) {
 
 // HandleDetectBuildpack attempts to figure which buildpack will be auto used based on directory contents
 func (app *App) HandleDetectBuildpack(w http.ResponseWriter, r *http.Request) {
-	tok, err := app.githubTokenFromRequest(r)
+
+	client, err := app.githubAppClientFromRequest(r)
 
 	if err != nil {
 		app.handleErrorInternal(err, w)
@@ -287,7 +289,6 @@ func (app *App) HandleDetectBuildpack(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	client := github.NewClient(app.GithubProjectConf.Client(oauth2.NoContext, tok))
 	owner := chi.URLParam(r, "owner")
 	name := chi.URLParam(r, "name")
 	branch := chi.URLParam(r, "branch")
@@ -333,15 +334,14 @@ func (app *App) HandleDetectBuildpack(w http.ResponseWriter, r *http.Request) {
 
 // HandleGetBranchContents retrieves the contents of a specific branch and subdirectory
 func (app *App) HandleGetBranchContents(w http.ResponseWriter, r *http.Request) {
-	tok, err := app.githubTokenFromRequest(r)
+
+	client, err := app.githubAppClientFromRequest(r)
 
 	if err != nil {
 		app.handleErrorInternal(err, w)
 		return
 	}
 
-	client := github.NewClient(app.GithubProjectConf.Client(oauth2.NoContext, tok))
-
 	queryParams, err := url.ParseQuery(r.URL.RawQuery)
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
@@ -379,14 +379,14 @@ var procfileRegex = regexp.MustCompile("^([A-Za-z0-9_]+):\\s*(.+)$")
 
 // HandleGetProcfileContents retrieves the contents of a procfile in a github repo
 func (app *App) HandleGetProcfileContents(w http.ResponseWriter, r *http.Request) {
-	tok, err := app.githubTokenFromRequest(r)
+
+	client, err := app.githubAppClientFromRequest(r)
 
 	if err != nil {
 		app.handleErrorInternal(err, w)
 		return
 	}
 
-	client := github.NewClient(app.GithubProjectConf.Client(oauth2.NoContext, tok))
 	owner := chi.URLParam(r, "owner")
 	name := chi.URLParam(r, "name")
 	branch := chi.URLParam(r, "branch")
@@ -440,14 +440,14 @@ type HandleGetRepoZIPDownloadURLResp struct {
 // HandleGetRepoZIPDownloadURL gets the URL for downloading a zip file from a Github
 // repository
 func (app *App) HandleGetRepoZIPDownloadURL(w http.ResponseWriter, r *http.Request) {
-	tok, err := app.githubTokenFromRequest(r)
+
+	client, err := app.githubAppClientFromRequest(r)
 
 	if err != nil {
 		app.handleErrorInternal(err, w)
 		return
 	}
 
-	client := github.NewClient(app.GithubProjectConf.Client(oauth2.NoContext, tok))
 	owner := chi.URLParam(r, "owner")
 	name := chi.URLParam(r, "name")
 	branch := chi.URLParam(r, "branch")
@@ -487,6 +487,29 @@ func (app *App) HandleGetRepoZIPDownloadURL(w http.ResponseWriter, r *http.Reque
 	json.NewEncoder(w).Encode(apiResp)
 }
 
+// githubAppClientFromRequest gets the github app installation id from the request and authenticates
+// using it and a private key file
+func (app *App) githubAppClientFromRequest(r *http.Request) (*github.Client, error) {
+
+	installationID, err := strconv.ParseUint(chi.URLParam(r, "installation_id"), 0, 64)
+
+	if err != nil || installationID == 0 {
+		return nil, fmt.Errorf("could not read installation id")
+	}
+
+	itr, err := ghinstallation.NewKeyFromFile(
+		http.DefaultTransport,
+		app.GithubAppConf.AppID,
+		int64(installationID),
+		"/porter/docker/github_app_private_key.pem")
+
+	if err != nil {
+		return nil, err
+	}
+
+	return github.NewClient(&http.Client{Transport: itr}), nil
+}
+
 // finds the github token given the git repo id and the project id
 func (app *App) githubTokenFromRequest(
 	r *http.Request,

+ 20 - 6
server/api/integration_handler.go

@@ -7,7 +7,9 @@ import (
 	"encoding/hex"
 	"encoding/json"
 	"fmt"
+	"github.com/go-chi/chi"
 	"github.com/google/go-github/github"
+	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/oauth"
 	"golang.org/x/oauth2"
 	"gorm.io/gorm"
@@ -18,9 +20,6 @@ import (
 	"strconv"
 	"strings"
 
-	"github.com/go-chi/chi"
-	"github.com/porter-dev/porter/internal/forms"
-
 	"github.com/porter-dev/porter/internal/models/integrations"
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 )
@@ -479,6 +478,11 @@ func (app *App) HandleGithubAppAuthorize(w http.ResponseWriter, r *http.Request)
 	http.Redirect(w, r, url, 302)
 }
 
+// HandleGithubAppOauthInit redirects the user to the Porter github app authorization page
+func (app *App) HandleGithubAppOauthInit(w http.ResponseWriter, r *http.Request) {
+	http.Redirect(w, r, app.GithubAppConf.AuthCodeURL("", oauth2.AccessTypeOffline), 302)
+}
+
 // HandleGithubAppInstall redirects the user to the Porter github app installation page
 func (app *App) HandleGithubAppInstall(w http.ResponseWriter, r *http.Request) {
 	http.Redirect(w, r, fmt.Sprintf("https://github.com/apps/%s/installations/new", app.GithubAppConf.AppName), 302)
@@ -494,7 +498,7 @@ type HandleListGithubAppAccessResp struct {
 // HandleListGithubAppAccess provides basic info on if the current user is authenticated through the GitHub app
 // and what accounts/organizations their authentication has access to
 func (app *App) HandleListGithubAppAccess(w http.ResponseWriter, r *http.Request) {
-	tok, err := app.getGithubUserTokenFromRequest(r)
+	tok, err := app.getGithubAppOauthTokenFromRequest(r)
 
 	if err != nil {
 		res := HandleListGithubAppAccessResp{
@@ -561,8 +565,9 @@ func (app *App) HandleListGithubAppAccess(w http.ResponseWriter, r *http.Request
 	json.NewEncoder(w).Encode(res)
 }
 
-// getGithubUserTokenFromRequest
-func (app *App) getGithubUserTokenFromRequest(r *http.Request) (*oauth2.Token, error) {
+// getGithubAppOauthTokenFromRequest gets the oauth token from the request based on the currently
+// logged in user. Note that this authenticates as the user, rather than the installation.
+func (app *App) getGithubAppOauthTokenFromRequest(r *http.Request) (*oauth2.Token, error) {
 	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil {
@@ -581,9 +586,18 @@ func (app *App) getGithubUserTokenFromRequest(r *http.Request) (*oauth2.Token, e
 		return nil, err
 	}
 
+	_, _, err = oauth.GetAccessToken(oauthInt.SharedOAuthModel,
+		&app.GithubAppConf.Config,
+		oauth.MakeUpdateGithubAppOauthIntegrationFunction(oauthInt, *app.Repo))
+
+	if err != nil {
+		return nil, err
+	}
+
 	return &oauth2.Token{
 		AccessToken:  string(oauthInt.AccessToken),
 		RefreshToken: string(oauthInt.RefreshToken),
+		Expiry:       oauthInt.Expiry,
 		TokenType:    "Bearer",
 	}, nil
 }

+ 13 - 42
server/api/oauth_github_handler.go

@@ -8,6 +8,7 @@ import (
 	"strconv"
 	"strings"
 
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/models"
 	"gorm.io/gorm"
 
@@ -17,7 +18,6 @@ import (
 	"golang.org/x/oauth2"
 
 	"github.com/porter-dev/porter/internal/models/integrations"
-	segment "gopkg.in/segmentio/analytics-go.v3"
 )
 
 // HandleGithubOAuthStartUser starts the oauth2 flow for a user login request.
@@ -131,22 +131,8 @@ func (app *App) HandleGithubOAuthCallback(w http.ResponseWriter, r *http.Request
 		}
 
 		// send to segment
-		if app.segmentClient != nil {
-			client := *app.segmentClient
-			client.Enqueue(segment.Identify{
-				UserId: fmt.Sprintf("%v", user.ID),
-				Traits: segment.NewTraits().
-					SetEmail(user.Email).
-					Set("github", "true"),
-			})
-
-			client.Enqueue(segment.Track{
-				UserId: fmt.Sprintf("%v", user.ID),
-				Event:  "New User",
-				Properties: segment.NewProperties().
-					Set("email", user.Email),
-			})
-		}
+		app.analyticsClient.Identify(analytics.CreateSegmentIdentifyNewUser(user, true))
+		app.analyticsClient.Track(analytics.CreateSegmentNewUserTrack(user))
 
 		// log the user in
 		app.Logger.Info().Msgf("New user created: %d", user.ID)
@@ -245,6 +231,11 @@ func (app *App) upsertUserFromToken(tok *oauth2.Token) (*models.User, error) {
 			if err != nil {
 				return nil, err
 			}
+
+			if !verified {
+				// non-fatal email verification flow
+				app.startEmailVerificationFlow(user)
+			}
 		} else if err == nil {
 			return nil, fmt.Errorf("email already registered")
 		} else if err != nil {
@@ -307,31 +298,6 @@ func (app *App) HandleGithubAppOAuthCallback(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	if _, ok := session.Values["state"]; !ok {
-		app.sendExternalError(
-			err,
-			http.StatusForbidden,
-			HTTPError{
-				Code: http.StatusForbidden,
-				Errors: []string{
-					"Could not read cookie: are cookies enabled?",
-				},
-			},
-			w,
-		)
-
-		return
-	}
-
-	if r.URL.Query().Get("state") != session.Values["state"] {
-		if session.Values["query_params"] != "" {
-			http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
-		} else {
-			http.Redirect(w, r, "/dashboard", 302)
-		}
-		return
-	}
-
 	token, err := app.GithubAppConf.Exchange(oauth2.NoContext, r.URL.Query().Get("code"))
 
 	if err != nil || !token.Valid() {
@@ -343,6 +309,10 @@ func (app *App) HandleGithubAppOAuthCallback(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
+	fmt.Println("exchange happaned")
+	fmt.Println(token.AccessToken)
+	fmt.Println(token.RefreshToken)
+
 	userID, err := app.getUserIDFromRequest(r)
 
 	if err != nil {
@@ -361,6 +331,7 @@ func (app *App) HandleGithubAppOAuthCallback(w http.ResponseWriter, r *http.Requ
 		SharedOAuthModel: integrations.SharedOAuthModel{
 			AccessToken:  []byte(token.AccessToken),
 			RefreshToken: []byte(token.RefreshToken),
+			Expiry:       token.Expiry,
 		},
 		UserID: user.ID,
 	}

+ 4 - 18
server/api/oauth_google_handler.go

@@ -8,13 +8,12 @@ import (
 	"net/url"
 	"strings"
 
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/models"
 	"gorm.io/gorm"
 
 	"github.com/porter-dev/porter/internal/oauth"
 	"golang.org/x/oauth2"
-
-	segment "gopkg.in/segmentio/analytics-go.v3"
 )
 
 // HandleGoogleStartUser starts the oauth2 flow for a user login request.
@@ -96,22 +95,9 @@ func (app *App) HandleGoogleOAuthCallback(w http.ResponseWriter, r *http.Request
 	}
 
 	// send to segment
-	if app.segmentClient != nil {
-		client := *app.segmentClient
-		client.Enqueue(segment.Identify{
-			UserId: fmt.Sprintf("%v", user.ID),
-			Traits: segment.NewTraits().
-				SetEmail(user.Email).
-				Set("github", "true"),
-		})
-
-		client.Enqueue(segment.Track{
-			UserId: fmt.Sprintf("%v", user.ID),
-			Event:  "New User",
-			Properties: segment.NewProperties().
-				Set("email", user.Email),
-		})
-	}
+	app.analyticsClient.Identify(analytics.CreateSegmentIdentifyNewUser(user, true))
+
+	app.analyticsClient.Track(analytics.CreateSegmentNewUserTrack(user))
 
 	// log the user in
 	app.Logger.Info().Msgf("New user created: %d", user.ID)

+ 1 - 1
server/api/registry_handler.go

@@ -362,7 +362,7 @@ func (app *App) HandleGetProjectRegistryDOCRToken(w http.ResponseWriter, r *http
 				return
 			}
 
-			tok, expiry, err := oauth.GetAccessToken(oauthInt, app.DOConf, *app.Repo)
+			tok, expiry, err := oauth.GetAccessToken(oauthInt.SharedOAuthModel, app.DOConf, oauth.MakeUpdateOAuthIntegrationTokenFunction(oauthInt, *app.Repo))
 
 			if err != nil {
 				app.handleErrorDataRead(err, w)

+ 47 - 48
server/api/release_handler.go

@@ -3,12 +3,14 @@ package api
 import (
 	"encoding/json"
 	"fmt"
+	"gorm.io/gorm"
 	"net/http"
 	"net/url"
 	"strconv"
 	"strings"
 	"sync"
 
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/templater/parser"
@@ -24,7 +26,6 @@ import (
 	"github.com/porter-dev/porter/internal/integrations/ci/actions"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/repository"
-	segment "gopkg.in/segmentio/analytics-go.v3"
 	"gopkg.in/yaml.v2"
 )
 
@@ -936,32 +937,35 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 
 				yaml.Unmarshal([]byte(form.Values), cEnv)
 
-				gr, err := app.Repo.GitRepo.ReadGitRepo(gitAction.GitRepoID)
+				gr, err := app.Repo.GitRepo.ReadGitRepo(gitAction.GithubInstallationID)
 
 				if err != nil {
-					app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
-						Code:   ErrReleaseReadData,
-						Errors: []string{"github repo integration not found"},
-					}, w)
+					if err != gorm.ErrRecordNotFound {
+						app.handleErrorInternal(err, w)
+						return
+					}
+					gr = nil
 				}
 
 				repoSplit := strings.Split(gitAction.GitRepo, "/")
 
 				gaRunner := &actions.GithubActions{
-					ServerURL:      app.ServerConf.ServerURL,
-					GitIntegration: gr,
-					GitRepoName:    repoSplit[1],
-					GitRepoOwner:   repoSplit[0],
-					Repo:           *app.Repo,
-					GithubConf:     app.GithubProjectConf,
-					WebhookToken:   release.WebhookToken,
-					ProjectID:      uint(projID),
-					ReleaseName:    name,
-					GitBranch:      gitAction.GitBranch,
-					DockerFilePath: gitAction.DockerfilePath,
-					FolderPath:     gitAction.FolderPath,
-					ImageRepoURL:   gitAction.ImageRepoURI,
-					BuildEnv:       cEnv.Container.Env.Normal,
+					ServerURL:              app.ServerConf.ServerURL,
+					GithubOAuthIntegration: gr,
+					GithubInstallationID:   gitAction.GithubInstallationID,
+					GithubAppID:            app.GithubAppConf.AppID,
+					GitRepoName:            repoSplit[1],
+					GitRepoOwner:           repoSplit[0],
+					Repo:                   *app.Repo,
+					GithubConf:             app.GithubProjectConf,
+					WebhookToken:           release.WebhookToken,
+					ProjectID:              uint(projID),
+					ReleaseName:            name,
+					GitBranch:              gitAction.GitBranch,
+					DockerFilePath:         gitAction.DockerfilePath,
+					FolderPath:             gitAction.FolderPath,
+					ImageRepoURL:           gitAction.ImageRepoURI,
+					BuildEnv:               cEnv.Container.Env.Normal,
 				}
 
 				err = gaRunner.CreateEnvSecret()
@@ -1087,15 +1091,7 @@ func (app *App) HandleReleaseDeployWebhook(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	if app.segmentClient != nil {
-		client := *app.segmentClient
-		client.Enqueue(segment.Track{
-			UserId: "anonymous",
-			Event:  "Triggered Re-deploy via Webhook",
-			Properties: segment.NewProperties().
-				Set("repository", repository),
-		})
-	}
+	app.analyticsClient.Track(analytics.CreateSegmentRedeployViaWebhookTrack("anonymous", repository.(string)))
 
 	w.WriteHeader(http.StatusOK)
 }
@@ -1321,13 +1317,14 @@ func (app *App) HandleRollbackRelease(w http.ResponseWriter, r *http.Request) {
 
 				yaml.Unmarshal(rawValues, cEnv)
 
-				gr, err := app.Repo.GitRepo.ReadGitRepo(gitAction.GitRepoID)
+				gr, err := app.Repo.GitRepo.ReadGitRepo(gitAction.GithubInstallationID)
 
 				if err != nil {
-					app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
-						Code:   ErrReleaseReadData,
-						Errors: []string{"github repo integration not found"},
-					}, w)
+					if err != gorm.ErrRecordNotFound {
+						app.handleErrorInternal(err, w)
+						return
+					}
+					gr = nil
 				}
 
 				repoSplit := strings.Split(gitAction.GitRepo, "/")
@@ -1340,20 +1337,22 @@ func (app *App) HandleRollbackRelease(w http.ResponseWriter, r *http.Request) {
 				}
 
 				gaRunner := &actions.GithubActions{
-					ServerURL:      app.ServerConf.ServerURL,
-					GitIntegration: gr,
-					GitRepoName:    repoSplit[1],
-					GitRepoOwner:   repoSplit[0],
-					Repo:           *app.Repo,
-					GithubConf:     app.GithubProjectConf,
-					WebhookToken:   release.WebhookToken,
-					ProjectID:      uint(projID),
-					ReleaseName:    name,
-					GitBranch:      gitAction.GitBranch,
-					DockerFilePath: gitAction.DockerfilePath,
-					FolderPath:     gitAction.FolderPath,
-					ImageRepoURL:   gitAction.ImageRepoURI,
-					BuildEnv:       cEnv.Container.Env.Normal,
+					ServerURL:              app.ServerConf.ServerURL,
+					GithubOAuthIntegration: gr,
+					GithubInstallationID:   gitAction.GithubInstallationID,
+					GithubAppID:            app.GithubAppConf.AppID,
+					GitRepoName:            repoSplit[1],
+					GitRepoOwner:           repoSplit[0],
+					Repo:                   *app.Repo,
+					GithubConf:             app.GithubProjectConf,
+					WebhookToken:           release.WebhookToken,
+					ProjectID:              uint(projID),
+					ReleaseName:            name,
+					GitBranch:              gitAction.GitBranch,
+					DockerFilePath:         gitAction.DockerfilePath,
+					FolderPath:             gitAction.FolderPath,
+					ImageRepoURL:           gitAction.ImageRepoURI,
+					BuildEnv:               cEnv.Container.Env.Normal,
 				}
 
 				err = gaRunner.CreateEnvSecret()

+ 44 - 59
server/api/user_handler.go

@@ -15,12 +15,12 @@ import (
 	"gorm.io/gorm"
 
 	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/analytics"
 	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/integrations/email"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
-	segment "gopkg.in/segmentio/analytics-go.v3"
 )
 
 // Enumeration of user API error codes, represented as int64
@@ -51,25 +51,14 @@ func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
 
 	if err == nil {
 		// send to segment
-		if app.segmentClient != nil {
-			client := *app.segmentClient
-
-			client.Enqueue(segment.Identify{
-				UserId: fmt.Sprintf("%v", user.ID),
-				Traits: segment.NewTraits().
-					SetEmail(user.Email).
-					Set("github", "false"),
-			})
-
-			client.Enqueue(segment.Track{
-				UserId: fmt.Sprintf("%v", user.ID),
-				Event:  "New User",
-				Properties: segment.NewProperties().
-					Set("email", user.Email),
-			})
-		}
+		app.analyticsClient.Identify(analytics.CreateSegmentIdentifyNewUser(user, false))
+		app.analyticsClient.Track(analytics.CreateSegmentNewUserTrack(user))
 
 		app.Logger.Info().Msgf("New user created: %d", user.ID)
+
+		// non-fatal email verification flow
+		app.startEmailVerificationFlow(user)
+
 		var redirect string
 
 		if valR := session.Values["redirect"]; valR != nil {
@@ -397,46 +386,7 @@ func (app *App) InitiateEmailVerifyUser(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	// error already handled by helper
-	if err != nil {
-		return
-	}
-
-	form := &forms.InitiateResetUserPasswordForm{
-		Email: user.Email,
-	}
-
-	// convert the form to a pw reset token model
-	pwReset, rawToken, err := form.ToPWResetToken()
-
-	if err != nil {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-		return
-	}
-
-	// handle write to the database
-	pwReset, err = app.Repo.PWResetToken.CreatePWResetToken(pwReset)
-
-	if err != nil {
-		app.handleErrorDataWrite(err, w)
-		return
-	}
-
-	queryVals := url.Values{
-		"token":    []string{rawToken},
-		"token_id": []string{fmt.Sprintf("%d", pwReset.ID)},
-	}
-
-	sgClient := email.SendgridClient{
-		APIKey:                app.ServerConf.SendgridAPIKey,
-		VerifyEmailTemplateID: app.ServerConf.SendgridVerifyEmailTemplateID,
-		SenderEmail:           app.ServerConf.SendgridSenderEmail,
-	}
-
-	err = sgClient.SendEmailVerification(
-		fmt.Sprintf("%s/api/email/verify/finalize?%s", app.ServerConf.ServerURL, queryVals.Encode()),
-		form.Email,
-	)
+	err = app.startEmailVerificationFlow(user)
 
 	if err != nil {
 		app.handleErrorInternal(err, w)
@@ -444,7 +394,6 @@ func (app *App) InitiateEmailVerifyUser(w http.ResponseWriter, r *http.Request)
 	}
 
 	w.WriteHeader(http.StatusOK)
-	return
 }
 
 // FinalizEmailVerifyUser completes the email verification flow for a user.
@@ -903,3 +852,39 @@ func (app *App) getUserIDFromRequest(r *http.Request) (uint, error) {
 
 	return userID, nil
 }
+
+func (app *App) startEmailVerificationFlow(user *models.User) error {
+	form := &forms.InitiateResetUserPasswordForm{
+		Email: user.Email,
+	}
+
+	// convert the form to a pw reset token model
+	pwReset, rawToken, err := form.ToPWResetToken()
+
+	if err != nil {
+		return err
+	}
+
+	// handle write to the database
+	pwReset, err = app.Repo.PWResetToken.CreatePWResetToken(pwReset)
+
+	if err != nil {
+		return err
+	}
+
+	queryVals := url.Values{
+		"token":    []string{rawToken},
+		"token_id": []string{fmt.Sprintf("%d", pwReset.ID)},
+	}
+
+	sgClient := email.SendgridClient{
+		APIKey:                app.ServerConf.SendgridAPIKey,
+		VerifyEmailTemplateID: app.ServerConf.SendgridVerifyEmailTemplateID,
+		SenderEmail:           app.ServerConf.SendgridSenderEmail,
+	}
+
+	return sgClient.SendEmailVerification(
+		fmt.Sprintf("%s/api/email/verify/finalize?%s", app.ServerConf.ServerURL, queryVals.Encode()),
+		form.Email,
+	)
+}

+ 98 - 27
server/middleware/auth.go

@@ -2,8 +2,12 @@ package middleware
 
 import (
 	"bytes"
+	"context"
 	"encoding/json"
 	"errors"
+	"github.com/google/go-github/github"
+	"github.com/porter-dev/porter/internal/oauth"
+	"golang.org/x/oauth2"
 	"io/ioutil"
 	"net/http"
 	"net/url"
@@ -19,10 +23,11 @@ import (
 
 // Auth implements the authorization functions
 type Auth struct {
-	store      sessions.Store
-	cookieName string
-	tokenConf  *token.TokenGeneratorConf
-	repo       *repository.Repository
+	store         sessions.Store
+	cookieName    string
+	tokenConf     *token.TokenGeneratorConf
+	repo          *repository.Repository
+	GithubAppConf *oauth2.Config
 }
 
 // NewAuth returns a new Auth instance
@@ -31,8 +36,9 @@ func NewAuth(
 	cookieName string,
 	tokenConf *token.TokenGeneratorConf,
 	repo *repository.Repository,
+	GithubAppConf *oauth2.Config,
 ) *Auth {
-	return &Auth{store, cookieName, tokenConf, repo}
+	return &Auth{store, cookieName, tokenConf, repo, GithubAppConf}
 }
 
 // BasicAuthenticate just checks that a user is logged in
@@ -394,53 +400,117 @@ func (auth *Auth) DoesUserHaveRegistryAccess(
 	})
 }
 
-// DoesUserHaveGitRepoAccess looks for a project_id parameter and a
-// git_repo_id parameter, and verifies that the git repo belongs
-// to the project
-func (auth *Auth) DoesUserHaveGitRepoAccess(
+// DoesUserHaveGitInstallationAccess checks that a user has access to an installation id
+// by ensuring the installation id exists for one org or account they have access to
+// note that this makes a github API request, but the endpoint is fast so this doesn't add
+// much overhead
+func (auth *Auth) DoesUserHaveGitInstallationAccess(
 	next http.Handler,
-	projLoc IDLocation,
 	gitRepoLoc IDLocation,
 ) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		grID, err := findGitRepoIDInRequest(r, gitRepoLoc)
+		grID, err := findGitInstallationIDInRequest(r, gitRepoLoc)
 
 		if err != nil {
 			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
 			return
 		}
 
-		projID, err := findProjIDInRequest(r, projLoc)
+		tok := auth.getTokenFromRequest(r)
+
+		var userID uint
+
+		if tok != nil {
+			userID = tok.IBy
+		} else {
+			session, err := auth.store.Get(r, auth.cookieName)
+
+			if err != nil {
+				http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+				return
+			}
+
+			sessionUserID, ok := session.Values["user_id"]
+			userID = sessionUserID.(uint)
+
+			if !ok {
+				http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+				return
+			}
+		}
+
+		user, err := auth.repo.User.ReadUser(userID)
 
 		if err != nil {
 			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
 			return
 		}
 
-		// get the service accounts belonging to the project
-		grs, err := auth.repo.GitRepo.ListGitReposByProjectID(uint(projID))
+		oauthInt, err := auth.repo.GithubAppOAuthIntegration.ReadGithubAppOauthIntegration(user.GithubAppIntegrationID)
 
 		if err != nil {
-			http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
 			return
 		}
 
-		doesExist := false
+		_, _, err = oauth.GetAccessToken(oauthInt.SharedOAuthModel,
+			auth.GithubAppConf,
+			oauth.MakeUpdateGithubAppOauthIntegrationFunction(oauthInt, *auth.repo))
 
-		for _, gr := range grs {
-			if gr.ID == uint(grID) {
-				doesExist = true
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
+		}
+
+		client := github.NewClient(auth.GithubAppConf.Client(oauth2.NoContext, &oauth2.Token{
+			AccessToken:  string(oauthInt.AccessToken),
+			RefreshToken: string(oauthInt.RefreshToken),
+			TokenType:    "Bearer",
+		}))
+
+		accountIDs := make([]int64, 0)
+
+		AuthUser, _, err := client.Users.Get(context.Background(), "")
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
+		}
+
+		accountIDs = append(accountIDs, *AuthUser.ID)
+
+		opts := &github.ListOptions{
+			PerPage: 100,
+			Page:    1,
+		}
+
+		for {
+			orgs, pages, err := client.Organizations.List(context.Background(), "", opts)
+
+			if err != nil {
+				http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+				return
+			}
+
+			for _, org := range orgs {
+				accountIDs = append(accountIDs, *org.ID)
+			}
+
+			if pages.NextPage == 0 {
 				break
 			}
 		}
 
-		if doesExist {
-			next.ServeHTTP(w, r)
-			return
+		installations, err := auth.repo.GithubAppInstallation.ReadGithubAppInstallationByAccountIDs(accountIDs)
+
+		for _, installation := range installations {
+			if uint64(installation.InstallationID) == grID {
+				next.ServeHTTP(w, r)
+				return
+			}
 		}
 
 		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
-		return
 	})
 }
 
@@ -938,12 +1008,13 @@ func findRegistryIDInRequest(r *http.Request, registryLoc IDLocation) (uint64, e
 	return regID, nil
 }
 
-func findGitRepoIDInRequest(r *http.Request, gitRepoLoc IDLocation) (uint64, error) {
+// findGitInstallationIDInRequest extracts and installation ID from a request
+func findGitInstallationIDInRequest(r *http.Request, gitRepoLoc IDLocation) (uint64, error) {
 	var grID uint64
 	var err error
 
 	if gitRepoLoc == URLParam {
-		grID, err = strconv.ParseUint(chi.URLParam(r, "git_repo_id"), 0, 64)
+		grID, err = strconv.ParseUint(chi.URLParam(r, "installation_id"), 0, 64)
 
 		if err != nil {
 			return 0, err
@@ -973,10 +1044,10 @@ func findGitRepoIDInRequest(r *http.Request, gitRepoLoc IDLocation) (uint64, err
 			return 0, err
 		}
 
-		if regStrArr, ok := vals["git_repo_id"]; ok && len(regStrArr) == 1 {
+		if regStrArr, ok := vals["installation_id"]; ok && len(regStrArr) == 1 {
 			grID, err = strconv.ParseUint(regStrArr[0], 10, 64)
 		} else {
-			return 0, errors.New("git repo id not found")
+			return 0, errors.New("git app installation id not found")
 		}
 	}
 

+ 26 - 33
server/router/router.go

@@ -13,6 +13,7 @@ import (
 	"github.com/porter-dev/porter/server/api"
 	mw "github.com/porter-dev/porter/server/middleware"
 	"github.com/porter-dev/porter/server/middleware/requestlog"
+	"golang.org/x/oauth2"
 )
 
 // New creates a new Chi router instance and registers all routes supported by the
@@ -21,9 +22,15 @@ func New(a *api.App) *chi.Mux {
 	l := a.Logger
 	r := chi.NewRouter()
 
+	var ghAppConf *oauth2.Config
+
+	if a.GithubAppConf != nil {
+		ghAppConf = &a.GithubAppConf.Config
+	}
+
 	auth := mw.NewAuth(a.Store, a.ServerConf.CookieName, &token.TokenGeneratorConf{
 		TokenSecret: a.ServerConf.TokenGeneratorSecret,
-	}, a.Repo)
+	}, a.Repo, ghAppConf)
 
 	r.Route("/api", func(r chi.Router) {
 		r.Use(mw.ContentTypeJSON)
@@ -188,6 +195,12 @@ func New(a *api.App) *chi.Mux {
 				requestlog.NewHandler(a.HandleGithubAppAuthorize, l),
 			)
 
+			r.Method(
+				"GET",
+				"/integrations/github-app/oauth",
+				requestlog.NewHandler(a.HandleGithubAppOauthInit, l),
+			)
+
 			r.Method(
 				"GET",
 				"/integrations/github-app/install",
@@ -1129,28 +1142,13 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
-			r.Method(
-				"DELETE",
-				"/projects/{project_id}/gitrepos/{git_repo_id}",
-				auth.DoesUserHaveProjectAccess(
-					auth.DoesUserHaveGitRepoAccess(
-						requestlog.NewHandler(a.HandleDeleteProjectGitRepo, l),
-						mw.URLParam,
-						mw.URLParam,
-					),
-					mw.URLParam,
-					mw.WriteAccess,
-				),
-			)
-
 			r.Method(
 				"GET",
-				"/projects/{project_id}/gitrepos/{git_repo_id}/repos",
+				"/projects/{project_id}/gitrepos/{installation_id}/repos",
 				auth.DoesUserHaveProjectAccess(
-					auth.DoesUserHaveGitRepoAccess(
+					auth.DoesUserHaveGitInstallationAccess(
 						requestlog.NewHandler(a.HandleListRepos, l),
 						mw.URLParam,
-						mw.URLParam,
 					),
 					mw.URLParam,
 					mw.ReadAccess,
@@ -1159,12 +1157,11 @@ func New(a *api.App) *chi.Mux {
 
 			r.Method(
 				"GET",
-				"/projects/{project_id}/gitrepos/{git_repo_id}/repos/{kind}/{owner}/{name}/branches",
+				"/projects/{project_id}/gitrepos/{installation_id}/repos/{kind}/{owner}/{name}/branches",
 				auth.DoesUserHaveProjectAccess(
-					auth.DoesUserHaveGitRepoAccess(
+					auth.DoesUserHaveGitInstallationAccess(
 						requestlog.NewHandler(a.HandleGetBranches, l),
 						mw.URLParam,
-						mw.URLParam,
 					),
 					mw.URLParam,
 					mw.ReadAccess,
@@ -1173,12 +1170,11 @@ func New(a *api.App) *chi.Mux {
 
 			r.Method(
 				"GET",
-				"/projects/{project_id}/gitrepos/{git_repo_id}/repos/{kind}/{owner}/{name}/{branch}/buildpack/detect",
+				"/projects/{project_id}/gitrepos/{installation_id}/repos/{kind}/{owner}/{name}/{branch}/buildpack/detect",
 				auth.DoesUserHaveProjectAccess(
-					auth.DoesUserHaveGitRepoAccess(
+					auth.DoesUserHaveGitInstallationAccess(
 						requestlog.NewHandler(a.HandleDetectBuildpack, l),
 						mw.URLParam,
-						mw.URLParam,
 					),
 					mw.URLParam,
 					mw.ReadAccess,
@@ -1187,12 +1183,11 @@ func New(a *api.App) *chi.Mux {
 
 			r.Method(
 				"GET",
-				"/projects/{project_id}/gitrepos/{git_repo_id}/repos/{kind}/{owner}/{name}/{branch}/contents",
+				"/projects/{project_id}/gitrepos/{installation_id}/repos/{kind}/{owner}/{name}/{branch}/contents",
 				auth.DoesUserHaveProjectAccess(
-					auth.DoesUserHaveGitRepoAccess(
+					auth.DoesUserHaveGitInstallationAccess(
 						requestlog.NewHandler(a.HandleGetBranchContents, l),
 						mw.URLParam,
-						mw.URLParam,
 					),
 					mw.URLParam,
 					mw.ReadAccess,
@@ -1201,12 +1196,11 @@ func New(a *api.App) *chi.Mux {
 
 			r.Method(
 				"GET",
-				"/projects/{project_id}/gitrepos/{git_repo_id}/repos/{kind}/{owner}/{name}/{branch}/procfile",
+				"/projects/{project_id}/gitrepos/{installation_id}/repos/{kind}/{owner}/{name}/{branch}/procfile",
 				auth.DoesUserHaveProjectAccess(
-					auth.DoesUserHaveGitRepoAccess(
+					auth.DoesUserHaveGitInstallationAccess(
 						requestlog.NewHandler(a.HandleGetProcfileContents, l),
 						mw.URLParam,
-						mw.URLParam,
 					),
 					mw.URLParam,
 					mw.ReadAccess,
@@ -1215,12 +1209,11 @@ func New(a *api.App) *chi.Mux {
 
 			r.Method(
 				"GET",
-				"/projects/{project_id}/gitrepos/{git_repo_id}/repos/{kind}/{owner}/{name}/{branch}/tarball_url",
+				"/projects/{project_id}/gitrepos/{installation_id}/repos/{kind}/{owner}/{name}/{branch}/tarball_url",
 				auth.DoesUserHaveProjectAccess(
-					auth.DoesUserHaveGitRepoAccess(
+					auth.DoesUserHaveGitInstallationAccess(
 						requestlog.NewHandler(a.HandleGetRepoZIPDownloadURL, l),
 						mw.URLParam,
-						mw.URLParam,
 					),
 					mw.URLParam,
 					mw.ReadAccess,