Parcourir la source

endpoint for listing app revisions (#3436)

ianedwards il y a 2 ans
Parent
commit
ea8c358ddf

+ 8 - 22
api/server/handlers/porter_app/current_app_revision.go

@@ -1,18 +1,17 @@
 package porter_app
 
 import (
-	"encoding/base64"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
 
 	"connectrpc.com/connect"
 
-	"github.com/porter-dev/api-contracts/generated/go/helpers"
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 
 	"github.com/google/uuid"
 
+	"github.com/porter-dev/porter/internal/porter_app"
 	"github.com/porter-dev/porter/internal/telemetry"
 
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -46,12 +45,8 @@ type LatestAppRevisionRequest struct {
 
 // LatestAppRevisionResponse is the response object for the /apps/{porter_app_name}/latest endpoint
 type LatestAppRevisionResponse struct {
-	// B64AppProto is the base64 encoded app proto definition
-	B64AppProto string `json:"b64_app_proto"`
-	// Status is the status of the revision
-	Status string `json:"status"`
-	// RevisionNumber is the revision number with respect to the app and deployment target
-	RevisionNumber uint64 `json:"revision_number"`
+	// AppRevision is the latest revision for the app
+	AppRevision porter_app.Revision `json:"app_revision"`
 }
 
 // ServeHTTP translates the request into a CurrentAppRevision grpc request, forwards to the cluster control plane, and returns the response.
@@ -134,25 +129,16 @@ func (c *LatestAppRevisionHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	if currentAppRevisionResp.Msg.App == nil {
-		err := telemetry.Error(ctx, span, err, "current app revision definition is nil")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	encoded, err := helpers.MarshalContractObject(ctx, currentAppRevisionResp.Msg.App)
+	appRevision := currentAppRevisionResp.Msg.AppRevision
+	encodedRevision, err := porter_app.EncodedRevisionFromProto(ctx, appRevision)
 	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error marshalling app proto back to json")
+		err := telemetry.Error(ctx, span, err, "error encoding revision from proto")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	b64 := base64.StdEncoding.EncodeToString(encoded)
-
-	response := &LatestAppRevisionResponse{
-		B64AppProto:    b64,
-		Status:         currentAppRevisionResp.Msg.Status,
-		RevisionNumber: currentAppRevisionResp.Msg.RevisionNumber,
+	response := LatestAppRevisionResponse{
+		AppRevision: encodedRevision,
 	}
 
 	c.WriteResult(w, r, response)

+ 134 - 0
api/server/handlers/porter_app/list_app_revisions.go

@@ -0,0 +1,134 @@
+package porter_app
+
+import (
+	"net/http"
+
+	"connectrpc.com/connect"
+	"github.com/google/uuid"
+	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/models"
+	"github.com/porter-dev/porter/internal/porter_app"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// ListAppRevisionsHandler handles requests to the /apps/{porter_app_name}/revisions endpoint
+type ListAppRevisionsHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewListAppRevisionsHandler returns a new ListAppRevisionsHandler
+func NewListAppRevisionsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListAppRevisionsHandler {
+	return &ListAppRevisionsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// ListAppRevisionsRequest represents the response from the /apps/{porter_app_name}/revisions endpoint
+type ListAppRevisionsRequest struct {
+	// The deployment target ID for the revisions
+	DeploymentTargetID string `schema:"deployment_target_id"`
+}
+
+// ListAppRevisionsResponse represents the response from the /apps/{porter_app_name}/revisions endpoint
+type ListAppRevisionsResponse struct {
+	AppRevisions []porter_app.Revision `json:"app_revisions"`
+}
+
+func (c *ListAppRevisionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-app-revisions")
+	defer span.End()
+
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, nil, "error parsing porter app name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "application-name", Value: appName})
+
+	app, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, appName)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error reading porter app by name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if app.ID == 0 {
+		err = telemetry.Error(ctx, span, nil, "app with name does not exist in project")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	request := &ListAppRevisionsRequest{}
+	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
+	}
+
+	deploymentTargetID, err := uuid.Parse(request.DeploymentTargetID)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "invalid deployment target ID")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	if deploymentTargetID == uuid.Nil {
+		err = telemetry.Error(ctx, span, nil, "deployment target ID cannot be nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: deploymentTargetID.String()})
+
+	listAppRevisionsReq := connect.NewRequest(&porterv1.ListAppRevisionsRequest{
+		ProjectId:          int64(project.ID),
+		AppId:              int64(app.ID),
+		DeploymentTargetId: request.DeploymentTargetID,
+	})
+
+	listAppRevisionsResp, err := c.Config().ClusterControlPlaneClient.ListAppRevisions(r.Context(), listAppRevisionsReq)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error listing app revisions")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if listAppRevisionsResp == nil || listAppRevisionsResp.Msg == nil {
+		err = telemetry.Error(ctx, span, nil, "list app revisions response is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	appRevisions := listAppRevisionsResp.Msg.AppRevisions
+	if appRevisions == nil {
+		appRevisions = []*porterv1.AppRevision{}
+	}
+
+	res := &ListAppRevisionsResponse{
+		AppRevisions: make([]porter_app.Revision, 0),
+	}
+
+	for _, revision := range appRevisions {
+		encodedRevision, err := porter_app.EncodedRevisionFromProto(ctx, revision)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error getting encoded revision from proto")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		res.AppRevisions = append(res.AppRevisions, encodedRevision)
+	}
+
+	c.WriteResult(w, r, res)
+}

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

@@ -745,5 +745,34 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/revisions -> porter_app.NewCurrentAppRevisionHandler
+	listAppRevisionsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("/apps/{%s}/revisions", types.URLParamPorterAppName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	listAppRevisionsHandler := porter_app.NewListAppRevisionsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listAppRevisionsEndpoint,
+		Handler:  listAppRevisionsHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 7 - 2
cli/cmd/v2/apply.go

@@ -76,11 +76,16 @@ func Apply(ctx context.Context, cliConf config.CLIConfig, client api.Client, por
 			return fmt.Errorf("error getting current app revision: %w", err)
 		}
 
-		if currentAppRevisionResp.B64AppProto == "" {
+		if currentAppRevisionResp == nil {
+			return errors.New("current app revision is nil")
+		}
+
+		appRevision := currentAppRevisionResp.AppRevision
+		if appRevision.B64AppProto == "" {
 			return errors.New("current app revision b64 app proto is empty")
 		}
 
-		currentImageTag, err := imageTagFromBase64AppProto(currentAppRevisionResp.B64AppProto)
+		currentImageTag, err := imageTagFromBase64AppProto(appRevision.B64AppProto)
 		if err != nil {
 			return fmt.Errorf("error getting image tag from current app revision: %w", err)
 		}

+ 30 - 1
dashboard/src/main/home/app-dashboard/app-view/AppView.tsx

@@ -15,10 +15,12 @@ import web from "assets/web.png";
 import box from "assets/box.png";
 import github from "assets/github-white.png";
 import pr_icon from "assets/pull_request_icon.svg";
+import notFound from "assets/not-found.png";
 
 import Icon from "components/porter/Icon";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
+import Link from "components/porter/Link";
 
 export const porterAppValidator = z.object({
   name: z.string(),
@@ -191,7 +193,18 @@ const AppView: React.FC<Props> = ({ match }) => {
   }
 
   if (status === "error" || porterAppStatus === "error" || !revision) {
-    return <div>error</div>;
+    return (
+      <Placeholder>
+        <Container row>
+          <PlaceholderIcon src={notFound} />
+          <Text color="helper">
+            No application matching "{params.appName}" was found.
+          </Text>
+        </Container>
+        <Spacer y={1} />
+        <Link to="/apps">Return to dashboard</Link>
+      </Placeholder>
+    );
   }
 
   return (
@@ -221,6 +234,8 @@ const AppView: React.FC<Props> = ({ match }) => {
           </>
         )}
       </Container>
+      <Spacer y={0.5} />
+      
     </StyledExpandedApp>
   );
 };
@@ -241,6 +256,20 @@ const StyledExpandedApp = styled.div`
     }
   }
 `;
+const PlaceholderIcon = styled.img`
+  height: 13px;
+  margin-right: 12px;
+  opacity: 0.65;
+`;
+const Placeholder = styled.div`
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  font-size: 13px;
+`;
 const A = styled.a`
   display: flex;
   align-items: center;

+ 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.90
+	github.com/porter-dev/api-contracts v0.0.93
 	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.90 h1:qInnOFj95CXwOSUKw9nde8dHqkOLa2f6GcNY1Pmxbhs=
-github.com/porter-dev/api-contracts v0.0.90/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+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/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=

+ 59 - 0
internal/porter_app/revisions.go

@@ -0,0 +1,59 @@
+package porter_app
+
+import (
+	"context"
+	"encoding/base64"
+	"time"
+
+	"github.com/porter-dev/api-contracts/generated/go/helpers"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// Revision represents the data for a single revision
+type Revision struct {
+	// B64AppProto is the base64 encoded app proto definition
+	B64AppProto string `json:"b64_app_proto"`
+	// Status is the status of the revision
+	Status string `json:"status"`
+	// RevisionNumber is the revision number with respect to the app and deployment target
+	RevisionNumber uint64 `json:"revision_number"`
+	// CreatedAt is the time the revision was created
+	CreatedAt time.Time `json:"created_at"`
+	// UpdatedAt is the time the revision was updated
+	UpdatedAt time.Time `json:"updated_at"`
+}
+
+// EncodedRevisionFromProto converts an AppRevision proto object into a Revision object
+func EncodedRevisionFromProto(ctx context.Context, appRevision *porterv1.AppRevision) (Revision, error) {
+	ctx, span := telemetry.NewSpan(ctx, "encoded-revision-from-proto")
+	defer span.End()
+
+	var revision Revision
+
+	if appRevision == nil {
+		return revision, telemetry.Error(ctx, span, nil, "current app revision definition is nil")
+	}
+
+	appProto := appRevision.App
+	if appProto == nil {
+		return revision, telemetry.Error(ctx, span, nil, "app proto is nil")
+	}
+
+	encoded, err := helpers.MarshalContractObject(ctx, appProto)
+	if err != nil {
+		return revision, telemetry.Error(ctx, span, err, "error marshalling app proto back to json")
+	}
+
+	b64 := base64.StdEncoding.EncodeToString(encoded)
+
+	revision = Revision{
+		B64AppProto:    b64,
+		Status:         appRevision.Status,
+		RevisionNumber: appRevision.RevisionNumber,
+		CreatedAt:      appRevision.CreatedAt.AsTime(),
+		UpdatedAt:      appRevision.UpdatedAt.AsTime(),
+	}
+
+	return revision, nil
+}