Procházet zdrojové kódy

Allow functionality for rolling back a porter app release in the deploy event card (#3118)

Feroze Mohideen před 2 roky
rodič
revize
192e693594

+ 14 - 5
api/server/handlers/porter_app/create.go

@@ -228,7 +228,11 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 			return
 		}
 
-		c.createPorterAppEvent(ctx, "SUCCESS", porterApp.ID, 1)
+		_, err = createPorterAppEvent(ctx, "SUCCESS", porterApp.ID, 1, imageInfo.Tag, c.Repo().PorterAppEvent())
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error creating porter app event: %s", err.Error())))
+			return
+		}
 
 		c.WriteResult(w, r, porterApp.ToPorterAppType())
 	} else {
@@ -362,14 +366,18 @@ func (c *CreatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 			return
 		}
 
-		c.createPorterAppEvent(ctx, "SUCCESS", updatedPorterApp.ID, helmRelease.Version+1)
+		_, err = createPorterAppEvent(ctx, "SUCCESS", updatedPorterApp.ID, helmRelease.Version+1, imageInfo.Tag, c.Repo().PorterAppEvent())
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error creating porter app event: %s", err.Error())))
+			return
+		}
 
 		c.WriteResult(w, r, updatedPorterApp.ToPorterAppType())
 	}
 }
 
 // createPorterAppEvent creates an event for use in the activity feed
-func (c *CreatePorterAppHandler) createPorterAppEvent(ctx context.Context, status string, appID uint, revision int) (*models.PorterAppEvent, error) {
+func createPorterAppEvent(ctx context.Context, status string, appID uint, revision int, tag string, repo repository.PorterAppEventRepository) (*models.PorterAppEvent, error) {
 	event := models.PorterAppEvent{
 		ID:                 uuid.New(),
 		Status:             status,
@@ -377,11 +385,12 @@ func (c *CreatePorterAppHandler) createPorterAppEvent(ctx context.Context, statu
 		TypeExternalSource: "KUBERNETES",
 		PorterAppID:        appID,
 		Metadata: map[string]any{
-			"revision": revision,
+			"revision":  revision,
+			"image_tag": tag,
 		},
 	}
 
-	err := c.Repo().PorterAppEvent().CreateEvent(ctx, &event)
+	err := repo.CreateEvent(ctx, &event)
 	if err != nil {
 		return nil, err
 	}

+ 96 - 0
api/server/handlers/porter_app/rollback.go

@@ -0,0 +1,96 @@
+package porter_app
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"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/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+type RollbackPorterAppHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewRollbackPorterAppHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *RollbackPorterAppHandler {
+	return &RollbackPorterAppHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *RollbackPorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-rollback-porter-app")
+	defer span.End()
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.RollbackPorterAppRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	stackName, reqErr := requestutils.GetURLParamString(r, types.URLParamStackName)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, reqErr, "error getting stack name from url")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "stack-name", Value: stackName})
+	namespace := fmt.Sprintf("porter-stack-%s", stackName)
+
+	helmAgent, err := c.GetHelmAgent(ctx, r, cluster, namespace)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting helm agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	helmRelease, err := helmAgent.GetRelease(ctx, stackName, 0, false)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting helm release")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	imageInfo := attemptToGetImageInfoFromRelease(helmRelease.Config)
+	if imageInfo.Tag == "" {
+		err = telemetry.Error(ctx, span, err, "error getting image info from release")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	porterApp, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, stackName)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting porter app")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	err = helmAgent.RollbackRelease(ctx, helmRelease.Name, request.Revision)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error rolling back release")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	_, err = createPorterAppEvent(ctx, "SUCCESS", porterApp.ID, helmRelease.Version+1, imageInfo.Tag, c.Repo().PorterAppEvent())
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error creating porter app event")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+}

+ 29 - 0
api/server/router/porter_app.go

@@ -169,6 +169,35 @@ func getStackRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/{stack}/rollback -> porter_app.NewRollbackPorterAppHandler
+	rollbackPorterAppEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/rollback", relPath, types.URLParamStackName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	rollbackPorterAppHandler := porter_app.NewRollbackPorterAppHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: rollbackPorterAppEndpoint,
+		Handler:  rollbackPorterAppHandler,
+		Router:   r,
+	})
+
 	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/{stack}/pr -> porter_app.NewOpenStackPRHandler
 	createSecretAndOpenGitHubPullRequestEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 4 - 0
api/types/porter_app.go

@@ -58,6 +58,10 @@ type UpdatePorterAppRequest struct {
 	PullRequestURL string `json:"pull_request_url"`
 }
 
+type RollbackPorterAppRequest struct {
+	Revision int `json:"revision" form:"required"`
+}
+
 type ListPorterAppResponse []*PorterApp
 
 // PorterAppEvent represents an event that occurs on a Porter stack during a stacks lifecycle.

+ 12 - 4
dashboard/src/components/porter/Container.tsx

@@ -4,13 +4,17 @@ import styled from "styled-components";
 type Props = {
   children: React.ReactNode;
   row?: boolean;
+  column?: boolean;
   spaced?: boolean;
+  alignItems?: string;
 };
 
 const Container: React.FC<Props> = ({
   children,
   row,
   spaced,
+  column,
+  alignItems,
 }) => {
   const [isExpanded, setIsExpanded] = useState(false);
 
@@ -18,6 +22,8 @@ const Container: React.FC<Props> = ({
     <StyledContainer
       spaced={spaced}
       row={row}
+      column={column}
+      alignItems={alignItems}
     >
       {children}
     </StyledContainer>
@@ -27,11 +33,13 @@ const Container: React.FC<Props> = ({
 export default Container;
 
 const StyledContainer = styled.div<{
-  row: boolean;
-  spaced: boolean;
+  row?: boolean;
+  column?: boolean;
+  spaced?: boolean;
+  alignItems?: string
 }>`
-  display: ${props => props.row ? "flex" : "block"};
+  display: ${props => props.row || props.column ? "flex" : "block"};
   flex-direction: ${props => props.row ? "row" : "column"};
-  align-items: center;
+  align-items: ${props => props.alignItems ? props.alignItems : "center"};
   justify-content: ${props => props.spaced ? "space-between" : "flex-start"};
 `;

+ 66 - 22
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/DeployEventCard.tsx

@@ -1,8 +1,8 @@
 import React, { useEffect, useState } from "react";
 
 
-import run_for from "assets/run_for.png";
 import deploy from "assets/deploy.png";
+import refresh from "assets/refresh.png";
 
 import Text from "components/porter/Text";
 import Container from "components/porter/Container";
@@ -12,6 +12,9 @@ import Modal from "components/porter/Modal";
 import { PorterAppEvent } from "shared/types";
 import { getDuration, getStatusIcon } from './utils';
 import { StyledEventCard } from "./EventCard";
+import styled from "styled-components";
+import Button from "components/porter/Button";
+import api from "shared/api";
 
 type Props = {
   event: PorterAppEvent;
@@ -25,7 +28,7 @@ const DeployEventCard: React.FC<Props> = ({ event, appData }) => {
   const renderStatusText = (event: PorterAppEvent) => {
     switch (event.status) {
       case "SUCCESS":
-        return <Text color="#68BF8B">Deployment succeeded</Text>;
+        return event.metadata.image_tag ? <Text color="#68BF8B">Deployed <Code>{event.metadata.image_tag}</Code></Text> : <Text color="#68BF8B">Deployment successful</Text>;
       case "FAILED":
         return <Text color="#FF6060">Deployment failed</Text>;
       default:
@@ -33,35 +36,76 @@ const DeployEventCard: React.FC<Props> = ({ event, appData }) => {
     }
   };
 
+  const revertToRevision = async (revision: number) => {
+    try {
+      await api
+        .rollbackPorterApp(
+          "<token>",
+          {
+            revision,
+          },
+          {
+            project_id: appData.app.project_id,
+            stack_name: appData.app.name,
+            cluster_id: appData.app.cluster_id,
+          }
+        )
+      window.location.reload();
+    } catch (err) {
+      console.log(err)
+    }
+  }
+
   return (
-    <StyledEventCard>
-      <Container row spaced>
-        <Container row>
-          <Icon height="18px" src={deploy} />
-          <Spacer inline width="10px" />
-          <Text size={14}>Application version no. {event.metadata?.revision}</Text>
+    <StyledEventCard row>
+      <Container column alignItems="flex-start">
+        <Container row spaced>
+          <Container row>
+            <Icon height="18px" src={deploy} />
+            <Spacer inline width="10px" />
+            <Text size={14}>Application version no. {event.metadata?.revision}</Text>
+          </Container>
         </Container>
-        {getDuration(event) !== "0s" && (
+        <Spacer y={0.5} />
+        <Container row spaced>
           <Container row>
-            <Icon height="14px" src={run_for} />
-            <Spacer inline width="6px" />
-            <Text color="helper">{getDuration(event)}</Text>
+            <Icon height="18px" src={getStatusIcon(event.status)} />
+            <Spacer inline width="10px" />
+            {renderStatusText(event)}
           </Container>
-        )}
+        </Container>
       </Container>
-      <Spacer y={1} />
       <Container row spaced>
-        <Container row>
-          <Icon height="18px" src={getStatusIcon(event.status)} />
-          <Spacer inline width="10px" />
-          {renderStatusText(event)}
-        </Container>
+        <RevertButton onClick={() => revertToRevision(event.metadata.revision)}>
+          <Icon src={refresh} height={"13px"} />
+          <Spacer inline width="6px" />
+          <Text>Revert</Text>
+        </RevertButton>
       </Container>
-      {showModal && (
-        <Modal closeModal={() => setShowModal(false)}>{modalContent}</Modal>
-      )}
     </StyledEventCard>
   );
 };
 
 export default DeployEventCard;
+
+const Code = styled.span`
+  font-family: monospace;
+`;
+
+const RevertButton = styled.div<{ width?: string }>`
+  border-radius: 5px;
+  height: 30px;
+  font-size: 13px;
+  color: white;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  padding: 0px 10px;
+  background: #ffffff11;
+  border: 1px solid #aaaabb33;
+  cursor: pointer;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+  width: 92px;
+`;

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

@@ -211,6 +211,20 @@ const deletePorterApp = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/stacks/${name}`;
 });
 
+const rollbackPorterApp = baseApi<
+  {
+    revision: number;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    stack_name: string;
+  }
+>("POST", (pathParams) => {
+  let { project_id, cluster_id, stack_name } = pathParams;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/stacks/${stack_name}/rollback`;
+});
+
 const getLogsWithinTimeRange = baseApi<
   {
     chart_name?: string;
@@ -2657,6 +2671,7 @@ export default {
   getPorterApp,
   createPorterApp,
   deletePorterApp,
+  rollbackPorterApp,
   getLogsWithinTimeRange,
   createConfigMap,
   deleteCluster,