Explorar o código

prompt users to delete their workflow files if they are deleting a stack (#3307)

Feroze Mohideen %!s(int64=2) %!d(string=hai) anos
pai
achega
167e72a85f

+ 1 - 0
api/server/handlers/porter_app/analytics.go

@@ -87,6 +87,7 @@ func (v *PorterAppAnalyticsHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 			FirstName:              user.FirstName,
 			LastName:               user.LastName,
 			CompanyName:            user.CompanyName,
+			DeleteWorkflowFile:     request.DeleteWorkflowFile,
 		}))
 	}
 

+ 58 - 46
api/server/handlers/porter_app/create_secret_and_open_pr.go

@@ -54,46 +54,56 @@ func (c *OpenStackPRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	// generate porter jwt token
-	jwt, err := token.GetTokenForAPI(user.ID, project.ID)
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting token for API: %w", err)))
-		return
-	}
-	encoded, err := jwt.EncodeToken(c.Config().TokenConf)
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error encoding API token: %w", err)))
-		return
-	}
+	var secretName string
+	if request.DeleteWorkflowFilename == "" {
+		// generate porter jwt token
+		jwt, err := token.GetTokenForAPI(user.ID, project.ID)
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting token for API: %w", err)))
+			return
+		}
+		encoded, err := jwt.EncodeToken(c.Config().TokenConf)
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error encoding API token: %w", err)))
+			return
+		}
 
-	// create porter secret
-	secretName := fmt.Sprintf("PORTER_STACK_%d_%d", project.ID, cluster.ID)
-	err = actions.CreateGithubSecret(
-		client,
-		secretName,
-		encoded,
-		request.GithubRepoOwner,
-		request.GithubRepoName,
-	)
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error generating secret: %w", err)))
-		return
+		// create porter secret
+		secretName = fmt.Sprintf("PORTER_STACK_%d_%d", project.ID, cluster.ID)
+		err = actions.CreateGithubSecret(
+			client,
+			secretName,
+			encoded,
+			request.GithubRepoOwner,
+			request.GithubRepoName,
+		)
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error generating secret: %w", err)))
+			return
+		}
 	}
 
 	var pr *github.PullRequest
-	if request.OpenPr {
+	var prRequestBody string
+	if request.DeleteWorkflowFilename == "" {
+		prRequestBody = "Hello 👋 from Porter! Please merge this PR to finish setting up your application."
+	} else {
+		prRequestBody = "Please merge this PR to delete the workflow file associated with your application."
+	}
+	if request.OpenPr || request.DeleteWorkflowFilename != "" {
 		pr, err = actions.OpenGithubPR(&actions.GithubPROpts{
-			Client:         client,
-			GitRepoOwner:   request.GithubRepoOwner,
-			GitRepoName:    request.GithubRepoName,
-			StackName:      stackName,
-			ProjectID:      project.ID,
-			ClusterID:      cluster.ID,
-			ServerURL:      c.Config().ServerConf.ServerURL,
-			DefaultBranch:  request.Branch,
-			SecretName:     secretName,
-			PorterYamlPath: request.PorterYamlPath,
-			Body:           "Hello 👋 from Porter! Please merge this PR to finish setting up your application.",
+			Client:                 client,
+			GitRepoOwner:           request.GithubRepoOwner,
+			GitRepoName:            request.GithubRepoName,
+			StackName:              stackName,
+			ProjectID:              project.ID,
+			ClusterID:              cluster.ID,
+			ServerURL:              c.Config().ServerConf.ServerURL,
+			DefaultBranch:          request.Branch,
+			SecretName:             secretName,
+			PorterYamlPath:         request.PorterYamlPath,
+			Body:                   prRequestBody,
+			DeleteWorkflowFilename: request.DeleteWorkflowFilename,
 		})
 	}
 
@@ -119,19 +129,21 @@ func (c *OpenStackPRHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			URL: pr.GetHTMLURL(),
 		}
 
-		// update DB with the PR url
-		porterApp, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, stackName)
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("unable to get porter app db: %w", err)))
-			return
-		}
+		if request.DeleteWorkflowFilename == "" {
+			// update DB with the PR url
+			porterApp, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, stackName)
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("unable to get porter app db: %w", err)))
+				return
+			}
 
-		porterApp.PullRequestURL = pr.GetHTMLURL()
+			porterApp.PullRequestURL = pr.GetHTMLURL()
 
-		_, err = c.Repo().PorterApp().UpdatePorterApp(porterApp)
-		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("unable to write pr url to porter app db: %w", err)))
-			return
+			_, err = c.Repo().PorterApp().UpdatePorterApp(porterApp)
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("unable to write pr url to porter app db: %w", err)))
+				return
+			}
 		}
 	}
 

+ 5 - 3
api/types/stack.go

@@ -18,6 +18,7 @@ type CreateSecretAndOpenGHPRRequest struct {
 	OpenPr                  bool   `json:"open_pr"`
 	Branch                  string `json:"branch"`
 	PorterYamlPath          string `json:"porter_yaml_path"`
+	DeleteWorkflowFilename  string `json:"delete_workflow_filename"`
 }
 
 type CreateSecretAndOpenGHPRResponse struct {
@@ -27,7 +28,8 @@ type CreateSecretAndOpenGHPRResponse struct {
 type GetStackResponse PorterApp
 
 type PorterAppAnalyticsRequest struct {
-	Step         string `json:"step" form:"required,max=255"`
-	StackName    string `json:"stack_name"`
-	ErrorMessage string `json:"error_message"`
+	Step               string `json:"step" form:"required,max=255"`
+	StackName          string `json:"stack_name"`
+	ErrorMessage       string `json:"error_message"`
+	DeleteWorkflowFile bool   `json:"delete_workflow_file"`
 }

+ 67 - 0
dashboard/src/main/home/app-dashboard/expanded-app/DeleteApplicationModal.tsx

@@ -0,0 +1,67 @@
+import Button from "components/porter/Button";
+import Checkbox from "components/porter/Checkbox";
+import Modal from "components/porter/Modal";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+import React, { useState } from "react";
+import styled from "styled-components";
+
+type Props = {
+    closeModal: () => void;
+    githubWorkflowFilename: string;
+    deleteApplication: (deleteWorkflowFile?: boolean) => void;
+};
+
+const GithubActionModal: React.FC<Props> = ({
+    closeModal,
+    githubWorkflowFilename,
+    deleteApplication,
+}) => {
+    const [deleteGithubWorkflow, setDeleteGithubWorkflow] = useState(true);
+
+    const renderDeleteGithubWorkflowText = () => {
+        if (githubWorkflowFilename === "") {
+            return null;
+        }
+        return (
+            <>
+                <Text color="helper">You may also want to remove this application's associated CI file from your repository.</Text>
+                <Spacer y={0.5} />
+                <Checkbox
+                    checked={deleteGithubWorkflow}
+                    toggleChecked={() => setDeleteGithubWorkflow(!deleteGithubWorkflow)}
+                >
+                    <Text color="helper">
+                        Upon deletion, open a PR to remove this application's associated CI file (<Code>{githubWorkflowFilename}</Code>) from my repository.
+                    </Text>
+                </Checkbox>
+                <Spacer y={1} />
+            </>
+        )
+    }
+
+    return (
+        <Modal closeModal={closeModal}>
+            <Text size={16}>
+                Confirm deletion
+            </Text>
+            <Spacer y={0.5} />
+            <Text color="helper">Click the button below to confirm deletion. This action is irreversible.</Text>
+            <Spacer y={0.5} />
+            {renderDeleteGithubWorkflowText()}
+            <Button
+                onClick={() => deleteApplication(deleteGithubWorkflow)}
+                color="#b91133"
+            >
+                Delete
+            </Button>
+        </Modal>
+    );
+};
+
+export default GithubActionModal;
+
+const Code = styled.span`
+  font-family: monospace;
+`;

+ 59 - 53
dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx

@@ -47,8 +47,8 @@ import AnimateHeight from "react-animate-height";
 import { PartialEnvGroup, PopulatedEnvGroup } from "../../../../components/porter-form/types";
 import { BuildMethod, PorterApp } from "../types/porterApp";
 import HelmValuesTab from "./HelmValuesTab";
-import ProjectDeleteConsent from "main/home/project-settings/ProjectDeleteConsent";
 import PorterAppRevisionSection from "./PorterAppRevisionSection";
+import SettingsTab from "./SettingsTab";
 
 type Props = RouteComponentProps & {};
 
@@ -91,6 +91,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   const [workflowCheckPassed, setWorkflowCheckPassed] = useState<boolean>(
     false
   );
+  const [githubWorkflowFilename, setGithubWorkflowFilename] = useState<string>("");
   const [hasBuiltImage, setHasBuiltImage] = useState<boolean>(false);
 
   const [forceRefreshRevisions, setForceRefreshRevisions] = useState<boolean>(
@@ -98,7 +99,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
   );
 
   const [showRevisions, setShowRevisions] = useState<boolean>(false);
-  const [showDeleteOverlay, setShowDeleteOverlay] = useState<boolean>(false);
 
   // this is what we read from their porter.yaml in github
   const [porterJson, setPorterJson] = useState<PorterJson | undefined>(undefined);
@@ -273,12 +273,13 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
             }
           );
           setWorkflowCheckPassed(true);
+          setGithubWorkflowFilename(`porter_stack_${resPorterApp.data.name}.yml`);
         } catch (err) {
           // Handle unmerged PR
           if (err.response?.status === 404) {
             try {
               // Check for user-copied porter.yml as fallback
-              const resPorterYml = await api.getBranchContents(
+              await api.getBranchContents(
                 "<token>",
                 { dir: `./.github/workflows/porter.yml` },
                 {
@@ -291,6 +292,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                 }
               );
               setWorkflowCheckPassed(true);
+              setGithubWorkflowFilename(`porter.yml`);
             } catch (err) {
               setWorkflowCheckPassed(false);
             }
@@ -304,8 +306,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
     }
   };
 
-  const deletePorterApp = async () => {
-    setShowDeleteOverlay(false);
+  const deletePorterApp = async (deleteGHWorkflowFile?: boolean) => {
     setDeleting(true);
     const { appName } = props.match.params as any;
     if (syncedEnvGroups) {
@@ -340,6 +341,10 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           name: appName,
         }
       );
+    } catch (err) {
+      // TODO: handle error
+    }
+    try {
       await api.deleteNamespace(
         "<token>",
         {},
@@ -349,24 +354,53 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           namespace: `porter-stack-${appName}`,
         }
       );
-      // intentionally do not await this promise
-      api.updateStackStep(
-        "<token>",
-        {
-          step: "stack-deletion",
-          stack_name: appName,
-        },
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-        }
-      );
-      props.history.push("/apps");
     } catch (err) {
       // TODO: handle error
-    } finally {
-      setDeleting(false);
     }
+
+    let deleteWorkflowFile = false;
+
+    if (deleteGHWorkflowFile && githubWorkflowFilename !== "" && appData?.app != null) {
+      try {
+        const res = await api.createSecretAndOpenGitHubPullRequest(
+          "<token>",
+          {
+            github_app_installation_id: appData.app.git_repo_id,
+            github_repo_owner: appData.app.repo_name.split("/")[0],
+            github_repo_name: appData.app.repo_name.split("/")[1],
+            branch: appData.app.git_branch,
+            delete_workflow_filename: githubWorkflowFilename,
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+            stack_name: appData.app.name,
+          }
+        );
+        if (res.data?.url) {
+          window.open(res.data.url, "_blank", "noreferrer");
+        }
+        deleteWorkflowFile = true;
+      } catch (err) {
+        // TODO: handle error
+      }
+    }
+
+    // intentionally do not await this promise
+    api.updateStackStep(
+      "<token>",
+      {
+        step: "stack-deletion",
+        stack_name: appName,
+        delete_workflow_file: deleteWorkflowFile,
+      },
+      {
+        project_id: currentProject.id,
+        cluster_id: currentCluster.id,
+      }
+    );
+
+    props.history.push("/apps");
   };
 
   const updatePorterApp = async (options: Partial<CreateUpdatePorterAppOptions>) => {
@@ -705,24 +739,11 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           />
         );
       case "settings":
-        return (
-          <>
-            <Text size={16}>Delete "{appData.app.name}"</Text>
-            <Spacer y={1} />
-            <Text color="helper">
-              Delete this application and all of its resources.
-            </Text>
-            <Spacer y={1} />
-            <Button
-              onClick={() => {
-                setShowDeleteOverlay(true);
-              }}
-              color="#b91133"
-            >
-              Delete
-            </Button>
-          </>
-        );
+        return <SettingsTab
+          appName={appData.app.name}
+          githubWorkflowFilename={githubWorkflowFilename}
+          deleteApplication={deletePorterApp}
+        />;
       case "logs":
         return <LogSection currentChart={appData.chart} services={services} />;
       case "metrics":
@@ -991,17 +1012,6 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
           )}
         </StyledExpandedApp>
       )}
-      {showDeleteOverlay && (
-        <ConfirmOverlay
-          message={`Are you sure you want to delete "${appData.app.name}"?`}
-          onYes={() => {
-            deletePorterApp();
-          }}
-          onNo={() => {
-            setShowDeleteOverlay(false);
-          }}
-        />
-      )}
     </>
   );
 };
@@ -1013,10 +1023,6 @@ const A = styled.a`
   align-items: center;
 `;
 
-const Underline = styled.div`
-  border-bottom: 1px solid #ffffff;
-`;
-
 const RefreshButton = styled.div`
   color: #ffffff;
   display: flex;

+ 57 - 0
dashboard/src/main/home/app-dashboard/expanded-app/SettingsTab.tsx

@@ -0,0 +1,57 @@
+import Button from "components/porter/Button";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+import DeleteApplicationModal from "./DeleteApplicationModal";
+
+type Props = {
+    appName: string;
+    githubWorkflowFilename: string;
+    deleteApplication: (deleteWorkflowFile?: boolean) => void;
+};
+
+const SettingsTab: React.FC<Props> = ({
+    appName,
+    githubWorkflowFilename,
+    deleteApplication
+}) => {
+    const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+
+    useEffect(() => {
+        // Do something
+    }, []);
+
+    return (
+        <StyledSettingsTab>
+            <Text size={16}>Delete "{appName}"</Text>
+            <Spacer y={1} />
+            <Text color="helper">
+                Delete this application and all of its resources.
+            </Text>
+            <Spacer y={1} />
+            <Button
+                onClick={() => {
+                    setIsDeleteModalOpen(true);
+                }}
+                color="#b91133"
+            >
+                Delete
+            </Button>
+            {isDeleteModalOpen &&
+                <DeleteApplicationModal
+                    closeModal={() => setIsDeleteModalOpen(false)}
+                    githubWorkflowFilename={githubWorkflowFilename}
+                    deleteApplication={deleteApplication}
+                />
+            }
+        </StyledSettingsTab>
+    );
+};
+
+export default SettingsTab;
+
+const StyledSettingsTab = styled.div`
+width: 100%;
+`;

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

@@ -2467,6 +2467,7 @@ const updateStackStep = baseApi<
     step: string;
     stack_name?: string;
     error_message?: string;
+    delete_workflow_file?: boolean;
   },
   {
     project_id: number;
@@ -2657,9 +2658,10 @@ const createSecretAndOpenGitHubPullRequest = baseApi<
     github_app_installation_id: number;
     github_repo_owner: string;
     github_repo_name: string;
-    open_pr: boolean;
     branch: string;
-    porter_yaml_path: string;
+    open_pr?: boolean;
+    porter_yaml_path?: string;
+    delete_workflow_filename?: string;
   },
   {
     project_id: number;

+ 7 - 5
internal/analytics/tracks.go

@@ -834,11 +834,12 @@ func StackLaunchFailureTrack(opts *StackLaunchFailureOpts) segmentTrack {
 type StackDeletionOpts struct {
 	*ProjectScopedTrackOpts
 
-	StackName   string
-	Email       string
-	FirstName   string
-	LastName    string
-	CompanyName string
+	StackName          string
+	Email              string
+	FirstName          string
+	LastName           string
+	CompanyName        string
+	DeleteWorkflowFile bool
 }
 
 // StackDeletionTrack returns a track for when a user deletes a stack
@@ -848,6 +849,7 @@ func StackDeletionTrack(opts *StackDeletionOpts) segmentTrack {
 	additionalProps["email"] = opts.Email
 	additionalProps["name"] = opts.FirstName + " " + opts.LastName
 	additionalProps["company"] = opts.CompanyName
+	additionalProps["delete_workflow_file"] = opts.DeleteWorkflowFile
 
 	return getSegmentProjectTrack(
 		opts.ProjectScopedTrackOpts,

+ 56 - 27
internal/integrations/ci/actions/stack.go

@@ -20,6 +20,7 @@ type GithubPROpts struct {
 	SecretName                string
 	PorterYamlPath            string
 	Body                      string
+	DeleteWorkflowFilename    string
 }
 
 type GetStackApplyActionYAMLOpts struct {
@@ -33,26 +34,19 @@ type GetStackApplyActionYAMLOpts struct {
 
 func OpenGithubPR(opts *GithubPROpts) (*github.PullRequest, error) {
 	var pr *github.PullRequest
-	applyWorkflowYAML, err := getStackApplyActionYAML(&GetStackApplyActionYAMLOpts{
-		ServerURL:      opts.ServerURL,
-		ClusterID:      opts.ClusterID,
-		ProjectID:      opts.ProjectID,
-		StackName:      opts.StackName,
-		DefaultBranch:  opts.DefaultBranch,
-		SecretName:     opts.SecretName,
-		PorterYamlPath: opts.PorterYamlPath,
-	})
-	if err != nil {
-		return pr, err
+	var prBranchName string
+	if opts.DeleteWorkflowFilename != "" {
+		prBranchName = "porter-stack-delete"
+	} else {
+		prBranchName = "porter-stack"
 	}
 
-	prBranchName := "porter-stack"
-
-	err = createNewBranch(opts.Client,
+	err := createNewBranch(opts.Client,
 		opts.GitRepoOwner,
 		opts.GitRepoName,
 		opts.DefaultBranch,
-		prBranchName)
+		prBranchName,
+	)
 	if err != nil {
 		return pr, fmt.Errorf(
 			"error creating branch: %w",
@@ -60,23 +54,58 @@ func OpenGithubPR(opts *GithubPROpts) (*github.PullRequest, error) {
 		)
 	}
 
-	_, err = commitWorkflowFile(
-		opts.Client,
-		fmt.Sprintf("porter_stack_%s.yml", strings.ToLower(opts.StackName)),
-		applyWorkflowYAML, opts.GitRepoOwner,
-		opts.GitRepoName, prBranchName, false,
-	)
-
-	if err != nil {
-		return pr, fmt.Errorf(
-			"error committing file: %w",
-			err,
+	if opts.DeleteWorkflowFilename == "" {
+		applyWorkflowYAML, err := getStackApplyActionYAML(&GetStackApplyActionYAMLOpts{
+			ServerURL:      opts.ServerURL,
+			ClusterID:      opts.ClusterID,
+			ProjectID:      opts.ProjectID,
+			StackName:      opts.StackName,
+			DefaultBranch:  opts.DefaultBranch,
+			SecretName:     opts.SecretName,
+			PorterYamlPath: opts.PorterYamlPath,
+		})
+		if err != nil {
+			return pr, err
+		}
+		_, err = commitWorkflowFile(
+			opts.Client,
+			fmt.Sprintf("porter_stack_%s.yml", strings.ToLower(opts.StackName)),
+			applyWorkflowYAML, opts.GitRepoOwner,
+			opts.GitRepoName, prBranchName, false,
+		)
+		if err != nil {
+			return pr, fmt.Errorf(
+				"error committing file: %w",
+				err,
+			)
+		}
+	} else {
+		err = deleteGithubFile(
+			opts.Client,
+			opts.DeleteWorkflowFilename,
+			opts.GitRepoOwner,
+			opts.GitRepoName,
+			prBranchName,
+			false,
 		)
+		if err != nil {
+			return pr, fmt.Errorf(
+				"error committing deletion: %w",
+				err,
+			)
+		}
+
 	}
 
+	var prTitle string
+	if opts.DeleteWorkflowFilename != "" {
+		prTitle = fmt.Sprintf("Delete Porter Application %s", opts.StackName)
+	} else {
+		prTitle = fmt.Sprintf("Enable Porter Application %s", opts.StackName)
+	}
 	pr, _, err = opts.Client.PullRequests.Create(
 		context.Background(), opts.GitRepoOwner, opts.GitRepoName, &github.NewPullRequest{
-			Title: github.String("Enable Porter Application"),
+			Title: github.String(prTitle),
 			Base:  github.String(opts.DefaultBranch),
 			Head:  github.String(prBranchName),
 			Body:  github.String(opts.Body),