فهرست منبع

update and delete apps (#3450)

ianedwards 2 سال پیش
والد
کامیت
5fe8745ddf

+ 34 - 10
api/server/handlers/porter_app/delete.go

@@ -3,6 +3,8 @@ 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/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -11,6 +13,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
 )
 
 type DeletePorterAppByNameHandler struct {
@@ -30,26 +33,47 @@ func NewDeletePorterAppByNameHandler(
 }
 
 func (c *DeletePorterAppByNameHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ctx := r.Context()
-	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+	ctx, span := telemetry.NewSpan(r.Context(), "server-delete-porter-app-by-name")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
 	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
 	if reqErr != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(reqErr, http.StatusBadRequest))
+		err := telemetry.Error(ctx, span, reqErr, "error parsing porter app name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
 
-	porterApp, appErr := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, appName)
-	if appErr != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(appErr))
+	if appName == "" {
+		err := telemetry.Error(ctx, span, nil, "porter app name cannot be empty")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
 
-	delApp, delErr := c.Repo().PorterApp().DeletePorterApp(porterApp)
-	if delErr != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(delErr))
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-name", Value: appName})
+
+	deleteReq := connect.NewRequest[porterv1.DeletePorterAppRequest](&porterv1.DeletePorterAppRequest{
+		ProjectId: int64(project.ID),
+		AppName:   appName,
+	})
+	ccpResp, err := c.Config().ClusterControlPlaneClient.DeletePorterApp(r.Context(), deleteReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error deleting porter app")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if ccpResp == nil {
+		err := telemetry.Error(ctx, span, err, "ccp resp is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if ccpResp.Msg == nil {
+		err := telemetry.Error(ctx, span, err, "ccp resp msg is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	c.WriteResult(w, r, delApp)
+	c.WriteResult(w, r, ccpResp.Msg)
 }

+ 7 - 7
dashboard/package-lock.json

@@ -13,7 +13,7 @@
         "@loadable/component": "^5.15.2",
         "@material-ui/core": "^4.11.3",
         "@material-ui/lab": "^4.0.0-alpha.61",
-        "@porter-dev/api-contracts": "^0.0.93",
+        "@porter-dev/api-contracts": "^0.0.95",
         "@react-spring/web": "^9.6.1",
         "@sentry/react": "^6.13.2",
         "@sentry/tracing": "^6.13.2",
@@ -2454,9 +2454,9 @@
       }
     },
     "node_modules/@porter-dev/api-contracts": {
-      "version": "0.0.93",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.93.tgz",
-      "integrity": "sha512-BPJKvCNUXsVGw2rp3SC04fp6lYRTliEdxxORs/SxbxkQOysZxs21K/lAHmti7LIlqyFStil+g+gKyTCQSyWagg==",
+      "version": "0.0.95",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.95.tgz",
+      "integrity": "sha512-nwbpfyv5qvhjKdHU7fnR3S6+E9ijwm3/OtZ+WCItn1JZNrDZtb2x047AkBndVU6NKDtUnxHYGYwQJo5spAw7cQ==",
       "dependencies": {
         "@bufbuild/protobuf": "^1.1.0"
       }
@@ -16943,9 +16943,9 @@
       "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
     },
     "@porter-dev/api-contracts": {
-      "version": "0.0.93",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.93.tgz",
-      "integrity": "sha512-BPJKvCNUXsVGw2rp3SC04fp6lYRTliEdxxORs/SxbxkQOysZxs21K/lAHmti7LIlqyFStil+g+gKyTCQSyWagg==",
+      "version": "0.0.95",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.95.tgz",
+      "integrity": "sha512-nwbpfyv5qvhjKdHU7fnR3S6+E9ijwm3/OtZ+WCItn1JZNrDZtb2x047AkBndVU6NKDtUnxHYGYwQJo5spAw7cQ==",
       "requires": {
         "@bufbuild/protobuf": "^1.1.0"
       }

+ 1 - 1
dashboard/package.json

@@ -8,7 +8,7 @@
     "@loadable/component": "^5.15.2",
     "@material-ui/core": "^4.11.3",
     "@material-ui/lab": "^4.0.0-alpha.61",
-    "@porter-dev/api-contracts": "^0.0.93",
+    "@porter-dev/api-contracts": "^0.0.95",
     "@react-spring/web": "^9.6.1",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",

+ 12 - 2
dashboard/src/lib/hooks/useAppAnalytics.ts

@@ -5,12 +5,21 @@ import api from "shared/api";
 type AppStep =
   | "stack-launch-complete"
   | "stack-launch-success"
-  | "stack-launch-failure";
+  | "stack-launch-failure"
+  | "stack-deletion";
 
 export const useAppAnalytics = (appName: string) => {
   const { currentCluster, currentProject } = useContext(Context);
 
-  const updateAppStep = async (step: AppStep, errorMessage: string = "") => {
+  const updateAppStep = async ({
+    step,
+    errorMessage = "",
+    deleteWorkflow = false,
+  }: {
+    step: AppStep;
+    errorMessage?: string;
+    deleteWorkflow?: boolean;
+  }) => {
     try {
       if (!currentCluster?.id || !currentProject?.id) {
         return;
@@ -21,6 +30,7 @@ export const useAppAnalytics = (appName: string) => {
           step,
           stack_name: appName,
           error_message: errorMessage,
+          delete_workflow_file: deleteWorkflow,
         },
         {
           cluster_id: currentCluster.id,

+ 7 - 0
dashboard/src/lib/hooks/usePorterYaml.ts

@@ -139,6 +139,13 @@ export const usePorterYaml = ({
     }
   }, [data]);
 
+  if (source?.type !== "github") {
+    return {
+      loading: false,
+      detectedServices: null,
+    };
+  }
+
   if (status === "loading") {
     return {
       loading: true,

+ 63 - 24
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -14,6 +14,10 @@ import TabSelector from "components/TabSelector";
 import { useHistory } from "react-router";
 import { match } from "ts-pattern";
 import Overview from "./tabs/Overview";
+import { useAppValidation } from "lib/hooks/useAppValidation";
+import api from "shared/api";
+import { useQueryClient } from "@tanstack/react-query";
+import Settings from "./tabs/Settings";
 
 // commented out tabs are not yet implemented
 // will be included as support is available based on data from app revisions rather than helm releases
@@ -39,6 +43,7 @@ type AppDataContainerProps = {
 
 const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
   const history = useHistory();
+  const queryClient = useQueryClient();
   const {
     porterApp,
     latestProto,
@@ -48,6 +53,9 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
     deploymentTargetId,
     servicesFromYaml,
   } = useLatestRevision();
+  const { validateApp } = useAppValidation({
+    deploymentTargetID: deploymentTargetId,
+  });
 
   const currentTab = useMemo(() => {
     if (tabParam && validTabs.includes(tabParam as ValidTab)) {
@@ -86,7 +94,32 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
       source: latestSource,
     },
   });
-  const { reset } = porterAppFormMethods;
+  const { reset, handleSubmit } = porterAppFormMethods;
+
+  const onSubmit = handleSubmit(async (data) => {
+    try {
+      const validatedAppProto = await validateApp(data);
+      await api.applyApp(
+        "<token>",
+        {
+          b64_app_proto: btoa(validatedAppProto.toJsonString()),
+          deployment_target_id: deploymentTargetId,
+        },
+        {
+          project_id: projectId,
+          cluster_id: clusterId,
+        }
+      );
+
+      await queryClient.invalidateQueries([
+        "getLatestRevision",
+        projectId,
+        clusterId,
+        deploymentTargetId,
+        porterApp.name,
+      ]);
+    } catch (err) {}
+  });
 
   useEffect(() => {
     if (servicesFromYaml) {
@@ -95,32 +128,38 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         source: latestSource,
       });
     }
-  }, [servicesFromYaml]);
+  }, [servicesFromYaml, currentTab]);
 
   return (
     <FormProvider {...porterAppFormMethods}>
-      <RevisionsList
-        latestRevisionNumber={latestRevision.revision_number}
-        deploymentTargetId={deploymentTargetId}
-        projectId={projectId}
-        clusterId={clusterId}
-        appName={porterApp.name}
-        sourceType={latestSource.type}
-      />
-      <Spacer y={1} />
-      <TabSelector
-        noBuffer
-        options={[{ label: "Overview", value: "overview" }]}
-        currentTab={currentTab}
-        setCurrentTab={() => {
-          history.push(`/apps/${porterApp.name}/${currentTab}`);
-        }}
-      />
-      <Spacer y={1} />
-      {match(currentTab)
-        .with("overview", () => <Overview />)
-        .otherwise(() => null)}
-      <Spacer y={2} />
+      <form onSubmit={onSubmit}>
+        <RevisionsList
+          latestRevisionNumber={latestRevision.revision_number}
+          deploymentTargetId={deploymentTargetId}
+          projectId={projectId}
+          clusterId={clusterId}
+          appName={porterApp.name}
+          sourceType={latestSource.type}
+        />
+        <Spacer y={1} />
+        <TabSelector
+          noBuffer
+          options={[
+            { label: "Overview", value: "overview" },
+            { label: "Settings", value: "settings" },
+          ]}
+          currentTab={currentTab}
+          setCurrentTab={(tab) => {
+            history.push(`/apps/${porterApp.name}/${tab}`);
+          }}
+        />
+        <Spacer y={1} />
+        {match(currentTab)
+          .with("overview", () => <Overview />)
+          .with("settings", () => <Settings />)
+          .otherwise(() => null)}
+        <Spacer y={2} />
+      </form>
     </FormProvider>
   );
 };

+ 20 - 14
dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx

@@ -11,9 +11,11 @@ import {
 } from "lib/porter-apps/services";
 import Error from "components/porter/Error";
 import Button from "components/porter/Button";
+import { useLatestRevision } from "../LatestRevisionContext";
 
 const Overview: React.FC = () => {
   const { formState } = useFormContext<PorterAppFormData>();
+  const { porterApp } = useLatestRevision();
 
   const buttonStatus = useMemo(() => {
     if (formState.isSubmitting) {
@@ -29,20 +31,24 @@ const Overview: React.FC = () => {
 
   return (
     <>
-      <Text size={16}>Pre-deploy job</Text>
-      <Spacer y={0.5} />
-      <ServiceList
-        limitOne={true}
-        addNewText={"Add a new pre-deploy job"}
-        prePopulateService={deserializeService({
-          service: defaultSerialized({
-            name: "pre-deploy",
-            type: "predeploy",
-          }),
-        })}
-        isPredeploy
-      />
-      <Spacer y={0.5} />
+      {porterApp.git_repo_id && (
+        <>
+          <Text size={16}>Pre-deploy job</Text>
+          <Spacer y={0.5} />
+          <ServiceList
+            limitOne={true}
+            addNewText={"Add a new pre-deploy job"}
+            prePopulateService={deserializeService({
+              service: defaultSerialized({
+                name: "pre-deploy",
+                type: "predeploy",
+              }),
+            })}
+            isPredeploy
+          />
+          <Spacer y={0.5} />
+        </>
+      )}
       <Text size={16}>Application services</Text>
       <Spacer y={0.5} />
       <ServiceList addNewText={"Add a new service"} />

+ 140 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/Settings.tsx

@@ -0,0 +1,140 @@
+import React, { useCallback, useState } from "react";
+import styled from "styled-components";
+import { useHistory } from "react-router";
+
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import Button from "components/porter/Button";
+import DeleteApplicationModal from "../../expanded-app/DeleteApplicationModal";
+
+import { useLatestRevision } from "../LatestRevisionContext";
+import api from "shared/api";
+import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
+
+const Settings: React.FC = () => {
+  const history = useHistory();
+  const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+  const { porterApp, clusterId, projectId } = useLatestRevision();
+  const { updateAppStep } = useAppAnalytics(porterApp.name);
+
+  const githubWorkflowFilename = `porter_stack_${porterApp.name}.yml`;
+
+  const workflowFileExists = useCallback(async () => {
+    try {
+      if (
+        !porterApp.git_branch ||
+        !porterApp.repo_name ||
+        !porterApp.git_repo_id
+      ) {
+        return false;
+      }
+
+      await api.getBranchContents(
+        "<token>",
+        {
+          dir: `./.github/workflows/porter_stack_${porterApp.name}.yml`,
+        },
+        {
+          project_id: projectId,
+          git_repo_id: porterApp.git_repo_id,
+          kind: "github",
+          owner: porterApp.repo_name.split("/")[0],
+          name: porterApp.repo_name.split("/")[1],
+          branch: porterApp.git_branch,
+        }
+      );
+
+      return true;
+    } catch (err) {
+      return false;
+    }
+  }, [githubWorkflowFilename, porterApp.name, clusterId, projectId]);
+
+  const onDelete = useCallback(
+    async (deleteWorkflow?: boolean) => {
+      try {
+        await api.deletePorterApp(
+          "<token>",
+          {},
+          {
+            cluster_id: clusterId,
+            project_id: projectId,
+            name: porterApp.name,
+          }
+        );
+
+        if (!deleteWorkflow) {
+          return;
+        }
+
+        const exists = await workflowFileExists();
+        if (
+          exists &&
+          porterApp.git_branch &&
+          porterApp.repo_name &&
+          porterApp.git_repo_id
+        ) {
+          const res = await api.createSecretAndOpenGitHubPullRequest(
+            "<token>",
+            {
+              github_app_installation_id: porterApp.git_repo_id,
+              github_repo_owner: porterApp.repo_name.split("/")[0],
+              github_repo_name: porterApp.repo_name.split("/")[1],
+              branch: porterApp.git_branch,
+              delete_workflow_filename: githubWorkflowFilename,
+            },
+            {
+              project_id: projectId,
+              cluster_id: clusterId,
+              stack_name: porterApp.name,
+            }
+          );
+          if (res.data?.url) {
+            window.open(res.data.url, "_blank", "noreferrer");
+          }
+
+          updateAppStep({ step: "stack-deletion", deleteWorkflow: true });
+          history.push("/apps");
+          return;
+        }
+
+        updateAppStep({ step: "stack-deletion", deleteWorkflow: false });
+        history.push("/apps");
+      } catch (err) {}
+    },
+    [githubWorkflowFilename, porterApp.name, clusterId, projectId]
+  );
+
+  return (
+    <StyledSettingsTab>
+      <Text size={16}>Delete "{porterApp.name}"</Text>
+      <Spacer y={1} />
+      <Text color="helper">
+        Delete this application and all of its resources.
+      </Text>
+      <Spacer y={1} />
+      <Button
+        type="button"
+        onClick={() => {
+          setIsDeleteModalOpen(true);
+        }}
+        color="#b91133"
+      >
+        Delete
+      </Button>
+      {isDeleteModalOpen && (
+        <DeleteApplicationModal
+          closeModal={() => setIsDeleteModalOpen(false)}
+          githubWorkflowFilename={githubWorkflowFilename}
+          deleteApplication={onDelete}
+        />
+      )}
+    </StyledSettingsTab>
+  );
+};
+
+export default Settings;
+
+const StyledSettingsTab = styled.div`
+  width: 100%;
+`;

+ 7 - 4
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -166,7 +166,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
     }) => {
       setIsDeploying(true);
       // log analytics event that we started form submission
-      updateAppStep("stack-launch-complete");
+      updateAppStep({ step: "stack-launch-complete" });
 
       try {
         if (!currentProject?.id || !currentCluster?.id) {
@@ -202,7 +202,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
         );
 
         // log analytics event that we successfully deployed
-        updateAppStep("stack-launch-success");
+        updateAppStep({ step: "stack-launch-success" });
 
         if (source.type === "docker-registry") {
           history.push(`/apps/${app.name}`);
@@ -211,14 +211,17 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
         return true;
       } catch (err) {
         if (axios.isAxiosError(err) && err.response?.data?.error) {
-          updateAppStep("stack-launch-failure", err.response?.data?.error);
+          updateAppStep({
+            step: "stack-launch-failure",
+            errorMessage: err.response?.data?.error,
+          });
           setDeployError(err.response?.data?.error);
           return false;
         }
 
         const msg =
           "An error occurred while deploying your application. Please try again.";
-        updateAppStep("stack-launch-failure", msg);
+        updateAppStep({ step: "stack-launch-failure", errorMessage: msg });
         setDeployError(msg);
         return false;
       } finally {

+ 1 - 1
go.mod

@@ -79,7 +79,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.0.93
+	github.com/porter-dev/api-contracts v0.0.95
 	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

+ 2 - 2
go.sum

@@ -1489,8 +1489,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.0.93 h1:RuPDe64q7D4/IvrofWRAbiWWT3v96TqCeU3kJXAxIIU=
-github.com/porter-dev/api-contracts v0.0.93/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.0.95 h1:at2td0mo5zEFJljAmDjBIiTZUvSqua41RU9q+jFCSNE=
+github.com/porter-dev/api-contracts v0.0.95/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 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=