Procházet zdrojové kódy

Merge branch 'master' of https://github.com/porter-dev/porter into 0.5.0-forms-refactor

jusrhee před 4 roky
rodič
revize
db00ad42bc

+ 2 - 0
.gitignore

@@ -11,6 +11,8 @@ internal/local_templates
 gon*.hcl
 *prod.Dockerfile
 staging.sh
+*.crt
+*.key
 
 # Local .terraform directories
 **/.terraform/*

+ 7 - 1
cli/cmd/login/server.go

@@ -55,7 +55,13 @@ func Login(
 	}()
 
 	// open browser for host login
-	redirectHost := fmt.Sprintf("http://localhost:%d", port)
+	var redirectHost string
+	if utils.CheckIfWsl() {
+		redirectHost = fmt.Sprintf("http://%s:%d", utils.GetWslHostName(), port)
+	} else {
+		redirectHost = fmt.Sprintf("http://localhost:%d", port)
+	}
+
 	loginURL := fmt.Sprintf("%s/api/cli/login?redirect=%s", host, url.QueryEscape(redirectHost))
 
 	err = utils.OpenBrowser(loginURL)

+ 10 - 1
cli/cmd/utils/browser.go

@@ -1,6 +1,7 @@
 package utils
 
 import (
+	"fmt"
 	"os/exec"
 	"runtime"
 )
@@ -10,6 +11,8 @@ func OpenBrowser(url string) error {
 	var cmd string
 	var args []string
 
+	fmt.Printf("Attempting to open your browser. If this does not work, please navigate to: %s", url)
+
 	switch runtime.GOOS {
 	case "windows":
 		cmd = "cmd"
@@ -17,8 +20,14 @@ func OpenBrowser(url string) error {
 	case "darwin":
 		cmd = "open"
 	default: // "linux", "freebsd", "openbsd", "netbsd"
-		cmd = "xdg-open"
+		if CheckIfWsl() {
+			cmd = "cmd.exe"
+			args = []string{"/c", "start"}
+		} else {
+			cmd = "xdg-open"
+		}
 	}
+
 	args = append(args, url)
 	return exec.Command(cmd, args...).Start()
 }

+ 29 - 0
cli/cmd/utils/wsl.go

@@ -0,0 +1,29 @@
+package utils
+
+import (
+	"os/exec"
+	"regexp"
+	"strings"
+)
+
+// Checks based on uname if the linux environment is under wsl or not
+func CheckIfWsl() bool {
+	out, err := exec.Command("uname", "-a").Output()
+	if err != nil {
+		return false
+	}
+	// On some cases, uname on wsl outputs microsoft capitalized
+	matched, _ := regexp.Match(`microsoft|Microsoft`, out)
+	return matched
+}
+
+// Gets the subsystem host ip
+// If the CLI is running under WSL the localhost url will not work so
+// this function should return the real ip that we should redirect to
+func GetWslHostName() string {
+	out, err := exec.Command("wsl.exe", "hostname", "-I").Output()
+	if err != nil {
+		return "localhost"
+	}
+	return strings.TrimSpace(string(out))
+}

+ 1 - 32
cmd/app/main.go

@@ -7,7 +7,6 @@ import (
 	"net/http"
 	"os"
 
-	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 
 	"github.com/porter-dev/porter/server/api"
@@ -18,7 +17,6 @@ import (
 	"github.com/porter-dev/porter/server/router"
 
 	prov "github.com/porter-dev/porter/internal/kubernetes/provisioner"
-	ints "github.com/porter-dev/porter/internal/models/integrations"
 )
 
 // Version will be linked by an ldflag during build
@@ -45,36 +43,7 @@ func main() {
 		return
 	}
 
-	err = db.AutoMigrate(
-		&models.Project{},
-		&models.Role{},
-		&models.User{},
-		&models.Session{},
-		&models.GitRepo{},
-		&models.Registry{},
-		&models.HelmRepo{},
-		&models.Cluster{},
-		&models.ClusterCandidate{},
-		&models.ClusterResolver{},
-		&models.Infra{},
-		&models.GitActionConfig{},
-		&models.Invite{},
-		&models.AuthCode{},
-		&models.DNSRecord{},
-		&models.PWResetToken{},
-		&ints.KubeIntegration{},
-		&ints.BasicIntegration{},
-		&ints.OIDCIntegration{},
-		&ints.OAuthIntegration{},
-		&ints.GCPIntegration{},
-		&ints.AWSIntegration{},
-		&ints.TokenCache{},
-		&ints.ClusterTokenCache{},
-		&ints.RegTokenCache{},
-		&ints.HelmRepoTokenCache{},
-		&ints.GithubAppInstallation{},
-		&ints.GithubAppOAuthIntegration{},
-	)
+	err = gorm.AutoMigrate(db)
 
 	if err != nil {
 		logger.Fatal().Err(err).Msg("")

+ 4 - 35
cmd/migrate/main.go

@@ -9,9 +9,7 @@ import (
 	adapter "github.com/porter-dev/porter/internal/adapter"
 	"github.com/porter-dev/porter/internal/config"
 	lr "github.com/porter-dev/porter/internal/logger"
-	"github.com/porter-dev/porter/internal/models"
-
-	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/repository/gorm"
 
 	"github.com/joeshaw/envdecode"
 )
@@ -29,40 +27,11 @@ func main() {
 		return
 	}
 
-	err = db.AutoMigrate(
-		&models.Project{},
-		&models.Role{},
-		&models.User{},
-		&models.Release{},
-		&models.Session{},
-		&models.GitRepo{},
-		&models.Registry{},
-		&models.HelmRepo{},
-		&models.Cluster{},
-		&models.ClusterCandidate{},
-		&models.ClusterResolver{},
-		&models.Infra{},
-		&models.GitActionConfig{},
-		&models.Invite{},
-		&models.AuthCode{},
-		&models.DNSRecord{},
-		&models.PWResetToken{},
-		&ints.KubeIntegration{},
-		&ints.BasicIntegration{},
-		&ints.OIDCIntegration{},
-		&ints.OAuthIntegration{},
-		&ints.GCPIntegration{},
-		&ints.AWSIntegration{},
-		&ints.TokenCache{},
-		&ints.ClusterTokenCache{},
-		&ints.RegTokenCache{},
-		&ints.HelmRepoTokenCache{},
-		&ints.GithubAppInstallation{},
-		&ints.GithubAppOAuthIntegration{},
-	)
+	err = gorm.AutoMigrate(db)
 
 	if err != nil {
-		panic(err)
+		logger.Fatal().Err(err).Msg("")
+		return
 	}
 
 	if shouldRotate, oldKeyStr, newKeyStr := shouldKeyRotate(); shouldRotate {

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

@@ -255,7 +255,6 @@ class Home extends Component<PropsType, StateType> {
     let { match } = this.props;
     let params = match.params as any;
     let { cluster } = params;
-    console.log("cluster is", cluster);
 
     let { user } = this.context;
 

+ 12 - 13
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/AreaChart.tsx

@@ -4,12 +4,7 @@ import { curveMonotoneX } from "@visx/curve";
 import { scaleTime, scaleLinear } from "@visx/scale";
 import { AxisLeft, AxisBottom } from "@visx/axis";
 
-import {
-  Tooltip,
-  TooltipWithBounds,
-  defaultStyles,
-  useTooltip,
-} from "@visx/tooltip";
+import { TooltipWithBounds, defaultStyles, useTooltip } from "@visx/tooltip";
 
 import { GridRows, GridColumns } from "@visx/grid";
 
@@ -138,7 +133,11 @@ const AreaChart: React.FunctionComponent<AreaProps> = ({
             : d0;
       }
 
-      if (!isHpaEnabled) {
+      const hpaIndex = bisectDate(hpaData, x0, 1);
+      // Get new index without min value to be sure that data exists for HPA
+      const hpaIndex2 = bisectDate(hpaData, x0);
+
+      if (!isHpaEnabled || hpaIndex !== hpaIndex2) {
         showTooltip({
           tooltipData: { data: d, tooltipHpaData: undefined },
           tooltipLeft: x || 0,
@@ -147,8 +146,8 @@ const AreaChart: React.FunctionComponent<AreaProps> = ({
         return;
       }
 
-      const tooltipHpaData0 = hpaData[index - 1];
-      const tooltipHpaData1 = hpaData[index];
+      const tooltipHpaData0 = hpaData[hpaIndex - 1];
+      const tooltipHpaData1 = hpaData[hpaIndex];
       let tooltipHpaData = tooltipHpaData0;
 
       if (tooltipHpaData1 && getDate(tooltipHpaData1)) {
@@ -194,7 +193,7 @@ const AreaChart: React.FunctionComponent<AreaProps> = ({
     (hpaEnabled &&
       tooltipData?.tooltipHpaData &&
       valueScale(getValue(tooltipData?.tooltipHpaData))) ||
-    0;
+    null;
 
   const dataGraphTooltipGlyphPosition =
     (tooltipData?.data && valueScale(getValue(tooltipData.data))) || 0;
@@ -332,7 +331,7 @@ const AreaChart: React.FunctionComponent<AreaProps> = ({
               strokeWidth={2}
               pointerEvents="none"
             />
-            {isHpaEnabled && (
+            {isHpaEnabled && hpaGraphTooltipGlyphPosition !== null && (
               <>
                 <circle
                   cx={tooltipLeft}
@@ -376,9 +375,9 @@ const AreaChart: React.FunctionComponent<AreaProps> = ({
             <div style={{ color: accentColor }}>
               {dataKey}: {getValue(tooltipData.data)}
             </div>
-            {isHpaEnabled && (
+            {isHpaEnabled && hpaGraphTooltipGlyphPosition !== null && (
               <div style={{ color: "#FFF" }}>
-                HPA Threshold: {getValue(tooltipData.tooltipHpaData)}
+                Autoscaling Threshold: {getValue(tooltipData.tooltipHpaData)}
               </div>
             )}
           </TooltipWithBounds>

+ 6 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx

@@ -20,7 +20,7 @@ type PropsType = {
 };
 
 const resolutions: { [range: string]: string } = {
-  "1H": "15s",
+  "1H": "1s",
   "6H": "15s",
   "1D": "15s",
   "1M": "5h",
@@ -72,7 +72,10 @@ const MetricsSection: React.FunctionComponent<PropsType> = ({
         if (prev.find((option) => option.value === "hpa_replicas")) {
           return [...prev];
         }
-        return [...prev, { value: "hpa_replicas", label: "HPA Replicas" }];
+        return [
+          ...prev,
+          { value: "hpa_replicas", label: "Number of replicas" },
+        ];
       });
     } else {
       setMetricsOptions((prev) => {
@@ -488,7 +491,7 @@ const MetricsSection: React.FunctionComponent<PropsType> = ({
               <CheckboxRow
                 toggle={() => setHpaEnabled((prev) => !prev)}
                 checked={hpaEnabled}
-                label="Enable HPA Metrics"
+                label="Show Autoscaling Threshold"
               />
             )}
           <ParentSize>

+ 145 - 145
dashboard/src/main/home/integrations/IntegrationCategories.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { useEffect, useContext, useState } from "react";
 import styled from "styled-components";
 import GHIcon from "assets/GithubIcon";
 
@@ -8,45 +8,31 @@ import { RouteComponentProps, withRouter } from "react-router";
 import IntegrationList from "./IntegrationList";
 import api from "shared/api";
 import { pushFiltered } from "shared/routing";
+import Loading from "../../../components/Loading";
+import ConfirmOverlay from "../../../components/ConfirmOverlay";
+import SlackIntegrationList from "./SlackIntegrationList";
 import TitleSection from "components/TitleSection";
 
-type PropsType = RouteComponentProps & {
+type Props = RouteComponentProps & {
   category: string;
 };
 
-type StateType = {
-  // currentIntegration: string | null;
-  currentOptions: any[];
-  currentTitles: any[];
-  currentIds: any[];
-  currentIntegrationData: any[];
-};
+const IntegrationCategories: React.FC<Props> = (props) => {
+  const [currentOptions, setCurrentOptions] = useState([]);
+  const [currentTitles, setCurrentTitles] = useState([]);
+  const [currentIds, setCurrentIds] = useState([]);
+  const [currentIntegrationData, setCurrentIntegrationData] = useState([]);
+  const [loading, setLoading] = useState(false);
+  const [slackData, setSlackData] = useState([]);
 
-class IntegrationCategories extends Component<PropsType, StateType> {
-  state = {
-    currentOptions: [] as any[],
-    currentTitles: [] as any[],
-    currentIds: [] as any[],
-    currentIntegrationData: [] as any[],
-  };
+  const { currentProject, setCurrentModal } = useContext(Context);
 
-  componentDidMount() {
-    this.getIntegrationsForCategory(this.props.category);
-  }
-
-  componentDidUpdate(prevProps: PropsType, prevState: StateType) {
-    if (this.props.category != prevProps.category) {
-      this.getIntegrationsForCategory(this.props.category);
-    }
-  }
+  const getIntegrationsForCategory = (categoryType: string) => {
+    setLoading(true);
+    setCurrentOptions([]);
+    setCurrentTitles([]);
+    setCurrentIntegrationData([]);
 
-  getIntegrationsForCategory = (categoryType: string) => {
-    const { currentProject } = this.context;
-    this.setState({
-      currentOptions: [],
-      currentTitles: [],
-      currentIntegrationData: [],
-    });
     switch (categoryType) {
       case "kubernetes":
         api
@@ -73,39 +59,25 @@ class IntegrationCategories extends Component<PropsType, StateType> {
                 val.sort((a: any, b: any) => (a.name > b.name ? 1 : -1))
               );
             });
-
-            let currentOptions = [] as string[];
-            let currentTitles = [] as string[];
+            let newCurrentOptions = [] as string[];
+            let newCurrentTitles = [] as string[];
             final.forEach((integration: any, i: number) => {
-              currentOptions.push(integration.service);
-              currentTitles.push(integration.name);
-            });
-            this.setState({
-              currentOptions,
-              currentTitles,
-              currentIntegrationData: final,
+              newCurrentOptions.push(integration.service);
+              newCurrentTitles.push(integration.name);
             });
+            setCurrentOptions(newCurrentOptions);
+            setCurrentTitles(newCurrentTitles);
+            setCurrentIntegrationData(final);
+            setLoading(false);
           })
           .catch(console.log);
         break;
-      case "repo":
+      case "slack":
         api
-          .getGitRepos("<token>", {}, { project_id: currentProject.id })
+          .getSlackIntegrations("<token>", {}, { id: currentProject.id })
           .then((res) => {
-            let currentOptions = [] as string[];
-            let currentTitles = [] as string[];
-            let currentIds = [] as any[];
-            res.data.forEach((item: any) => {
-              currentOptions.push(item.service);
-              currentTitles.push(item.repo_entity);
-              currentIds.push(item.id);
-            });
-            this.setState({
-              currentOptions,
-              currentTitles,
-              currentIds,
-              currentIntegrationData: res.data,
-            });
+            setSlackData(res.data);
+            setLoading(false);
           })
           .catch(console.log);
         break;
@@ -114,100 +86,128 @@ class IntegrationCategories extends Component<PropsType, StateType> {
     }
   };
 
-  render = () => {
-    const { category: currentCategory } = this.props;
-    let icon =
-      integrationList[currentCategory] && integrationList[currentCategory].icon;
-    let label =
-      integrationList[currentCategory] &&
-      integrationList[currentCategory].label;
-    let buttonText =
-      integrationList[currentCategory] &&
-      integrationList[currentCategory].buttonText;
-    if (currentCategory !== "repo") {
-      return (
-        <>
-          <Flex>
-            <TitleSection
-              handleNavBack={() =>
-                pushFiltered(this.props, "/integrations", ["project_id"])
-              }
-              icon={icon}
-            >
-              {label}
-            </TitleSection>
-            <Button
-              onClick={() =>
-                this.context.setCurrentModal("IntegrationsModal", {
-                  category: currentCategory,
-                  setCurrentIntegration: (x: string) =>
-                    pushFiltered(
-                      this.props,
-                      `/integrations/${this.props.category}/create/${x}`,
-                      ["project_id"]
-                    ),
-                })
-              }
-            >
-              <i className="material-icons">add</i>
-              {buttonText}
-            </Button>
-          </Flex>
+  useEffect(() => {
+    getIntegrationsForCategory(props.category);
+  }, [props.category]);
 
-          <IntegrationList
-            currentCategory={currentCategory}
-            integrations={this.state.currentOptions}
-            titles={this.state.currentTitles}
-            itemIdentifier={this.state.currentIntegrationData}
-            updateIntegrationList={() =>
-              this.getIntegrationsForCategory(this.props.category)
-            }
-          />
-        </>
-      );
-    } else {
-      return (
-        <>
-          <Flex>
-            <TitleSection
-              handleNavBack={() =>
-                pushFiltered(this.props, "/integrations", ["project_id"])
-              }
-              icon={icon}
-            >
-              {label}
-            </TitleSection>
-            <Button
-              onClick={() =>
-                window.open(
-                  `/api/oauth/projects/${this.context.currentProject.id}/github`
-                )
-              }
-            >
-              <GHIcon />
-              {buttonText}
-            </Button>
-          </Flex>
+  const { category: currentCategory } = props;
+  const icon =
+    integrationList[currentCategory] && integrationList[currentCategory].icon;
+  const label =
+    integrationList[currentCategory] && integrationList[currentCategory].label;
+  const buttonText =
+    integrationList[currentCategory] &&
+    integrationList[currentCategory].buttonText;
 
-          <IntegrationList
-            currentCategory={currentCategory}
-            integrations={this.state.currentOptions}
-            titles={this.state.currentTitles}
-            itemIdentifier={this.state.currentIds}
-            updateIntegrationList={() =>
-              this.getIntegrationsForCategory(this.props.category)
+  return (
+    <>
+      <Flex>
+        <TitleSection
+          handleNavBack={() =>
+            pushFiltered(props, "/integrations", ["project_id"])
+          }
+          icon={icon}
+        >
+          {label}
+        </TitleSection>
+        <Button
+          onClick={() => {
+            if (props.category != "slack") {
+              setCurrentModal("IntegrationsModal", {
+                category: currentCategory,
+                setCurrentIntegration: (x: string) =>
+                  pushFiltered(
+                    props,
+                    `/integrations/${props.category}/create/${x}`,
+                    ["project_id"]
+                  ),
+              });
+            } else {
+              window.location.href = `/api/oauth/projects/${currentProject.id}/slack`;
             }
-          />
-        </>
-      );
-    }
-  };
-}
+          }}
+        >
+          <i className="material-icons">add</i>
+          {buttonText}
+        </Button>
+      </Flex>
 
-IntegrationCategories.contextType = Context;
+      <LineBreak />
+
+      {loading ? (
+        <Loading />
+      ) : props.category == "slack" ? (
+        <SlackIntegrationList slackData={slackData} />
+      ) : (
+        <IntegrationList
+          currentCategory={props.category}
+          integrations={currentOptions}
+          titles={currentTitles}
+          itemIdentifier={currentIntegrationData}
+          updateIntegrationList={() =>
+            getIntegrationsForCategory(props.category)
+          }
+        />
+      )}
+    </>
+  );
+};
 
 export default withRouter(IntegrationCategories);
 
+const Label = styled.div`
+  color: #ffffff;
+  font-size: 14px;
+  font-weight: 500;
+`;
+
+const MainRow = styled.div`
+  height: 70px;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 25px;
+  border-radius: 5px;
+  :hover {
+    background: ${(props: { disabled: boolean }) =>
+      props.disabled ? "" : "#ffffff11"};
+    > i {
+      background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
+    }
+  }
+
+  > i {
+    border-radius: 20px;
+    font-size: 18px;
+    padding: 5px;
+    color: #ffffff44;
+    margin-right: -7px;
+    :hover {
+      background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
+    }
+  }
+`;
+
+const Integration = styled.div`
+  margin-left: -2px;
+  display: flex;
+  flex-direction: column;
+  background: #26282f;
+  cursor: ${(props: { disabled: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+  margin-bottom: 15px;
+  border-radius: 5px;
+  box-shadow: 0 5px 8px 0px #00000033;
+`;
+
+const StyledIntegrationList = styled.div`
+  margin-top: 20px;
+  margin-bottom: 80px;
+`;
+
 const Icon = styled.img`
   width: 27px;
   margin-right: 12px;

+ 14 - 23
dashboard/src/main/home/integrations/Integrations.tsx

@@ -12,18 +12,10 @@ import TitleSection from "components/TitleSection";
 
 type PropsType = RouteComponentProps;
 
-type StateType = {
-  currentIntegrationData: any[];
-};
-
-const IntegrationCategoryStrings = ["registry", "repo"]; /*"kubernetes",*/
-
-class Integrations extends Component<PropsType, StateType> {
-  state = {
-    currentIntegrationData: [] as any[],
-  };
+const IntegrationCategoryStrings = ["registry", "slack"]; /*"kubernetes",*/
 
-  render = () => (
+const Integrations: React.FC<PropsType> = (props) => {
+  return (
     <StyledIntegrations>
       <Switch>
         <Route
@@ -31,33 +23,32 @@ class Integrations extends Component<PropsType, StateType> {
           render={(rp) => {
             const { integration, category } = rp.match.params;
             if (!IntegrationCategoryStrings.includes(category)) {
-              pushFiltered(this.props, "/integrations", ["project_id"]);
+              pushFiltered(props, "/integrations", ["project_id"]);
             }
             let icon =
               integrationList[integration] && integrationList[integration].icon;
             return (
-              <div>
+              <Flex>
                 <TitleSection
-                  icon={icon}
                   handleNavBack={() =>
-                    pushFiltered(this.props, `/integrations/${category}`, [
+                    pushFiltered(props, `/integrations/${category}`, [
                       "project_id",
                     ])
                   }
+                  icon={icon}
                 >
-                  {integrationList[integration].label}
+                    {integrationList[integration].label}
                 </TitleSection>
-                <Buffer />
                 <CreateIntegrationForm
                   integrationName={integration}
                   closeForm={() => {
-                    pushFiltered(this.props, `/integrations/${category}`, [
+                    pushFiltered(props, `/integrations/${category}`, [
                       "project_id",
                     ]);
                   }}
                 />
                 <Br />
-              </div>
+              </Flex>
             );
           }}
         />
@@ -66,7 +57,7 @@ class Integrations extends Component<PropsType, StateType> {
           render={(rp) => {
             const currentCategory = rp.match.params.category;
             if (!IntegrationCategoryStrings.includes(currentCategory)) {
-              pushFiltered(this.props, "/integrations", ["project_id"]);
+              pushFiltered(props, "/integrations", ["project_id"]);
             }
             return <IntegrationCategories category={currentCategory} />;
           }}
@@ -77,9 +68,9 @@ class Integrations extends Component<PropsType, StateType> {
 
             <IntegrationList
               currentCategory={""}
-              integrations={["kubernetes", "registry"]}
+              integrations={["registry", "slack"]}
               setCurrent={(x) =>
-                pushFiltered(this.props, `/integrations/${x}`, ["project_id"])
+                pushFiltered(props, `/integrations/${x}`, ["project_id"])
               }
               isCategory={true}
               updateIntegrationList={() => {}}
@@ -89,7 +80,7 @@ class Integrations extends Component<PropsType, StateType> {
       </Switch>
     </StyledIntegrations>
   );
-}
+};
 
 export default withRouter(Integrations);
 

+ 189 - 0
dashboard/src/main/home/integrations/SlackIntegrationList.tsx

@@ -0,0 +1,189 @@
+import React, { useState, useRef, useContext } from "react";
+import ConfirmOverlay from "../../../components/ConfirmOverlay";
+import styled from "styled-components";
+import { Context } from "../../../shared/Context";
+import api from "../../../shared/api";
+
+interface Props {
+  slackData: any[];
+}
+
+const SlackIntegrationList: React.FC<Props> = (props) => {
+  const [isDelete, setIsDelete] = useState(false);
+  const [deleteIndex, setDeleteIndex] = useState(-1); // guaranteed to be set when used
+  const { currentProject, setCurrentError } = useContext(Context);
+  const deleted = useRef(new Set());
+
+  const handleDelete = () => {
+    api
+      .deleteSlackIntegration(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          slack_integration_id: props.slackData[deleteIndex].id,
+        }
+      )
+      .then(() => {
+        deleted.current.add(deleteIndex);
+        setIsDelete(false);
+      })
+      .catch((err) => {
+        setCurrentError(err);
+      });
+  };
+
+  return (
+    <>
+      <ConfirmOverlay
+        show={isDelete}
+        message={
+          deleteIndex != -1 &&
+          `Are you sure you want to delete the slack integration for team ${
+            props.slackData[deleteIndex].team_name ||
+            props.slackData[deleteIndex].team_id
+          } in channel ${props.slackData[deleteIndex].channel}?`
+        }
+        onYes={handleDelete}
+        onNo={() => setIsDelete(false)}
+      />
+      <StyledIntegrationList>
+        {props.slackData.map((inst, idx) => {
+          if (deleted.current.has(idx)) return null;
+          return (
+            <Integration
+              onClick={() => {}}
+              disabled={false}
+              key={`${inst.team_id}-${inst.channel}`}
+            >
+              <MainRow disabled={false}>
+                <Flex>
+                  <Icon src={inst.team_icon_url && inst.team_icon_url} />
+                  <Label>
+                    {inst.team_name || inst.team_id} - {inst.channel}
+                  </Label>
+                </Flex>
+                <MaterialIconTray disabled={false}>
+                  <i
+                    className="material-icons"
+                    onClick={() => {
+                      setDeleteIndex(idx);
+                      setIsDelete(true);
+                    }}
+                  >
+                    delete
+                  </i>
+                  <i
+                    className="material-icons"
+                    onClick={() => {
+                      window.open(inst.configuration_url, "_blank");
+                    }}
+                  >
+                    launch
+                  </i>
+                </MaterialIconTray>
+              </MainRow>
+            </Integration>
+          );
+        })}
+      </StyledIntegrationList>
+    </>
+  );
+};
+
+export default SlackIntegrationList;
+
+const Label = styled.div`
+  color: #ffffff;
+  font-size: 14px;
+  font-weight: 500;
+`;
+
+const StyledIntegrationList = styled.div`
+  margin-top: 20px;
+  margin-bottom: 80px;
+`;
+
+const MainRow = styled.div`
+  height: 70px;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 25px;
+  border-radius: 5px;
+  :hover {
+    background: ${(props: { disabled: boolean }) =>
+      props.disabled ? "" : "#ffffff11"};
+    > i {
+      background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
+    }
+  }
+
+  > i {
+    border-radius: 20px;
+    font-size: 18px;
+    padding: 5px;
+    color: #ffffff44;
+    margin-right: -7px;
+    :hover {
+      background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
+    }
+  }
+`;
+
+const Integration = styled.div`
+  margin-left: -2px;
+  display: flex;
+  flex-direction: column;
+  background: #26282f;
+  cursor: ${(props: { disabled: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+  margin-bottom: 15px;
+  border-radius: 5px;
+  box-shadow: 0 5px 8px 0px #00000033;
+`;
+
+const Icon = styled.img`
+  width: 27px;
+  margin-right: 12px;
+  margin-bottom: -1px;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+
+  > i {
+    cursor: pointer;
+    font-size 24px;
+    color: #969Fbbaa;
+    padding: 3px;
+    margin-right: 11px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+`;
+
+const MaterialIconTray = styled.div`
+  max-width: 60px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  > i {
+    background: #26282f;
+    border-radius: 20px;
+    font-size: 18px;
+    padding: 5px;
+    margin: 0 5px;
+    color: #ffffff44;
+    :hover {
+      background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
+    }
+  }
+`;

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

@@ -258,6 +258,16 @@ const deleteRegistryIntegration = baseApi<
   return `/api/projects/${pathParams.project_id}/registries/${pathParams.registry_id}`;
 });
 
+const deleteSlackIntegration = baseApi<
+  {},
+  {
+    project_id: number;
+    slack_integration_id: number;
+  }
+>("DELETE", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/slack_integrations/${pathParams.slack_integration_id}`;
+});
+
 const deployTemplate = baseApi<
   {
     templateName: string;
@@ -681,6 +691,13 @@ const getRepos = baseApi<{}, { id: number }>("GET", (pathParams) => {
   return `/api/projects/${pathParams.id}/repos`;
 });
 
+const getSlackIntegrations = baseApi<{}, { id: number }>(
+  "GET",
+  (pathParams) => {
+    return `/api/projects/${pathParams.id}/slack_integrations`;
+  }
+);
+
 const getRevisions = baseApi<
   {
     namespace: string;
@@ -1006,6 +1023,7 @@ export default {
   deletePod,
   deleteProject,
   deleteRegistryIntegration,
+  deleteSlackIntegration,
   createSubdomain,
   deployTemplate,
   deployAddon,
@@ -1050,6 +1068,7 @@ export default {
   getRegistryIntegrations,
   getReleaseToken,
   getRepoIntegrations,
+  getSlackIntegrations,
   getRepos,
   getRevisions,
   getTemplateInfo,

+ 5 - 0
dashboard/src/shared/common.tsx

@@ -26,6 +26,11 @@ export const integrationList: any = {
     label: "Git Repository",
     buttonText: "Link a Github Account",
   },
+  slack: {
+    icon: "https://image.flaticon.com/icons/png/512/2111/2111615.png",
+    label: "Slack",
+    buttonText: "Install Application",
+  },
   registry: {
     icon:
       "https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png",

+ 76 - 0
docker-compose.dev-secure.yaml

@@ -0,0 +1,76 @@
+version: "3"
+services:
+  webpack:
+    build:
+      context: ./dashboard
+      dockerfile: ./docker/dev.Dockerfile
+    env_file:
+      - ./dashboard/.env
+    restart: on-failure
+    volumes:
+      - ./dashboard/src:/webpack/src:rw,cached
+      - ./dashboard/package.json:/webpack/package.json
+  porter:
+    build:
+      context: .
+      dockerfile: ./docker/dev.Dockerfile
+    depends_on:
+      - postgres
+    env_file:
+      - ./docker/.env
+    command: /bin/sh -c '/porter/bin/migrate; air -c .air.toml;'
+    restart: on-failure
+    volumes:
+      - ./cmd:/porter/cmd
+      - ./internal:/porter/internal
+      - ./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
+    environment:
+      - POSTGRES_USER=porter
+      - POSTGRES_PASSWORD=porter
+      - POSTGRES_DB=porter
+    ports:
+      - 5400:5432
+    volumes:
+      - database:/var/lib/postgresql/data
+  redis:
+    image: redis:latest
+    container_name: redis
+    ports:
+      - 6379:6379
+    volumes:
+      - database:/var/lib/postgresql/data
+  chartmuseum:
+    image: docker.io/bitnami/chartmuseum:0-debian-10
+    container_name: chartmuseum
+    ports:
+      - 5000:8080
+    volumes:
+      - chartmuseum:/bitnami/data
+  nginx:
+    image: nginx:mainline-alpine
+    container_name: nginx
+    restart: unless-stopped
+    ports:
+      - 443:443
+    volumes:
+      - type: bind
+        source: ./docker/localhost.crt
+        target: /etc/ssl/localhost.crt
+      - type: bind
+        source: ./docker/localhost.key
+        target: /etc/ssl/localhost.key
+      - ./docker/nginx_local_secure.conf:/etc/nginx/nginx.conf:ro
+    depends_on:
+      - porter
+      - webpack
+
+volumes:
+  database:
+  metabase:
+  chartmuseum:

+ 46 - 0
docker/nginx_local_secure.conf

@@ -0,0 +1,46 @@
+events {}
+http {
+    upstream api {
+        server porter:8080;
+    }
+
+    upstream webpack {
+        server webpack:8080;
+    }
+
+    server {
+        listen               443 ssl;
+        ssl_certificate      /etc/ssl/localhost.crt;
+        ssl_certificate_key  /etc/ssl/localhost.key;
+        ssl_ciphers          HIGH:!aNULL:!MD5;
+
+        server_name localhost;
+
+        location /api/ {
+            proxy_pass http://api;
+            proxy_http_version 1.1;
+            proxy_set_header Upgrade $http_upgrade;
+            proxy_set_header Connection 'upgrade';
+            proxy_set_header Host $host;
+            proxy_cache_bypass $http_upgrade;
+            proxy_set_header   X-Forwarded-Host $server_name;
+            proxy_read_timeout 86400s;
+            proxy_send_timeout 86400s;
+        }
+
+        location / {
+            proxy_pass http://webpack;
+            proxy_pass_header Content-Security-Policy;
+            proxy_http_version 1.1;
+            proxy_set_header Upgrade $http_upgrade;
+            proxy_set_header Connection 'upgrade';
+            proxy_set_header Host $host;
+            proxy_cache_bypass $http_upgrade;
+            proxy_set_header   X-Forwarded-Host $server_name;
+            proxy_read_timeout 86400s;
+            proxy_send_timeout 86400s;
+        }
+    }
+
+    client_max_body_size 10M;
+}

+ 25 - 0
docs/developing/setup.md

@@ -53,3 +53,28 @@ Once WSL is installed, head to docker and enable WSL Integration.
 ![Docker Enable WSL Integration](https://i.imgur.com/QzMyxQx.png)
 
 Next, continue with the Getting Started Section
+
+## Secure Localhost Setup
+
+Sometimes, it may be necessary to serve securely over `https://localhost` (for example, required by Slack integrations). Run the following command from the repository root:
+
+```sh
+openssl req -x509 -out ./docker/localhost.crt -keyout ./docker/localhost.key \
+  -newkey rsa:2048 -nodes -sha256 \
+  -subj '/CN=localhost' -extensions EXT -config <( \
+   printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth")
+```
+
+Update `./docker/.env` with the following:
+
+```
+SERVER_URL=https://localhost
+```
+
+If using Chrome, paste the following into the Chrome address bar:
+
+> chrome://flags/#allow-insecure-localhost
+
+And then Enable the **Allow invalid certificates for resources loaded from localhost** field. 
+
+Finally, run `docker-compose -f docker-compose.dev-secure.yaml up` instead of the standard docker-compose file. 

+ 3 - 0
internal/config/config.go

@@ -59,6 +59,9 @@ type ServerConf struct {
 	SendgridProjectInviteTemplateID string `env:"SENDGRID_INVITE_TEMPLATE_ID"`
 	SendgridSenderEmail             string `env:"SENDGRID_SENDER_EMAIL"`
 
+	SlackClientID     string `env:"SLACK_CLIENT_ID"`
+	SlackClientSecret string `env:"SLACK_CLIENT_SECRET"`
+
 	DOClientID                 string `env:"DO_CLIENT_ID"`
 	DOClientSecret             string `env:"DO_CLIENT_SECRET"`
 	ProvisionerImageTag        string `env:"PROV_IMAGE_TAG,default=latest"`

+ 137 - 0
internal/integrations/slack/notifier.go

@@ -0,0 +1,137 @@
+package slack
+
+import (
+	"bytes"
+	"fmt"
+	"net/http"
+	"time"
+
+	"github.com/porter-dev/porter/internal/models/integrations"
+)
+
+type Notifier interface {
+	Notify(opts *NotifyOpts) error
+}
+
+type DeploymentStatus string
+
+const (
+	StatusDeployed string = "deployed"
+	StatusFailed   string = "failed"
+)
+
+type NotifyOpts struct {
+	// ProjectID is the id of the Porter project that this deployment belongs to
+	ProjectID uint
+
+	// ClusterID is the id of the Porter cluster that this deployment belongs to
+	ClusterID uint
+
+	// ClusterName is the name of the cluster that this deployment was deployed in
+	ClusterName string
+
+	// Status is the current status of the deployment.
+	Status string
+
+	// Info is any additional information about this status, such as an error message if
+	// the deployment failed.
+	Info string
+
+	// Name is the name of the deployment that this notification refers to.
+	Name string
+
+	// Namespace is the Kubernetes namespace of the deployment that this notification refers to.
+	Namespace string
+
+	URL string
+
+	Version int
+}
+
+type SlackNotifier struct {
+	slackInts []*integrations.SlackIntegration
+}
+
+func NewSlackNotifier(slackInts ...*integrations.SlackIntegration) Notifier {
+	return &SlackNotifier{
+		slackInts: slackInts,
+	}
+}
+
+func (s *SlackNotifier) Notify(opts *NotifyOpts) error {
+	var statusPayload string
+
+	switch opts.Status {
+	case StatusDeployed:
+		statusPayload = getSuccessPayload(opts)
+	case StatusFailed:
+		statusPayload = getFailedPayload(opts)
+	}
+
+	payload := fmt.Sprintf(`
+	{
+		"blocks": [
+			%s
+			{
+				"type": "divider"
+			},
+			{
+				"type": "section",
+				"text": {
+					"type": "mrkdwn",
+					"text": "*Name:* %s"
+				}
+			},
+			{
+				"type": "section",
+				"text": {
+					"type": "mrkdwn",
+					"text": "*Namespace:* %s"
+				}
+			},
+			{
+				"type": "section",
+				"text": {
+					"type": "mrkdwn",
+					"text": "*Version:* %d"
+				}
+			}
+		]
+	}
+	`, statusPayload, "`"+opts.Name+"`", "`"+opts.Namespace+"`", opts.Version)
+
+	reqBody := bytes.NewReader([]byte(payload))
+	client := &http.Client{
+		Timeout: time.Second * 5,
+	}
+
+	for _, slackInt := range s.slackInts {
+		client.Post(string(slackInt.Webhook), "application/json", reqBody)
+	}
+
+	return nil
+}
+
+func getSuccessPayload(opts *NotifyOpts) string {
+	return fmt.Sprintf(`
+		{
+			"type": "section",
+			"text": {
+				"type": "mrkdwn",
+				"text": ":rocket: Your application %s was successfully updated on Porter! <%s|View the new release.>"
+			}
+		},
+	`, "`"+opts.Name+"`", opts.URL)
+}
+
+func getFailedPayload(opts *NotifyOpts) string {
+	return fmt.Sprintf(`
+		{
+			"type": "section",
+			"text": {
+				"type": "mrkdwn",
+				"text": ":x: Your application %s failed to deploy on Porter. <%s|View the status here.>"
+			}
+		},
+	`, "`"+opts.Name+"`", opts.URL)
+}

+ 79 - 0
internal/integrations/slack/slack.go

@@ -0,0 +1,79 @@
+package slack
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"golang.org/x/oauth2"
+)
+
+func TokenToSlackIntegration(token *oauth2.Token) (*integrations.SlackIntegration, error) {
+	// cast the "incoming_webhook" field to a map[string]string
+	webhookConfig, ok := token.Extra("incoming_webhook").(map[string]interface{})
+
+	if !ok {
+		return nil, fmt.Errorf("could not get incoming webhook field from token")
+	}
+
+	teamInfo, err := getTeamInfo(token)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &integrations.SlackIntegration{
+		SharedOAuthModel: integrations.SharedOAuthModel{
+			AccessToken: []byte(token.AccessToken),
+		},
+		TeamID:           teamInfo.Team.ID,
+		TeamName:         teamInfo.Team.Name,
+		TeamIconURL:      teamInfo.Team.Icon.Image132,
+		Channel:          webhookConfig["channel"].(string),
+		ChannelID:        webhookConfig["channel_id"].(string),
+		ConfigurationURL: webhookConfig["configuration_url"].(string),
+		Webhook:          []byte(webhookConfig["url"].(string)),
+	}, nil
+}
+
+type teamInfoResponse struct {
+	OK   bool `json:"ok"`
+	Team struct {
+		ID   string `json:"id"`
+		Name string `json:"name"`
+		Icon struct {
+			Image132 string `json:"image_132"`
+		}
+	} `json:"team"`
+}
+
+func getTeamInfo(token *oauth2.Token) (*teamInfoResponse, error) {
+	url := "https://slack.com/api/team.info"
+
+	// Create a new request using http
+	req, err := http.NewRequest("GET", url, nil)
+
+	// add authorization header to the request
+	req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
+
+	// Send req using http Client
+	client := &http.Client{}
+	resp, err := client.Do(req)
+
+	if err != nil {
+		return nil, err
+	}
+
+	defer resp.Body.Close()
+
+	teamInfo := teamInfoResponse{}
+
+	err = json.NewDecoder(resp.Body).Decode(&teamInfo)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &teamInfo, nil
+}

+ 80 - 0
internal/models/integrations/slack.go

@@ -0,0 +1,80 @@
+package integrations
+
+import "gorm.io/gorm"
+
+// SlackIntegration is a webhook notifier to a specific channel in a Slack workspace.
+type SlackIntegration struct {
+	gorm.Model
+	SharedOAuthModel
+
+	// The name of the auth mechanism
+	Client OAuthIntegrationClient `json:"client"`
+
+	// The id of the user that linked this auth mechanism
+	UserID uint `json:"user_id"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	// The ID for the Slack team
+	TeamID string
+
+	// The name of the Slack team
+	TeamName string
+
+	// The icon url for the Slack team
+	TeamIconURL string
+
+	// The channel name that the Slack app is installed in
+	Channel string
+
+	// The channel id that the Slack app is installed in
+	ChannelID string
+
+	// The URL for configuring the workspace app instance
+	ConfigurationURL string
+
+	// ------------------------------------------------------------------
+	// All fields below encrypted before storage.
+	// ------------------------------------------------------------------
+
+	// The webhook to call
+	Webhook []byte
+}
+
+// SlackIntegrationExternal is an external SlackIntegration to be shared over
+// rest
+type SlackIntegrationExternal struct {
+	ID uint `json:"id"`
+
+	ProjectID uint `json:"project_id"`
+
+	// The ID for the Slack team
+	TeamID string `json:"team_id"`
+
+	// The name of the Slack team
+	TeamName string `json:"team_name"`
+
+	// The icon url for the Slack team
+	TeamIconURL string `json:"team_icon_url"`
+
+	// The channel name that the Slack app is installed in
+	Channel string `json:"channel"`
+
+	// The URL for configuring the workspace app instance
+	ConfigurationURL string `json:"configuration_url"`
+}
+
+// Externalize generates an external SlackIntegration to be shared over
+// rest
+func (s *SlackIntegration) Externalize() *SlackIntegrationExternal {
+	return &SlackIntegrationExternal{
+		ID:               s.ID,
+		ProjectID:        s.ProjectID,
+		TeamID:           s.TeamID,
+		TeamName:         s.TeamName,
+		TeamIconURL:      s.TeamIconURL,
+		Channel:          s.Channel,
+		ConfigurationURL: s.ConfigurationURL,
+	}
+}

+ 15 - 1
internal/oauth/config.go

@@ -4,9 +4,10 @@ import (
 	"context"
 	"crypto/rand"
 	"encoding/base64"
+	"time"
+
 	"github.com/porter-dev/porter/internal/models/integrations"
 	"github.com/porter-dev/porter/internal/repository"
-	"time"
 
 	"golang.org/x/oauth2"
 )
@@ -85,6 +86,19 @@ func NewGoogleClient(cfg *Config) *oauth2.Config {
 	}
 }
 
+func NewSlackClient(cfg *Config) *oauth2.Config {
+	return &oauth2.Config{
+		ClientID:     cfg.ClientID,
+		ClientSecret: cfg.ClientSecret,
+		Endpoint: oauth2.Endpoint{
+			AuthURL:  "https://slack.com/oauth/v2/authorize",
+			TokenURL: "https://slack.com/api/oauth.v2.access",
+		},
+		RedirectURL: cfg.BaseURL + "/api/oauth/slack/callback",
+		Scopes:      cfg.Scopes,
+	}
+}
+
 func CreateRandomState() string {
 	b := make([]byte, 16)
 	rand.Read(b)

+ 43 - 0
internal/repository/gorm/migrate.go

@@ -0,0 +1,43 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+
+	"gorm.io/gorm"
+)
+
+func AutoMigrate(db *gorm.DB) error {
+	return db.AutoMigrate(
+		&models.Project{},
+		&models.Role{},
+		&models.User{},
+		&models.Release{},
+		&models.Session{},
+		&models.GitRepo{},
+		&models.Registry{},
+		&models.HelmRepo{},
+		&models.Cluster{},
+		&models.ClusterCandidate{},
+		&models.ClusterResolver{},
+		&models.Infra{},
+		&models.GitActionConfig{},
+		&models.Invite{},
+		&models.AuthCode{},
+		&models.DNSRecord{},
+		&models.PWResetToken{},
+		&ints.KubeIntegration{},
+		&ints.BasicIntegration{},
+		&ints.OIDCIntegration{},
+		&ints.OAuthIntegration{},
+		&ints.GCPIntegration{},
+		&ints.AWSIntegration{},
+		&ints.TokenCache{},
+		&ints.ClusterTokenCache{},
+		&ints.RegTokenCache{},
+		&ints.HelmRepoTokenCache{},
+		&ints.GithubAppInstallation{},
+		&ints.GithubAppOAuthIntegration{},
+		&ints.SlackIntegration{},
+	)
+}

+ 1 - 0
internal/repository/gorm/repository.go

@@ -31,5 +31,6 @@ func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 		AWSIntegration:            NewAWSIntegrationRepository(db, key),
 		GithubAppInstallation:     NewGithubAppInstallationRepository(db),
 		GithubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(db),
+		SlackIntegration:          NewSlackIntegrationRepository(db, key),
 	}
 }

+ 168 - 0
internal/repository/gorm/slack.go

@@ -0,0 +1,168 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+// SlackIntegrationRepository uses gorm.DB for querying the database
+type SlackIntegrationRepository struct {
+	db  *gorm.DB
+	key *[32]byte
+}
+
+// NewSlackIntegrationRepository returns a SlackIntegrationRepository which uses
+// gorm.DB for querying the database. It accepts an encryption key to encrypt
+// sensitive data
+func NewSlackIntegrationRepository(
+	db *gorm.DB,
+	key *[32]byte,
+) repository.SlackIntegrationRepository {
+	return &SlackIntegrationRepository{db, key}
+}
+
+// CreateSlackIntegration creates a new kube auth mechanism
+func (repo *SlackIntegrationRepository) CreateSlackIntegration(
+	slackInt *ints.SlackIntegration,
+) (*ints.SlackIntegration, error) {
+	err := repo.EncryptSlackIntegrationData(slackInt, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if err := repo.db.Create(slackInt).Error; err != nil {
+		return nil, err
+	}
+
+	return slackInt, nil
+}
+
+// ListSlackIntegrationsByProjectID finds all kube auth mechanisms
+// for a given project id
+func (repo *SlackIntegrationRepository) ListSlackIntegrationsByProjectID(
+	projectID uint,
+) ([]*ints.SlackIntegration, error) {
+	slackInts := []*ints.SlackIntegration{}
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&slackInts).Error; err != nil {
+		return nil, err
+	}
+
+	for _, slackInt := range slackInts {
+		repo.DecryptSlackIntegrationData(slackInt, repo.key)
+	}
+
+	return slackInts, nil
+}
+
+// DeleteSlackIntegration deletes a slack integration by ID
+func (repo *SlackIntegrationRepository) DeleteSlackIntegration(
+	integrationID uint,
+) error {
+	if err := repo.db.Where("id = ?", integrationID).Delete(&ints.SlackIntegration{}).Error; err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// EncryptSlackIntegrationData will encrypt the slack integration data before
+// writing to the DB
+func (repo *SlackIntegrationRepository) EncryptSlackIntegrationData(
+	slackInt *ints.SlackIntegration,
+	key *[32]byte,
+) error {
+	if len(slackInt.ClientID) > 0 {
+		cipherData, err := repository.Encrypt(slackInt.ClientID, key)
+
+		if err != nil {
+			return err
+		}
+
+		slackInt.ClientID = cipherData
+	}
+
+	if len(slackInt.AccessToken) > 0 {
+		cipherData, err := repository.Encrypt(slackInt.AccessToken, key)
+
+		if err != nil {
+			return err
+		}
+
+		slackInt.AccessToken = cipherData
+	}
+
+	if len(slackInt.RefreshToken) > 0 {
+		cipherData, err := repository.Encrypt(slackInt.RefreshToken, key)
+
+		if err != nil {
+			return err
+		}
+
+		slackInt.RefreshToken = cipherData
+	}
+
+	if len(slackInt.Webhook) > 0 {
+		cipherData, err := repository.Encrypt(slackInt.Webhook, key)
+
+		if err != nil {
+			return err
+		}
+
+		slackInt.Webhook = cipherData
+	}
+
+	return nil
+}
+
+// DecryptSlackIntegrationData will decrypt the slack integration data before
+// returning it from the DB
+func (repo *SlackIntegrationRepository) DecryptSlackIntegrationData(
+	slackInt *ints.SlackIntegration,
+	key *[32]byte,
+) error {
+	if len(slackInt.ClientID) > 0 {
+		plaintext, err := repository.Decrypt(slackInt.ClientID, key)
+
+		if err != nil {
+			return err
+		}
+
+		slackInt.ClientID = plaintext
+	}
+
+	if len(slackInt.AccessToken) > 0 {
+		plaintext, err := repository.Decrypt(slackInt.AccessToken, key)
+
+		if err != nil {
+			return err
+		}
+
+		slackInt.AccessToken = plaintext
+	}
+
+	if len(slackInt.RefreshToken) > 0 {
+		plaintext, err := repository.Decrypt(slackInt.RefreshToken, key)
+
+		if err != nil {
+			return err
+		}
+
+		slackInt.RefreshToken = plaintext
+	}
+
+	if len(slackInt.Webhook) > 0 {
+		plaintext, err := repository.Decrypt(slackInt.Webhook, key)
+
+		if err != nil {
+			return err
+		}
+
+		slackInt.Webhook = plaintext
+	}
+
+	return nil
+}

+ 7 - 0
internal/repository/integrations.go

@@ -45,6 +45,13 @@ type GithubAppOAuthIntegrationRepository interface {
 	UpdateGithubAppOauthIntegration(am *ints.GithubAppOAuthIntegration) (*ints.GithubAppOAuthIntegration, error)
 }
 
+// SlackIntegrationRepository represents the set of queries on a Slack integration
+type SlackIntegrationRepository interface {
+	CreateSlackIntegration(slackInt *ints.SlackIntegration) (*ints.SlackIntegration, error)
+	ListSlackIntegrationsByProjectID(projectID uint) ([]*ints.SlackIntegration, error)
+	DeleteSlackIntegration(integrationID uint) error
+}
+
 // AWSIntegrationRepository represents the set of queries on the AWS auth
 // mechanism
 type AWSIntegrationRepository interface {

+ 1 - 0
internal/repository/repository.go

@@ -24,4 +24,5 @@ type Repository struct {
 	AWSIntegration            AWSIntegrationRepository
 	GithubAppInstallation     GithubAppInstallationRepository
 	GithubAppOAuthIntegration GithubAppOAuthIntegrationRepository
+	SlackIntegration          SlackIntegrationRepository
 }

+ 23 - 7
server/api/api.go

@@ -87,6 +87,7 @@ type App struct {
 	GithubAppConf     *oauth.GithubAppConf
 	DOConf            *oauth2.Config
 	GoogleUserConf    *oauth2.Config
+	SlackConf         *oauth2.Config
 
 	db              *gorm.DB
 	validator       *vr.Validate
@@ -96,13 +97,14 @@ type App struct {
 }
 
 type AppCapabilities struct {
-	Provisioning bool `json:"provisioner"`
-	Github       bool `json:"github"`
-	BasicLogin   bool `json:"basic_login"`
-	GithubLogin  bool `json:"github_login"`
-	GoogleLogin  bool `json:"google_login"`
-	Email        bool `json:"email"`
-	Analytics    bool `json:"analytics"`
+	Provisioning       bool `json:"provisioner"`
+	Github             bool `json:"github"`
+	BasicLogin         bool `json:"basic_login"`
+	GithubLogin        bool `json:"github_login"`
+	GoogleLogin        bool `json:"google_login"`
+	SlackNotifications bool `json:"slack_notifs"`
+	Email              bool `json:"email"`
+	Analytics          bool `json:"analytics"`
 }
 
 // New returns a new App instance
@@ -202,6 +204,20 @@ func New(conf *AppConfig) (*App, error) {
 		})
 	}
 
+	if sc.SlackClientID != "" && sc.SlackClientSecret != "" {
+		app.Capabilities.SlackNotifications = true
+
+		app.SlackConf = oauth.NewSlackClient(&oauth.Config{
+			ClientID:     sc.SlackClientID,
+			ClientSecret: sc.SlackClientSecret,
+			Scopes: []string{
+				"incoming-webhook",
+				"team:read",
+			},
+			BaseURL: sc.ServerURL,
+		})
+	}
+
 	if sc.DOClientID != "" && sc.DOClientSecret != "" {
 		app.DOConf = oauth.NewDigitalOceanClient(&oauth.Config{
 			ClientID:     sc.DOClientID,

+ 168 - 0
server/api/oauth_slack_handler.go

@@ -0,0 +1,168 @@
+package api
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/integrations/slack"
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/oauth"
+	"golang.org/x/oauth2"
+)
+
+// HandleSlackOAuthStartProject starts the oauth2 flow for a project slack request.
+// In this handler, the project id gets written to the session (along with the oauth
+// state param), so that the correct project id can be identified in the callback.
+func (app *App) HandleSlackOAuthStartProject(w http.ResponseWriter, r *http.Request) {
+	state := oauth.CreateRandomState()
+
+	err := app.populateOAuthSession(w, r, state, true)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// specify access type offline to get a refresh token
+	url := app.SlackConf.AuthCodeURL(state, oauth2.AccessTypeOffline)
+
+	http.Redirect(w, r, url, 302)
+}
+
+// HandleSlackOAuthCallback verifies the callback request by checking that the
+// state parameter has not been modified, and validates the token.
+func (app *App) HandleSlackOAuthCallback(w http.ResponseWriter, r *http.Request) {
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		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"] {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	token, err := app.SlackConf.Exchange(oauth2.NoContext, r.URL.Query().Get("code"))
+
+	if err != nil {
+		fmt.Println("ERR IS", err)
+		return
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+	projID, _ := session.Values["project_id"].(uint)
+
+	slackInt, err := slack.TokenToSlackIntegration(token)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	slackInt.UserID = userID
+	slackInt.ProjectID = projID
+
+	// save to repository
+	slackInt, err = app.Repo.SlackIntegration.CreateSlackIntegration(slackInt)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	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)
+	}
+}
+
+// HandleListSlackIntegrations lists all slack integrations belonging to a certain project
+// ID
+func (app *App) HandleListSlackIntegrations(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)
+		return
+	}
+
+	slackInts, err := app.Repo.SlackIntegration.ListSlackIntegrationsByProjectID(uint(projID))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	extSlackInts := make([]*integrations.SlackIntegrationExternal, 0)
+
+	for _, slackInt := range slackInts {
+		extSlackInts = append(extSlackInts, slackInt.Externalize())
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(extSlackInts); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleDeleteSlackIntegration deletes a slack integration for a project by ID
+func (app *App) HandleDeleteSlackIntegration(w http.ResponseWriter, r *http.Request) {
+	// check that slack integration belongs to given project
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	integrationID, err := strconv.ParseUint(chi.URLParam(r, "slack_integration_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	slackInts, err := app.Repo.SlackIntegration.ListSlackIntegrationsByProjectID(uint(projID))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	for _, slackInt := range slackInts {
+		if slackInt.ID == uint(integrationID) {
+			err = app.Repo.SlackIntegration.DeleteSlackIntegration(slackInt.ID)
+			if err != nil {
+				app.handleErrorInternal(err, w)
+				return
+			}
+			w.WriteHeader(http.StatusOK)
+		}
+	}
+
+	w.WriteHeader(http.StatusNotFound)
+}

+ 56 - 1
server/api/release_handler.go

@@ -25,6 +25,7 @@ import (
 	"github.com/porter-dev/porter/internal/helm/grapher"
 	"github.com/porter-dev/porter/internal/helm/loader"
 	"github.com/porter-dev/porter/internal/integrations/ci/actions"
+	"github.com/porter-dev/porter/internal/integrations/slack"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/repository"
 	"gopkg.in/yaml.v2"
@@ -977,9 +978,31 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
+	slackInts, _ := app.Repo.SlackIntegration.ListSlackIntegrationsByProjectID(uint(projID))
+	notifier := slack.NewSlackNotifier(slackInts...)
+
+	notifyOpts := &slack.NotifyOpts{
+		ProjectID:   uint(projID),
+		ClusterID:   form.Cluster.ID,
+		ClusterName: form.Cluster.Name,
+		Name:        name,
+		Namespace:   form.Namespace,
+		URL: fmt.Sprintf(
+			"%s/applications/%s/%s/%s",
+			app.ServerConf.ServerURL,
+			url.PathEscape(form.Cluster.Name),
+			form.Namespace,
+			name,
+		) + fmt.Sprintf("?project_id=%d", uint(projID)),
+	}
+
 	rel, err := agent.UpgradeRelease(conf, form.Values, app.DOConf)
 
 	if err != nil {
+		notifyOpts.Status = slack.StatusFailed
+
+		notifier.Notify(notifyOpts)
+
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
 			Code:   ErrReleaseDeploy,
 			Errors: []string{err.Error()},
@@ -988,6 +1011,11 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	notifyOpts.Status = string(rel.Info.Status)
+	notifyOpts.Version = rel.Version
+
+	notifier.Notify(notifyOpts)
+
 	// update the github actions env if the release exists and is built from source
 	if cName := rel.Chart.Metadata.Name; cName == "job" || cName == "web" || cName == "worker" {
 		clusterID, err := strconv.ParseUint(vals["cluster_id"][0], 10, 64)
@@ -1172,9 +1200,31 @@ func (app *App) HandleReleaseDeployWebhook(w http.ResponseWriter, r *http.Reques
 		Values:     rel.Config,
 	}
 
-	_, err = agent.UpgradeReleaseByValues(conf, app.DOConf)
+	slackInts, _ := app.Repo.SlackIntegration.ListSlackIntegrationsByProjectID(uint(form.ReleaseForm.Cluster.ProjectID))
+	notifier := slack.NewSlackNotifier(slackInts...)
+
+	notifyOpts := &slack.NotifyOpts{
+		ProjectID:   uint(form.ReleaseForm.Cluster.ProjectID),
+		ClusterID:   form.Cluster.ID,
+		ClusterName: form.Cluster.Name,
+		Name:        rel.Name,
+		Namespace:   rel.Namespace,
+		URL: fmt.Sprintf(
+			"%s/applications/%s/%s/%s",
+			app.ServerConf.ServerURL,
+			url.PathEscape(form.Cluster.Name),
+			form.Namespace,
+			rel.Name,
+		) + fmt.Sprintf("?project_id=%d", uint(form.ReleaseForm.Cluster.ProjectID)),
+	}
+
+	rel, err = agent.UpgradeReleaseByValues(conf, app.DOConf)
 
 	if err != nil {
+		notifyOpts.Status = slack.StatusFailed
+
+		notifier.Notify(notifyOpts)
+
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
 			Code:   ErrReleaseDeploy,
 			Errors: []string{err.Error()},
@@ -1183,6 +1233,11 @@ func (app *App) HandleReleaseDeployWebhook(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
+	notifyOpts.Status = string(rel.Info.Status)
+	notifyOpts.Version = rel.Version
+
+	notifier.Notify(notifyOpts)
+
 	app.analyticsClient.Track(analytics.CreateSegmentRedeployViaWebhookTrack("anonymous", repository.(string)))
 
 	w.WriteHeader(http.StatusOK)

+ 37 - 0
server/router/router.go

@@ -288,6 +288,22 @@ func New(a *api.App) *chi.Mux {
 				requestlog.NewHandler(a.HandleDOOAuthCallback, l),
 			)
 
+			r.Method(
+				"GET",
+				"/oauth/projects/{project_id}/slack",
+				auth.DoesUserHaveProjectAccess(
+					requestlog.NewHandler(a.HandleSlackOAuthStartProject, l),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
+			r.Method(
+				"GET",
+				"/oauth/slack/callback",
+				requestlog.NewHandler(a.HandleSlackOAuthCallback, l),
+			)
+
 			// /api/projects routes
 			r.Method(
 				"GET",
@@ -841,6 +857,27 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			// /api/projects/{project_id}/slack_integrations routes
+			r.Method(
+				"GET",
+				"/projects/{project_id}/slack_integrations",
+				auth.DoesUserHaveProjectAccess(
+					requestlog.NewHandler(a.HandleListSlackIntegrations, l),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
+			r.Method(
+				"DELETE",
+				"/projects/{project_id}/slack_integrations/{slack_integration_id}",
+				auth.DoesUserHaveProjectAccess(
+					requestlog.NewHandler(a.HandleDeleteSlackIntegration, l),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
 			// /api/projects/{project_id}/helmrepos routes
 			r.Method(
 				"POST",