浏览代码

app event webhook (#4529)

Co-authored-by: jusrhee <justin@porter.run>
Yosef Mihretie 2 年之前
父节点
当前提交
0a5890ba8a

+ 178 - 0
api/server/handlers/porter_app/app_event_webhook_get.go

@@ -0,0 +1,178 @@
+package porter_app
+
+import (
+	"errors"
+	"net/http"
+
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// AppEventWebhooksHandler is the handler for fetching app event webhooks
+type AppEventWebhooksHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+const (
+	appEventTypeBuild     string = "build"
+	appEventTypePredeploy string = "predeploy"
+	appEventTypeDeploy    string = "deploy"
+
+	appEventStatusSuccess  string = "success"
+	appEventStatusFailed   string = "failed"
+	appEventStatusCanceled string = "canceled"
+)
+
+// NewAppEventWebhooksHandler returns an AppEventWebhooksHandler
+func NewAppEventWebhooksHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *AppEventWebhooksHandler {
+	return &AppEventWebhooksHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// AppEventWebhooksResponse is the response payload for the AppEventWebhooksHandler
+type AppEventWebhooksResponse struct {
+	AppEventWebhooks []AppEventWebhook `json:"app_event_webhooks"`
+}
+
+// AppEventWebhook holds details for a single app event webhook
+type AppEventWebhook struct {
+	WebhookURL           string `json:"url"`
+	AppEventType         string `json:"app_event_type"`
+	AppEventStatus       string `json:"app_event_status"`
+	PayloadEncryptionKey string `json:"payload_encryption_key"`
+}
+
+// ServeHTTP handles the app event webhook fetch request
+func (a *AppEventWebhooksHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-app-event-webhooks")
+	defer span.End()
+
+	projectID, reqErr := requestutils.GetURLParamUint(r, types.URLParamProjectID)
+	if reqErr != nil {
+		e := telemetry.Error(ctx, span, reqErr, "error retrieving project id")
+		a.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+		return
+	}
+	deploymentTargetID, reqErr := requestutils.GetURLParamString(r, types.URLParamDeploymentTargetIdentifier)
+	if reqErr != nil {
+		e := telemetry.Error(ctx, span, reqErr, "error retrieving deployment target id")
+		a.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+		return
+	}
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
+	if reqErr != nil {
+		e := telemetry.Error(ctx, span, reqErr, "error retrieving app name")
+		a.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(
+		span,
+		telemetry.AttributeKV{Key: "project-id", Value: projectID},
+		telemetry.AttributeKV{Key: "deployment-target-id", Value: deploymentTargetID},
+		telemetry.AttributeKV{Key: "app-name", Value: appName},
+	)
+
+	ccpReq := connect.NewRequest(&porterv1.AppEventWebhooksRequest{
+		ProjectId: int64(projectID),
+		DeploymentTargetIdentifier: &porterv1.DeploymentTargetIdentifier{
+			Id: deploymentTargetID,
+		},
+		AppName: appName,
+	})
+	ccpResp, err := a.Config().ClusterControlPlaneClient.AppEventWebhooks(ctx, ccpReq)
+	if err != nil {
+		e := telemetry.Error(ctx, span, err, "ccp error while listing AppEventWebhooks")
+		a.HandleAPIError(w, r, apierrors.NewErrInternal(e))
+		return
+	}
+	resp := AppEventWebhooksResponse{}
+	if ccpResp.Msg == nil || ccpResp.Msg.AppEventWebhooks == nil {
+		a.WriteResult(w, r, resp)
+		return
+	}
+	for _, appEventWebhook := range ccpResp.Msg.AppEventWebhooks {
+		appEventType, err := toAppEventType(appEventWebhook.AppEventType)
+		if err != nil {
+			e := telemetry.Error(ctx, span, err, "error processing AppEventWebhook from ccp")
+			a.HandleAPIError(w, r, apierrors.NewErrInternal(e))
+			return
+		}
+		appEventStatus, err := toAppEventStatus(appEventWebhook.AppEventStatus)
+		if err != nil {
+			e := telemetry.Error(ctx, span, err, "error processing AppEventWebhook from ccp")
+			a.HandleAPIError(w, r, apierrors.NewErrInternal(e))
+			return
+		}
+		resp.AppEventWebhooks = append(resp.AppEventWebhooks, AppEventWebhook{
+			WebhookURL:           appEventWebhook.WebhookUrl,
+			AppEventType:         appEventType,
+			AppEventStatus:       appEventStatus,
+			PayloadEncryptionKey: appEventWebhook.PayloadEncryptionKey,
+		})
+	}
+	a.WriteResult(w, r, resp)
+}
+
+func toWebhookAppEventTypeEnum(appEventType string) (porterv1.WebhookAppEventType, error) {
+	switch appEventType {
+	case appEventTypeBuild:
+		return porterv1.WebhookAppEventType_WEBHOOK_APP_EVENT_TYPE_BUILD, nil
+	case appEventTypePredeploy:
+		return porterv1.WebhookAppEventType_WEBHOOK_APP_EVENT_TYPE_PREDEPLOY, nil
+	case appEventTypeDeploy:
+		return porterv1.WebhookAppEventType_WEBHOOK_APP_EVENT_TYPE_DEPLOY, nil
+	default:
+		return porterv1.WebhookAppEventType_WEBHOOK_APP_EVENT_TYPE_UNSPECIFIED, errors.New("unsupported app event type")
+	}
+}
+
+func toAppEventType(appEventWebhookEnum porterv1.WebhookAppEventType) (string, error) {
+	switch appEventWebhookEnum {
+	case porterv1.WebhookAppEventType_WEBHOOK_APP_EVENT_TYPE_BUILD:
+		return appEventTypeBuild, nil
+	case porterv1.WebhookAppEventType_WEBHOOK_APP_EVENT_TYPE_PREDEPLOY:
+		return appEventTypePredeploy, nil
+	case porterv1.WebhookAppEventType_WEBHOOK_APP_EVENT_TYPE_DEPLOY:
+		return appEventTypeDeploy, nil
+	default:
+		return "", errors.New("unsupported app event type")
+	}
+}
+
+func toWebhookAppEventStatusEnum(appEventStatus string) (porterv1.WebhookAppEventStatus, error) {
+	switch appEventStatus {
+	case appEventStatusSuccess:
+		return porterv1.WebhookAppEventStatus_WEBHOOK_APP_EVENT_STATUS_SUCCESS, nil
+	case appEventStatusFailed:
+		return porterv1.WebhookAppEventStatus_WEBHOOK_APP_EVENT_STATUS_FAILED, nil
+	case appEventStatusCanceled:
+		return porterv1.WebhookAppEventStatus_WEBHOOK_APP_EVENT_STATUS_CANCELED, nil
+	default:
+		return porterv1.WebhookAppEventStatus_WEBHOOK_APP_EVENT_STATUS_UNSPECIFIED, errors.New("unsupported app event status")
+	}
+}
+
+func toAppEventStatus(appEventStatusEnum porterv1.WebhookAppEventStatus) (string, error) {
+	switch appEventStatusEnum {
+	case porterv1.WebhookAppEventStatus_WEBHOOK_APP_EVENT_STATUS_SUCCESS:
+		return appEventStatusSuccess, nil
+	case porterv1.WebhookAppEventStatus_WEBHOOK_APP_EVENT_STATUS_FAILED:
+		return appEventStatusFailed, nil
+	case porterv1.WebhookAppEventStatus_WEBHOOK_APP_EVENT_STATUS_CANCELED:
+		return appEventStatusCanceled, nil
+	default:
+		return "", errors.New("unsupported app event status")
+	}
+}

+ 118 - 0
api/server/handlers/porter_app/app_event_webhook_update.go

@@ -0,0 +1,118 @@
+package porter_app
+
+import (
+	"net/http"
+
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// UpdateAppEventWebhookHandler is the handler for updating app event webhooks
+type UpdateAppEventWebhookHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewUpdateAppEventWebhookHandler returns an AppEventWebhooksHandler
+func NewUpdateAppEventWebhookHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateAppEventWebhookHandler {
+	return &UpdateAppEventWebhookHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// UpdateAppEventWebhookRequest is the request payload for the UpdateAppEventWebhookHandler
+type UpdateAppEventWebhookRequest struct {
+	AppEventWebhooks []AppEventWebhook `json:"app_event_webhooks"`
+}
+
+// UpdateAppEventWebhookResponse holds details for a single app event webhook
+type UpdateAppEventWebhookResponse struct{}
+
+// ServeHTTP handles the app event webhook update request
+func (a *UpdateAppEventWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-update-app-event-webhook")
+	defer span.End()
+
+	projectID, reqErr := requestutils.GetURLParamUint(r, types.URLParamProjectID)
+	if reqErr != nil {
+		e := telemetry.Error(ctx, span, reqErr, "error retrieving project id")
+		a.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+		return
+	}
+	deploymentTargetID, reqErr := requestutils.GetURLParamString(r, types.URLParamDeploymentTargetIdentifier)
+	if reqErr != nil {
+		e := telemetry.Error(ctx, span, reqErr, "error retrieving deployment target id")
+		a.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+		return
+	}
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
+	if reqErr != nil {
+		e := telemetry.Error(ctx, span, reqErr, "error retrieving app name")
+		a.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(
+		span,
+		telemetry.AttributeKV{Key: "project-id", Value: projectID},
+		telemetry.AttributeKV{Key: "deployment-target-id", Value: deploymentTargetID},
+		telemetry.AttributeKV{Key: "app-name", Value: appName},
+	)
+
+	ccpReq := connect.NewRequest(&porterv1.UpdateAppEventWebhooksRequest{
+		ProjectId: int64(projectID),
+		DeploymentTargetIdentifier: &porterv1.DeploymentTargetIdentifier{
+			Id: deploymentTargetID,
+		},
+		AppName:          appName,
+		AppEventWebhooks: []*porterv1.AppEventWebhook{},
+	})
+	req := UpdateAppEventWebhookRequest{}
+	if !a.DecodeAndValidate(w, r, &req) {
+		e := telemetry.Error(ctx, span, nil, "error decoding request")
+		a.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+		return
+	}
+	for _, appEventWebhook := range req.AppEventWebhooks {
+		telemetry.WithAttributes(
+			span,
+			telemetry.AttributeKV{Key: "app-event-type", Value: appEventWebhook.AppEventType},
+			telemetry.AttributeKV{Key: "app-event-status", Value: appEventWebhook.AppEventStatus},
+			telemetry.AttributeKV{Key: "webhook-url", Value: appEventWebhook.WebhookURL},
+		)
+		appEventTypeEnum, err := toWebhookAppEventTypeEnum(appEventWebhook.AppEventType)
+		if err != nil {
+			e := telemetry.Error(ctx, span, err, "invalid app event type")
+			a.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+			return
+		}
+		appEventStatusEnum, err := toWebhookAppEventStatusEnum(appEventWebhook.AppEventStatus)
+		if err != nil {
+			e := telemetry.Error(ctx, span, err, "invalid app event status")
+			a.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+			return
+		}
+		ccpReq.Msg.AppEventWebhooks = append(ccpReq.Msg.AppEventWebhooks, &porterv1.AppEventWebhook{
+			WebhookUrl:           appEventWebhook.WebhookURL,
+			AppEventType:         appEventTypeEnum,
+			AppEventStatus:       appEventStatusEnum,
+			PayloadEncryptionKey: appEventWebhook.PayloadEncryptionKey,
+		})
+	}
+	_, err := a.Config().ClusterControlPlaneClient.UpdateAppEventWebhooks(ctx, ccpReq)
+	if err != nil {
+		e := telemetry.Error(ctx, span, err, "ccp error while updating AppEventWebhook")
+		a.HandleAPIError(w, r, apierrors.NewErrInternal(e))
+		return
+	}
+	a.WriteResult(w, r, UpdateAppEventWebhookResponse{})
+}

+ 58 - 0
api/server/router/deployment_target.go

@@ -145,5 +145,63 @@ func getDeploymentTargetRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/targets/{deployment_target_identifier}/apps/{porter_app_name}/app-event-webhooks -> porter_app.NewAppEventWebhooksHandler
+	appEventWebhooks := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/apps/{%s}/app-event-webhooks", relPath, types.URLParamPorterAppName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.DeploymentTargetScope,
+			},
+		},
+	)
+
+	appEventWebhooksHandler := porter_app.NewAppEventWebhooksHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: appEventWebhooks,
+		Handler:  appEventWebhooksHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/targets/{deployment_target_identifier}/apps/{porter_app_name}/update-app-event-webhooks-> porter_app.NewUpdateAppEventWebhookHandler
+	updateAppEventWebhooks := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/apps/{%s}/update-app-event-webhooks", relPath, types.URLParamPorterAppName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.DeploymentTargetScope,
+			},
+		},
+	)
+
+	updateAppEventWebhooksHandler := porter_app.NewUpdateAppEventWebhookHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: updateAppEventWebhooks,
+		Handler:  updateAppEventWebhooksHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 7 - 2
dashboard/src/components/porter/Input.tsx

@@ -1,6 +1,7 @@
 import React, { useEffect, useState } from "react";
 import styled from "styled-components";
 import { boolean } from "zod";
+
 import Tooltip from "./Tooltip";
 
 type Props = {
@@ -18,6 +19,7 @@ type Props = {
   disabledTooltip?: string;
   onValueChange?: (value: string) => void;
   hideCursor?: boolean;
+  style?: React.CSSProperties;
 };
 
 const Input: React.FC<Props> = ({
@@ -35,6 +37,7 @@ const Input: React.FC<Props> = ({
   disabledTooltip,
   onValueChange,
   hideCursor = false,
+  style,
 }) => {
   const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
     const inputValue = e.target.value;
@@ -49,6 +52,7 @@ const Input: React.FC<Props> = ({
       <Block width={width}>
         {label && <Label>{label}</Label>}
         <StyledInput
+          style={style}
           value={value}
           onChange={handleChange}
           placeholder={placeholder}
@@ -56,7 +60,7 @@ const Input: React.FC<Props> = ({
           height={height}
           type={type || "text"}
           hasError={(error && true) || error === ""}
-          disabled={disabled ? disabled : false}
+          disabled={disabled || false}
           hideCursor={hideCursor}
         />
         {error && (
@@ -72,6 +76,7 @@ const Input: React.FC<Props> = ({
     <Block width={width}>
       {label && <Label>{label}</Label>}
       <StyledInput
+        style={style}
         autoFocus={autoFocus}
         value={value}
         onChange={handleChange}
@@ -80,7 +85,7 @@ const Input: React.FC<Props> = ({
         height={height}
         type={type || "text"}
         hasError={(error && true) || error === ""}
-        disabled={disabled ? disabled : false}
+        disabled={disabled || false}
         hideCursor={hideCursor}
       />
       {error && (

+ 25 - 7
dashboard/src/main/home/app-dashboard/app-view/tabs/Settings.tsx

@@ -5,27 +5,28 @@ import { useHistory } from "react-router";
 import styled from "styled-components";
 import { z } from "zod";
 
+import UploadArea from "components/form-components/UploadArea";
 import Button from "components/porter/Button";
 import Checkbox from "components/porter/Checkbox";
+import Container from "components/porter/Container";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Input from "components/porter/Input";
 import Spacer from "components/porter/Spacer";
 import Tag from "components/porter/Tag";
 import Text from "components/porter/Text";
 import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
+import { useCloudSqlSecret } from "lib/hooks/useCloudSqlSecret";
 import { type PorterAppFormData } from "lib/porter-apps";
 
 import api from "shared/api";
 import { Context } from "shared/Context";
+import { useDeploymentTarget } from "shared/DeploymentTargetContext";
 import document from "assets/document.svg";
 
-import UploadArea from "components/form-components/UploadArea";
-import Container from "components/porter/Container";
-import { ControlledInput } from "components/porter/ControlledInput";
-import Input from "components/porter/Input";
-import { useCloudSqlSecret } from "lib/hooks/useCloudSqlSecret";
-import { useDeploymentTarget } from "shared/DeploymentTargetContext";
 import DeleteApplicationModal from "../../expanded-app/DeleteApplicationModal";
 import { useLatestRevision } from "../LatestRevisionContext";
 import ExportAppModal from "./ExportAppModal";
+import Webhooks from "./Webhooks";
 
 const Settings: React.FC = () => {
   const { currentProject, currentCluster } = useContext(Context);
@@ -34,6 +35,7 @@ const Settings: React.FC = () => {
   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
   const [isExportModalOpen, setIsExportModalOpen] = useState(false);
   const { porterApp, clusterId, projectId } = useLatestRevision();
+  const { currentDeploymentTarget } = useDeploymentTarget();
   const { updateAppStep } = useAppAnalytics();
   const [isDeleting, setIsDeleting] = useState(false);
   const { control, register, watch } = useFormContext<PorterAppFormData>();
@@ -41,6 +43,11 @@ const Settings: React.FC = () => {
     `porter_stack_${porterApp.name}.yml`
   );
 
+  // this should always be in a deployment target context
+  if (!currentDeploymentTarget) {
+    return null;
+  }
+
   const workflowFileExists = useCallback(async () => {
     try {
       if (
@@ -150,7 +157,6 @@ const Settings: React.FC = () => {
     },
     [githubWorkflowFilename, porterApp.name, clusterId, projectId]
   );
-
   return (
     <StyledSettingsTab>
       <Text size={16}>Enable application auto-rollback</Text>
@@ -219,6 +225,18 @@ const Settings: React.FC = () => {
             <Spacer y={1} />
           </>
         )}
+      <Text size={16}>Application webhooks</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        Configure custom webhooks to trigger on different deployment events.
+      </Text>
+      <Spacer y={1} />
+      <Webhooks 
+        projectId={projectId}
+        appName={porterApp.name}
+        deploymentTargetId={currentDeploymentTarget.id}
+      />
+      <Spacer y={1} />
       <Text size={16}>Export &quot;{porterApp.name}&quot;</Text>
       <Spacer y={0.5} />
       <Text color="helper">Export this application as Porter YAML.</Text>

+ 273 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/Webhooks.tsx

@@ -0,0 +1,273 @@
+import React, {useEffect, useState } from "react";
+import styled from "styled-components";
+
+import Button from "components/porter/Button";
+import Checkbox from "components/porter/Checkbox";
+import Container from "components/porter/Container";
+import Input from "components/porter/Input";
+import Select from "components/porter/Select";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+import api from "shared/api";
+import { AppEventWebhook } from "shared/types";
+import { url } from "inspector";
+
+type Webhook = {
+  step: string;
+  status: string;
+  url: string;
+  secret: string;
+  hasSecret: boolean;
+};
+
+const stepOptions = [
+  { value: "build", label: "Build" },
+  { value: "deploy", label: "Deploy" },
+  { value: "predeploy", label: "Predeploy" },
+];
+
+const statusOptions = [
+  { value: "success", label: "Success" },
+  { value: "failed", label: "Failed" },
+  { value: "canceled", label: "Canceled" },
+];
+
+type Props = {
+  projectId: number;
+  deploymentTargetId: string;
+  appName: string;
+};
+
+const Webhooks: React.FC<Props> = ({ 
+  projectId,
+  deploymentTargetId, 
+  appName,
+}) => {
+  const [webhooks, setWebhooks] = useState<Webhook[]>([]);
+  const [saveStatus, setSaveStatus] = useState<string>("");
+
+  useEffect(() => {
+    api.appEventWebhooks(
+      "<token>",
+      {},
+      { 
+        projectId: projectId,
+        deploymentTargetId: deploymentTargetId,
+        appName: appName,
+       },
+    )
+    .then(({ data:  { app_event_webhooks } }) =>  {
+      console.log(app_event_webhooks);
+        setWebhooks(app_event_webhooks.map((item: AppEventWebhook) => {
+          return {
+            url: item.url, 
+            step: item.app_event_type,
+            status: item.app_event_status, 
+            secret: item.payload_encryption_key,
+            hasSecret: item.payload_encryption_key.length > 0,
+          };
+      }))
+    })
+    .catch((err) => {
+      console.error(err);
+    });
+  }, []);
+
+  // TODO: implement
+  const saveWebhooks = (): void => {
+    setSaveStatus("loading");
+    api.updateAppEventWebhooks(
+      "<token>",
+      {
+        app_event_webhooks: webhooks.map((item: Webhook) => {
+          return {
+            url: item.url, 
+            app_event_type: item.step,
+            app_event_status: item.status, 
+            payload_encryption_key: item.secret,
+          };
+      })
+      },
+      { 
+        projectId: projectId,
+        deploymentTargetId: deploymentTargetId,
+        appName: appName,
+       },
+    )
+    .then(() => {
+      setSaveStatus("success");
+    })
+    .catch((err) => {
+      console.error(err)
+      setSaveStatus("error");
+    })
+  };
+
+  const addWebhook = (): void => {
+    setWebhooks([
+      ...webhooks,
+      {
+        step: "deploy",
+        status: "success",
+        url: "",
+        secret: "",
+        hasSecret: false,
+      },
+    ]);
+  };
+
+
+  return (
+    <StyledWebhooks>
+      {webhooks?.map((webhook, i) => (
+        <>
+          <WebhookWrapper key={i}>
+            <Container row>
+              <Select
+                value={webhook.step}
+                setValue={(value) => {
+                  const newWebhooks = [...webhooks];
+                  newWebhooks[i].step = value;
+                  setWebhooks(newWebhooks);
+                }}
+                options={stepOptions}
+                width="110px"
+              />
+              <Spacer x={0.5} inline />
+              <Select
+                value={webhook.status}
+                setValue={(value) => {
+                  const newWebhooks = [...webhooks];
+                  newWebhooks[i].status = value;
+                  setWebhooks(newWebhooks);
+                }}
+                options={statusOptions}
+                width="110px"
+              />
+              <Spacer x={0.5} inline />
+            </Container>
+            <Input
+              value={webhook.url}
+              setValue={(url) => {
+                const newWebhooks = [...webhooks];
+                newWebhooks[i].url = url;
+                setWebhooks(newWebhooks);
+              }}
+              placeholder="https://example.com/your-webhook"
+              width="calc(100%)"
+            />
+            <Spacer x={1} inline />
+            <SecretWrapper hasSecret={webhook.hasSecret}>
+              <Container row style={{ minWidth: "120px" }}>
+                <Checkbox
+                  checked={webhook.hasSecret}
+                  toggleChecked={() => {
+                    const newWebhooks = [...webhooks];
+                    newWebhooks[i].hasSecret = !newWebhooks[i].hasSecret;
+                    newWebhooks[i].secret = "";
+                    setWebhooks(newWebhooks);
+                  }}
+                >
+                  <Text size={13}>Enable secret</Text>
+                </Checkbox>
+              </Container>
+              {webhook.hasSecret && (
+                <>
+                  <Bar />
+                  <Input
+                    style={{
+                      border: "none",
+                      height: "calc(100% - 2px)",
+                    }}
+                    value={webhook.secret}
+                    setValue={(secret) => {
+                      const newWebhooks = [...webhooks];
+                      newWebhooks[i].secret = secret;
+                      setWebhooks(newWebhooks);
+                    }}
+                    placeholder="ex: your-secret-key"
+                    width="100%"
+                  />
+                </>
+              )}
+            </SecretWrapper>
+            <DeleteButton
+              onClick={() => {
+                setWebhooks(webhooks.filter((_, index) => index !== i));
+              }}
+            >
+              <i className="material-icons">cancel</i>
+            </DeleteButton>
+          </WebhookWrapper>
+          {i === webhooks.length - 1 && <Spacer y={0.5} />}
+        </>
+      ))}
+      <Container row>
+        <Button alt onClick={addWebhook}>
+          <I className="material-icons">add</I> Add row
+        </Button>
+        <Spacer x={1} inline />
+        <Button onClick={saveWebhooks} status={saveStatus}>Save webhooks</Button>
+      </Container>
+    </StyledWebhooks>
+  );
+};
+
+export default Webhooks;
+
+const Bar = styled.div`
+  width: 1px;
+  height: 15px;
+  background: #494b4f;
+`;
+
+const SecretWrapper = styled.div<{ hasSecret: boolean }>`
+  display: flex;
+  align-items: center;
+  background: ${(props) => props.theme.fg};
+  border: 1px solid #494b4f;
+  padding-left: 8px;
+  border-radius: 5px;
+  height: 30px;
+  justify-content: space-between;
+  width: ${(props) => (props.hasSecret ? "600px" : "200px")};
+  transition: all 0.2s;
+`;
+
+const StyledWebhooks = styled.div``;
+
+const DeleteButton = styled.div`
+  width: 15px;
+  height: 15px;
+  display: flex;
+  align-items: center;
+  margin-top: 8px;
+  margin-left: 8px;
+  justify-content: center;
+
+  > i {
+    font-size: 17px;
+    color: #ffffff44;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    :hover {
+      color: #ffffff88;
+    }
+  }
+`;
+
+const WebhookWrapper = styled.div`
+  display: flex;
+  height: 30px;
+  width: 1000px;
+  max-width: 100%;
+  margin-bottom: 10px;
+`;
+
+const I = styled.i`
+  font-size: 16px;
+  margin-right: 7px;
+`;

+ 10 - 8
dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvVarRow.tsx

@@ -2,8 +2,6 @@ import React from "react";
 import { Controller, useFormContext } from "react-hook-form";
 import styled from "styled-components";
 
-import warning from "assets/warning.svg";
-
 import Image from "components/porter/Image";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
@@ -11,6 +9,8 @@ import Tooltip from "components/porter/Tooltip";
 import { type KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
 import { type PorterAppFormData } from "lib/porter-apps";
 
+import warning from "assets/warning.svg";
+
 type Props = {
   entry: KeyValueType;
   index: number;
@@ -171,14 +171,15 @@ type InputProps = {
 const Input = styled.input<{ flex?: boolean; override?: boolean }>`
   outline: none;
   display: ${(props) => (props.flex ? "flex" : "block")};
-  ${(props) => (props.flex && 'flex: 1;')}
+  ${(props) => props.flex && "flex: 1;"}
   border: none;
   font-size: 13px;
   background: ${(props) => props.theme.fg};
-  border: ${(props) => (props.override ? '2px solid #f4cb42' : ' 1px solid #494b4f')};
+  border: ${(props) =>
+    props.override ? "2px solid #f4cb42" : " 1px solid #494b4f"};
   border-radius: 5px;
-  width: ${(props) => props.width ? props.width : "270px"};
-  color: ${(props) => props.disabled ? "#ffffff44" : "#fefefe"};
+  width: ${(props) => (props.width ? props.width : "270px")};
+  color: ${(props) => (props.disabled ? "#ffffff44" : "#fefefe")};
   padding: 5px 10px;
   height: 35px;
 `;
@@ -190,7 +191,8 @@ export const MultiLineInputer = styled.textarea<InputProps>`
   flex: 1;
   font-size: 13px;
   background: ${(props) => props.theme.fg};
-  border: ${(props) => (props.override ? '2px solid #f4cb42' : ' 1px solid #494b4f')};
+  border: ${(props) =>
+    props.override ? "2px solid #f4cb42" : " 1px solid #494b4f"};
   border-radius: 5px;
   color: ${(props) => (props.disabled ? "#ffffff44" : "#fefefe")};
   padding: 8px 10px 5px 10px;
@@ -242,7 +244,7 @@ const DeleteButton = styled.div`
       color: #ffffff88;
     }
   }
-                    `;
+`;
 
 const HideButton = styled(DeleteButton)`
   > i {

+ 1 - 1
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -87,7 +87,7 @@ const Dashboard: React.FC<Props> = ({
         .catch(console.log);
     }
   }, [currentProject]);
-
+ 
   const currentTab = () => new URLSearchParams(props.location.search).get("tab") || "overview";
 
   useEffect(() => {

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

@@ -12,6 +12,7 @@ import {
 
 import { type PolicyDocType } from "./auth/types";
 import { baseApi } from "./baseApi";
+import { type AppEventWebhook } from "./types";
 import {
   type BuildConfig,
   type CreateUpdatePorterAppOptions,
@@ -3583,6 +3584,27 @@ const createCloudSqlSecret = baseApi<
     `/api/projects/${project_id}/targets/${deployment_target_id}/apps/${app_name}/cloudsql`
 );
 
+const appEventWebhooks = baseApi<
+  {},
+  {
+    projectId: number; deploymentTargetId: string; appName: string 
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.projectId}/targets/${pathParams.deploymentTargetId}/apps/${pathParams.appName}/app-event-webhooks`;
+});
+
+const updateAppEventWebhooks = baseApi<
+  {
+    app_event_webhooks: AppEventWebhook[];
+  },
+  {
+    projectId: number; deploymentTargetId: string; appName: string 
+  }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.projectId}/targets/${pathParams.deploymentTargetId}/apps/${pathParams.appName}/update-app-event-webhooks`;
+});
+
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -3888,4 +3910,8 @@ export default {
 
   getCloudSqlSecret,
   createCloudSqlSecret,
+
+  // Webhooks
+  appEventWebhooks,
+  updateAppEventWebhooks
 };

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

@@ -773,3 +773,10 @@ export type Soc2Check = {
 export type Soc2Data = {
   soc2_checks: Record<string, Soc2Check>;
 };
+
+export type AppEventWebhook = {
+  url: string;
+  app_event_type: string;
+  app_event_status: string;
+  payload_encryption_key: string;
+}

+ 2 - 2
go.mod

@@ -85,7 +85,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.2.143
+	github.com/porter-dev/api-contracts v0.2.149
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d
@@ -290,7 +290,7 @@ require (
 	github.com/jinzhu/inflection v1.0.0 // indirect
 	github.com/jinzhu/now v1.1.5 // indirect
 	github.com/jmespath/go-jmespath v0.4.0 // indirect
-	github.com/jmoiron/sqlx v1.3.5 // indirect
+	github.com/jmoiron/sqlx v1.3.5
 	github.com/josharian/intern v1.0.0 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect

+ 2 - 2
go.sum

@@ -1552,8 +1552,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.2.143 h1:sfAGEACY4N3xw9HmxXMUHJjbHlvclZUGnKonMUQ+VLE=
-github.com/porter-dev/api-contracts v0.2.143/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU=
+github.com/porter-dev/api-contracts v0.2.149 h1:pD2AjBypva1BVYnt7DMU78Ds0BmCubFrlgh/dPI8LEk=
+github.com/porter-dev/api-contracts v0.2.149/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

+ 31 - 0
internal/models/app_event_webhook.go

@@ -0,0 +1,31 @@
+package models
+
+import (
+	"database/sql"
+
+	"github.com/google/uuid"
+	"github.com/jmoiron/sqlx/types"
+	"gorm.io/gorm"
+)
+
+// AppEventWebhooks is a gorm model for storing webhook configuration for an app
+type AppEventWebhooks struct {
+	gorm.Model
+
+	// ID is a unqiue identifier of an AppEventWebhook entry
+	ID        uuid.UUID    `gorm:"type:uuid;primaryKey" json:"id"`
+	CreatedAt sql.NullTime `db:"created_at"`
+	UpdatedAt sql.NullTime `db:"updated_at"`
+	DeletedAt sql.NullTime `db:"deleted_at"`
+
+	// AppInstanceID uniquely identifies the application this webhook URL is configured for
+	AppInstanceID uuid.UUID `db:"app_instance_id"`
+
+	// webhooksJSON is a json text holding webhook configuration for an app
+	WebhooksJSON types.JSONText `db:"webhooks_json"`
+}
+
+// TableName overrides the table name
+func (AppEventWebhooks) TableName() string {
+	return "app_event_webhooks"
+}

+ 12 - 0
internal/repository/app_event_webhook.go

@@ -0,0 +1,12 @@
+package repository
+
+import (
+	"context"
+
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// AppEventWebhookRepository provides storage for app event webhook config
+type AppEventWebhookRepository interface {
+	Insert(ctx context.Context, webhook models.AppEventWebhooks) (models.AppEventWebhooks, error)
+}

+ 24 - 0
internal/repository/gorm/app_event_webhook_repository.go

@@ -0,0 +1,24 @@
+package gorm
+
+import (
+	"context"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// AppEventWebhookRepository provides storage for app event webhook config
+type AppEventWebhookRepository struct {
+	db *gorm.DB
+}
+
+// NewAppEventWebhookRepository returns a dummy AppEventWebhookRespository
+func NewAppEventWebhookRepository(db *gorm.DB) repository.AppEventWebhookRepository {
+	return &AppEventWebhookRepository{db}
+}
+
+// Insert is a placeholder - actual implementation of this repository in CCP
+func (repo *AppEventWebhookRepository) Insert(ctx context.Context, webhook models.AppEventWebhooks) (models.AppEventWebhooks, error) {
+	return webhook, nil
+}

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

@@ -86,5 +86,6 @@ func AutoMigrate(db *gorm.DB, debug bool) error {
 		&ints.GithubAppOAuthIntegration{},
 		&ints.SlackIntegration{},
 		&models.Ipam{},
+		&models.AppEventWebhooks{},
 	)
 }

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

@@ -33,6 +33,7 @@ type GormRepository struct {
 	githubAppInstallation     repository.GithubAppInstallationRepository
 	githubAppOAuthIntegration repository.GithubAppOAuthIntegrationRepository
 	slackIntegration          repository.SlackIntegrationRepository
+	appEventWebhook           repository.AppEventWebhookRepository
 	gitlabIntegration         repository.GitlabIntegrationRepository
 	gitlabAppOAuthIntegration repository.GitlabAppOAuthIntegrationRepository
 	notificationConfig        repository.NotificationConfigRepository
@@ -167,6 +168,11 @@ func (t *GormRepository) SlackIntegration() repository.SlackIntegrationRepositor
 	return t.slackIntegration
 }
 
+// AppEventWebhook returns the AppEventWebhookRepository interface implemented by gorm
+func (t *GormRepository) AppEventWebhook() repository.AppEventWebhookRepository {
+	return t.appEventWebhook
+}
+
 func (t *GormRepository) GitlabIntegration() repository.GitlabIntegrationRepository {
 	return t.gitlabIntegration
 }
@@ -345,5 +351,6 @@ func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.Creden
 		datastore:                 NewDatastoreRepository(db),
 		appInstance:               NewAppInstanceRepository(db),
 		ipam:                      NewIpamRepository(db),
+		appEventWebhook:           NewAppEventWebhookRepository(db),
 	}
 }

+ 1 - 0
internal/repository/repository.go

@@ -27,6 +27,7 @@ type Repository interface {
 	GithubAppInstallation() GithubAppInstallationRepository
 	GithubAppOAuthIntegration() GithubAppOAuthIntegrationRepository
 	SlackIntegration() SlackIntegrationRepository
+	AppEventWebhook() AppEventWebhookRepository
 	GitlabIntegration() GitlabIntegrationRepository
 	GitlabAppOAuthIntegration() GitlabAppOAuthIntegrationRepository
 	NotificationConfig() NotificationConfigRepository

+ 24 - 0
internal/repository/test/app_event_webhook.go

@@ -0,0 +1,24 @@
+package test
+
+import (
+	"context"
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+// AppEventWebhookRepository is a test repository for AppEventWebhooks
+type AppEventWebhookRepository struct {
+	canQuery bool
+}
+
+// NewAppEventWebhookRepository returns a new AppEventWebhookRepository
+func NewAppEventWebhookRepository(canQuery bool, failingMethods ...string) repository.AppEventWebhookRepository {
+	return &AppEventWebhookRepository{canQuery: false}
+}
+
+// Insert is a placeholder - actual implementation of this repository in CCP
+func (repo *AppEventWebhookRepository) Insert(context.Context, models.AppEventWebhooks) (models.AppEventWebhooks, error) {
+	return models.AppEventWebhooks{}, errors.New("cannot read database")
+}

+ 6 - 0
internal/repository/test/repository.go

@@ -32,6 +32,7 @@ type TestRepository struct {
 	gitlabIntegration         repository.GitlabIntegrationRepository
 	gitlabAppOAuthIntegration repository.GitlabAppOAuthIntegrationRepository
 	slackIntegration          repository.SlackIntegrationRepository
+	appEventWebhook           repository.AppEventWebhookRepository
 	notificationConfig        repository.NotificationConfigRepository
 	jobNotificationConfig     repository.JobNotificationConfigRepository
 	buildEvent                repository.BuildEventRepository
@@ -168,6 +169,10 @@ func (t *TestRepository) SlackIntegration() repository.SlackIntegrationRepositor
 	return t.slackIntegration
 }
 
+func (t *TestRepository) AppEventWebhook() repository.AppEventWebhookRepository {
+	return t.appEventWebhook
+}
+
 func (t *TestRepository) NotificationConfig() repository.NotificationConfigRepository {
 	return t.notificationConfig
 }
@@ -309,6 +314,7 @@ func NewRepository(canQuery bool, failingMethods ...string) repository.Repositor
 		gitlabIntegration:         NewGitlabIntegrationRepository(canQuery),
 		gitlabAppOAuthIntegration: NewGitlabAppOAuthIntegrationRepository(canQuery),
 		slackIntegration:          NewSlackIntegrationRepository(canQuery),
+		appEventWebhook:           NewAppEventWebhookRepository(canQuery),
 		notificationConfig:        NewNotificationConfigRepository(canQuery),
 		jobNotificationConfig:     NewJobNotificationConfigRepository(canQuery),
 		buildEvent:                NewBuildEventRepository(canQuery),