Kaynağa Gözat

Porter App V2 Revision Diff (#3772)

Feroze Mohideen 2 yıl önce
ebeveyn
işleme
c562784af3

+ 112 - 0
api/server/handlers/porter_app/yaml_from_revision.go

@@ -0,0 +1,112 @@
+package porter_app
+
+import (
+	"encoding/base64"
+	"net/http"
+
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"gopkg.in/yaml.v2"
+
+	v2 "github.com/porter-dev/porter/internal/porter_app/v2"
+	"github.com/porter-dev/porter/internal/telemetry"
+
+	"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"
+)
+
+// PorterYAMLFromRevisionHandler is the handler for the /apps/{porter_app_name}/revisions/{app_revision_id}/yaml endpoint
+type PorterYAMLFromRevisionHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewPorterYAMLFromRevisionHandler returns a new PorterYAMLFromRevisionHandler
+func NewPorterYAMLFromRevisionHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *PorterYAMLFromRevisionHandler {
+	return &PorterYAMLFromRevisionHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// PorterYAMLFromRevisionResponse is the response object for the /apps/{porter_app_name}/revisions/{app_revision_id}/yaml endpoint
+type PorterYAMLFromRevisionResponse struct {
+	B64PorterYAML string `json:"b64_porter_yaml"`
+}
+
+// ServeHTTP takes a porter app revision and returns the porter yaml for it
+func (c *PorterYAMLFromRevisionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-porter-yaml-from-revision")
+	defer span.End()
+
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	appRevisionID, reqErr := requestutils.GetURLParamString(r, types.URLParamAppRevisionID)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, nil, "error parsing app revision id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	getRevisionReq := connect.NewRequest(&porterv1.GetAppRevisionRequest{
+		ProjectId:     int64(project.ID),
+		AppRevisionId: appRevisionID,
+	})
+	ccpResp, err := c.Config().ClusterControlPlaneClient.GetAppRevision(ctx, getRevisionReq)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting app revision")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if ccpResp == nil || ccpResp.Msg == nil {
+		err = telemetry.Error(ctx, span, nil, "get app revision response is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if ccpResp.Msg.AppRevision == nil {
+		err = telemetry.Error(ctx, span, nil, "app revision is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	appProto := ccpResp.Msg.AppRevision.App
+	if appProto == nil {
+		err = telemetry.Error(ctx, span, nil, "app proto is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	app, err := v2.AppFromProto(appProto)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error converting app proto to porter yaml")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	porterYAMLString, err := yaml.Marshal(app)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error marshaling porter yaml")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	b64String := base64.StdEncoding.EncodeToString(porterYAMLString)
+
+	response := &PorterYAMLFromRevisionResponse{
+		B64PorterYAML: b64String,
+	}
+
+	c.WriteResult(w, r, response)
+}

+ 30 - 1
api/server/router/porter_app.go

@@ -572,7 +572,7 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/parse -> porter_app.NewParsePorterYAMLToProtoHandler
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/parse -> porter_app.NewParsePorterYAMLToProtoHandler
 	parsePorterYAMLToProtoEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
@@ -601,6 +601,35 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/revisions/{app_revision_id}/yaml -> porter_app.NewPorterYAMLFromRevisionHandler
+	porterYAMLFromRevision := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/revisions/{%s}/yaml", relPathV2, types.URLParamPorterAppName, types.URLParamAppRevisionID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	porterYAMLFromRevisionHandler := porter_app.NewPorterYAMLFromRevisionHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: porterYAMLFromRevision,
+		Handler:  porterYAMLFromRevisionHandler,
+		Router:   r,
+	})
+
 	// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/validate -> porter_app.NewValidatePorterAppHandler
 	validatePorterAppEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 4 - 2
dashboard/src/lib/hooks/useRevisionList.ts

@@ -15,12 +15,13 @@ export function useRevisionList({
   deploymentTargetId: string,
   projectId: number,
   clusterId: number
-}): { revisionList: AppRevision[], revisionIdToNumber: Record<string, number> } {
+}): { revisionList: AppRevision[], revisionIdToNumber: Record<string, number>, numberToRevisionId: Record<number, string> } {
   const [
     revisionList,
     setRevisionList,
   ] = useState<AppRevision[]>([]);
   const [revisionIdToNumber, setRevisionIdToNumber] = useState<Record<string, number>>({});
+  const [numberToRevisionId, setNumberToRevisionId] = useState<Record<number, string>>({});
   const { latestRevision } = useLatestRevision();
 
   const { data } = useQuery(
@@ -56,8 +57,9 @@ export function useRevisionList({
       const revisionList = data.app_revisions
       setRevisionList(revisionList);
       setRevisionIdToNumber(Object.fromEntries(revisionList.map(r => ([r.id, r.revision_number]))))
+      setNumberToRevisionId(Object.fromEntries(revisionList.map(r => ([r.revision_number, r.id]))))
     }
   }, [data]);
 
-  return { revisionList, revisionIdToNumber };
+  return { revisionList, revisionIdToNumber, numberToRevisionId };
 }

+ 37 - 28
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/DeployEventCard.tsx

@@ -13,6 +13,7 @@ import AnimateHeight from "react-animate-height";
 import ServiceStatusDetail from "./ServiceStatusDetail";
 import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
 import { useRevisionList } from "lib/hooks/useRevisionList";
+import RevisionDiffModal from "../modals/RevisionDiffModal";
 
 type Props = {
   event: PorterAppDeployEvent;
@@ -29,7 +30,7 @@ const DeployEventCard: React.FC<Props> = ({ event, appName, deploymentTargetId,
   const [revertModalVisible, setRevertModalVisible] = useState(false);
   const [serviceStatusVisible, setServiceStatusVisible] = useState(showServiceStatusDetail);
 
-  const { revisionIdToNumber } = useRevisionList({ appName, deploymentTargetId, projectId, clusterId });
+  const { revisionIdToNumber, numberToRevisionId } = useRevisionList({ appName, deploymentTargetId, projectId, clusterId });
 
   const renderStatusText = () => {
     switch (event.status) {
@@ -120,6 +121,36 @@ const DeployEventCard: React.FC<Props> = ({ event, appName, deploymentTargetId,
     }
   };
 
+  const renderRevisionDiffModal = (event: PorterAppDeployEvent) => {
+    const changedRevisionId = event.metadata.app_revision_id;
+    const changedRevisionNumber = revisionIdToNumber[event.metadata.app_revision_id];
+    if (changedRevisionNumber == null || changedRevisionNumber == 1) {
+      return null;
+    }
+    const baseRevisionNumber = revisionIdToNumber[event.metadata.app_revision_id] - 1;
+    if (numberToRevisionId[baseRevisionNumber] == null) {
+      return null;
+    }
+    const baseRevisionId = numberToRevisionId[baseRevisionNumber];
+    return (
+      <>
+        <Link hasunderline onClick={() => setDiffModalVisible(true)}>
+          View changes
+        </Link>
+        {diffModalVisible && (
+          <RevisionDiffModal
+            base={{ revisionId: baseRevisionId, revisionNumber: baseRevisionNumber }}
+            changed={{ revisionId: changedRevisionId, revisionNumber: changedRevisionNumber }}
+            close={() => setDiffModalVisible(false)}
+            projectId={projectId}
+            clusterId={clusterId}
+            appName={appName}
+          />
+        )}
+      </>
+    )
+  }
+
   const renderServiceDropdownCta = (numServices: number, color?: string) => {
     return (
       <ServiceStatusDropdownCtaContainer >
@@ -146,7 +177,8 @@ const DeployEventCard: React.FC<Props> = ({ event, appName, deploymentTargetId,
           <Icon height="12px" src={getStatusIcon(event.status)} />
           <Spacer inline width="10px" />
           {renderStatusText()}
-          {revisionIdToNumber[event.metadata.app_revision_id] != null && latestRevision.revision_number !== revisionIdToNumber[event.metadata.app_revision_id] && (
+          {/** uncomment the below once we've implemented revert from here */}
+          {/* {revisionIdToNumber[event.metadata.app_revision_id] != null && latestRevision.revision_number !== revisionIdToNumber[event.metadata.app_revision_id] && (
             <>
               <Spacer inline x={1} />
               <TempWrapper>
@@ -156,32 +188,9 @@ const DeployEventCard: React.FC<Props> = ({ event, appName, deploymentTargetId,
 
               </TempWrapper>
             </>
-          )}
-          <Spacer inline x={1} />
-          {/* <TempWrapper>
-            {event.metadata.revision != 1 && (<Link hasunderline onClick={() => setDiffModalVisible(true)}>
-              View changes
-            </Link>)}
-            {diffModalVisible && (
-              <ChangeLogModal
-                revision={event.metadata.revision}
-                currentChart={appData.chart}
-                modalVisible={diffModalVisible}
-                setModalVisible={setDiffModalVisible}
-                appData={appData}
-              />
-            )}
-            {revertModalVisible && (
-              <ChangeLogModal
-                revision={event.metadata.revision}
-                currentChart={appData.chart}
-                modalVisible={revertModalVisible}
-                setModalVisible={setRevertModalVisible}
-                revertModal={true}
-                appData={appData}
-              />
-            )}
-          </TempWrapper> */}
+          )} */}
+          <Spacer inline x={0.5} />
+          {renderRevisionDiffModal(event)}
         </Container>
       </Container>
       {event.metadata.service_deployment_metadata != null &&

+ 135 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/modals/RevisionDiffModal.tsx

@@ -0,0 +1,135 @@
+import React, { useEffect, useState } from "react";
+import Modal from "components/porter/Modal";
+import Loading from "components/Loading";
+import DiffViewer, { DiffMethod } from "react-diff-viewer";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import api from "shared/api";
+import { useQuery } from "@tanstack/react-query";
+import { z } from "zod";
+import styled from "styled-components";
+
+type Props = {
+    close: () => void;
+    base: {
+        revisionId: string;
+        revisionNumber: number;
+    };
+    changed: {
+        revisionId: string;
+        revisionNumber: number;
+    };
+    projectId: number;
+    clusterId: number;
+    appName: string;
+};
+
+const RevisionDiffModal: React.FC<Props> = ({
+    base,
+    changed,
+    close,
+    projectId,
+    clusterId,
+    appName,
+}) => {
+    const [baseYamlString, setBaseYamlString] = useState("");
+    const [changedYamlString, setChangedYamlString] = useState("");
+
+    const { data, isLoading, status } = useQuery(
+        ["getRevisionYaml", JSON.stringify(base), JSON.stringify(changed)],
+        async () => {
+            const baseRes = await api.porterYamlFromRevision(
+                "<token>",
+                {},
+                {
+                    project_id: projectId,
+                    cluster_id: clusterId,
+                    revision_id: base.revisionId,
+                    porter_app_name: appName,
+                }
+            );
+
+            const changedRes = await api.porterYamlFromRevision(
+                "<token>",
+                {},
+                {
+                    project_id: projectId,
+                    cluster_id: clusterId,
+                    revision_id: changed.revisionId,
+                    porter_app_name: appName,
+                }
+            );
+
+            const parsedBase = z.object({ b64_porter_yaml: z.string() }).parse(baseRes.data);
+            const decodedBase = atob(parsedBase.b64_porter_yaml);
+            const parsedChanged = z.object({ b64_porter_yaml: z.string() }).parse(changedRes.data);
+            const decodedChanged = atob(parsedChanged.b64_porter_yaml);
+            return { base: decodedBase, changed: decodedChanged };
+        },
+        {
+            refetchOnWindowFocus: false,
+        }
+    );
+    useEffect(() => {
+        if (status === "success") {
+            setBaseYamlString(data.base);
+            setChangedYamlString(data.changed);
+        }
+    }, [status]);
+
+    const newStyles = {
+        variables: {
+            dark: {
+                diffViewerTitleColor: 'fff'
+            }
+        },
+    };
+
+    return (
+        <>
+            <Modal closeModal={close} width={"800px"}>
+                <Spacer y={1} />
+                {isLoading ? (
+                    <Loading />
+                ) : (
+                    <RevisionDiffContainer>
+                        {baseYamlString === changedYamlString && (
+                            <>
+                                <NoChangesFound>
+                                    <Text size={16}>No changes found</Text>
+                                </NoChangesFound>
+                                <Spacer y={1} />
+                            </>
+                        )}
+                        <DiffViewer
+                            leftTitle={`Revision No. ${base.revisionNumber}`}
+                            rightTitle={`Revision No. ${changed.revisionNumber}`}
+                            oldValue={baseYamlString}
+                            newValue={changedYamlString}
+                            splitView={true}
+                            hideLineNumbers={false}
+                            useDarkTheme={true}
+                            compareMethod={DiffMethod.TRIMMED_LINES}
+                            styles={newStyles}
+                        />
+                    </RevisionDiffContainer>
+                )}
+            </Modal>
+        </>
+    );
+};
+
+export default RevisionDiffModal;
+
+const RevisionDiffContainer = styled.div`
+    max-height: 400px;
+    overflow-y: auto;
+    border-radius: 8px;
+`;
+
+const NoChangesFound = styled.div`
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    height: 100%;
+`;

+ 1 - 0
dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/RevisionTableContents.tsx

@@ -7,6 +7,7 @@ import styled from "styled-components";
 import { readableDate } from "shared/string_utils";
 import Text from "components/porter/Text";
 import { SourceOptions } from "lib/porter-apps";
+import api from "shared/api";
 
 type RevisionTableContentsProps = {
   latestRevisionNumber: number;

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

@@ -1004,6 +1004,18 @@ const getRevision = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/revisions/${revision_id}`;
 });
 
+const porterYamlFromRevision = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    porter_app_name: string;
+    revision_id: string;
+  }
+>("GET", ({ project_id, cluster_id, porter_app_name, revision_id }) => {
+  return `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/revisions/${revision_id}/yaml`;
+});
+
 const listAppRevisions = baseApi<
   {
     deployment_target_id: string;
@@ -3112,6 +3124,7 @@ export default {
   appPodStatus,
   getFeedEvents,
   updateStackStep,
+  porterYamlFromRevision,
   // -----------------------------------
   createConfigMap,
   deleteCluster,

+ 31 - 28
internal/porter_app/parse_test.go → internal/porter_app/test/parse_test.go

@@ -1,4 +1,4 @@
-package porter_app
+package test
 
 import (
 	"context"
@@ -10,6 +10,7 @@ import (
 	"k8s.io/utils/pointer"
 
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/internal/porter_app"
 	"github.com/sergi/go-diff/diffmatchpatch"
 
 	"github.com/matryer/is"
@@ -28,10 +29,10 @@ func TestParseYAML(t *testing.T) {
 		t.Run(tt.porterYamlFileName, func(t *testing.T) {
 			is := is.New(t)
 
-			want, err := os.ReadFile(fmt.Sprintf("testdata/%s.yaml", tt.porterYamlFileName))
+			want, err := os.ReadFile(fmt.Sprintf("../testdata/%s.yaml", tt.porterYamlFileName))
 			is.NoErr(err) // no error expected reading test file
 
-			got, err := ParseYAML(context.Background(), want, "test-app")
+			got, err := porter_app.ParseYAML(context.Background(), want, "test-app")
 			is.NoErr(err) // umbrella chart values should convert to map[string]any without issues
 
 			diffProtoWithFailTest(t, is, tt.want, got.AppProto)
@@ -47,31 +48,6 @@ func TestParseYAML(t *testing.T) {
 var result_nobuild = &porterv1.PorterApp{
 	Name: "test-app",
 	Services: map[string]*porterv1.Service{
-		"example-job": {
-			RunOptional:  pointer.String("echo 'hello world'"),
-			CpuCores:     0.1,
-			RamMegabytes: 256,
-			Config: &porterv1.Service_JobConfig{
-				JobConfig: &porterv1.JobServiceConfig{
-					AllowConcurrent: true,
-					Cron:            "*/10 * * * *",
-				},
-			},
-			Type: 3,
-		},
-		"example-wkr": {
-			RunOptional:  pointer.String("echo 'work'"),
-			Instances:    1,
-			Port:         80,
-			CpuCores:     0.1,
-			RamMegabytes: 256,
-			Config: &porterv1.Service_WorkerConfig{
-				WorkerConfig: &porterv1.WorkerServiceConfig{
-					Autoscaling: nil,
-				},
-			},
-			Type: 2,
-		},
 		"example-web": {
 			RunOptional:  pointer.String("node index.js"),
 			Instances:    0,
@@ -103,6 +79,33 @@ var result_nobuild = &porterv1.PorterApp{
 			},
 			Type: 1,
 		},
+		"example-wkr": {
+			RunOptional:  pointer.String("echo 'work'"),
+			Instances:    1,
+			Port:         80,
+			CpuCores:     0.1,
+			RamMegabytes: 256,
+			Config: &porterv1.Service_WorkerConfig{
+				WorkerConfig: &porterv1.WorkerServiceConfig{
+					Autoscaling: nil,
+				},
+			},
+			Type: 2,
+		},
+		"example-job": {
+			RunOptional:  pointer.String("echo 'hello world'"),
+			CpuCores:     0.1,
+			RamMegabytes: 256,
+			Config: &porterv1.Service_JobConfig{
+				JobConfig: &porterv1.JobServiceConfig{
+					AllowConcurrentOptional: pointer.Bool(true),
+					Cron:                    "*/10 * * * *",
+					SuspendCron:             pointer.Bool(false),
+					TimeoutSeconds:          60,
+				},
+			},
+			Type: 3,
+		},
 	},
 	Predeploy: &porterv1.Service{
 		RunOptional:  pointer.String("ls"),

+ 62 - 0
internal/porter_app/test/porter_app_to_yaml_test.go

@@ -0,0 +1,62 @@
+package test
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"reflect"
+	"testing"
+
+	"github.com/kr/pretty"
+	"github.com/matryer/is"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/internal/porter_app"
+	v2 "github.com/porter-dev/porter/internal/porter_app/v2"
+	"gopkg.in/yaml.v2"
+)
+
+func TestPorterAppToYAML(t *testing.T) {
+	tests := []struct {
+		porterYamlFileName string
+		want               *porterv1.PorterApp
+	}{
+		{"v2_input_no_build_no_env", result_nobuild},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.porterYamlFileName, func(t *testing.T) {
+			is := is.New(t)
+
+			originalYaml, err := os.ReadFile(fmt.Sprintf("../testdata/%s.yaml", tt.porterYamlFileName))
+			is.NoErr(err) // no error expected reading test file
+
+			porterAppProto, err := porter_app.ParseYAML(context.Background(), originalYaml, "test-app")
+			is.NoErr(err) // umbrella chart values should convert to map[string]any without issues
+
+			porterApp, err := v2.AppFromProto(porterAppProto.AppProto)
+			is.NoErr(err) // app proto should be converted back to porter app representation (unmarshaled porter yaml) without issues
+
+			diffPorterAppWithOriginalYamlTest(t, is, originalYaml, porterApp)
+		})
+	}
+}
+
+func diffPorterAppWithOriginalYamlTest(t *testing.T, is *is.I, wantYaml []byte, got v2.PorterApp) {
+	t.Helper()
+
+	var want map[string]interface{}
+	err := yaml.Unmarshal(wantYaml, &want)
+	is.NoErr(err)
+
+	gotYaml, err := yaml.Marshal(got)
+	is.NoErr(err)
+
+	var gotMap map[string]interface{}
+	err = yaml.Unmarshal(gotYaml, &gotMap)
+	is.NoErr(err)
+
+	// Compare the maps for equality
+	if !reflect.DeepEqual(want, gotMap) {
+		t.Errorf("Maps are not equal. Diff: %v", pretty.Diff(want, gotMap))
+	}
+}

+ 43 - 0
internal/porter_app/testdata/v2_input_no_build_no_env.yaml

@@ -0,0 +1,43 @@
+version: v2
+name: test-app
+image:
+  repository: nginx
+  tag: latest
+services:
+  example-web:
+    type: web
+    run: node index.js
+    port: 8080
+    cpuCores: 0.1
+    ramMegabytes: 256
+    autoscaling:
+      enabled: true
+      minInstances: 1
+      maxInstances: 3
+      memoryThresholdPercent: 60
+      cpuThresholdPercent: 60
+    domains:
+      - name: test1.example.com
+      - name: test2.example.com
+    healthCheck:
+      enabled: true
+      httpPath: /healthz
+  example-wkr:
+    type: worker
+    run: echo 'work'
+    port: 80
+    cpuCores: 0.1
+    ramMegabytes: 256
+    instances: 1
+  example-job:
+    type: job
+    run: echo 'hello world'
+    allowConcurrent: true
+    cpuCores: 0.1
+    ramMegabytes: 256
+    cron: '*/10 * * * *'
+    timeoutSeconds: 60
+    suspendCron: false
+predeploy:
+  type: job
+  run: ls

+ 5 - 3
internal/porter_app/testdata/v2_input_nobuild.yaml

@@ -1,5 +1,5 @@
 version: v2
-name: "test-app"
+name: 'test-app'
 image:
   repository: nginx
   tag: latest
@@ -35,10 +35,12 @@ services:
     allowConcurrent: true
     cpuCores: 0.1
     ramMegabytes: 256
-    cron: "*/10 * * * *"
+    cron: '*/10 * * * *'
+    timeoutSeconds: 60
+    suspendCron: false
 predeploy:
   type: job
   run: ls
 env:
   PORT: 8080
-  NODE_ENV: production
+  NODE_ENV: production

+ 162 - 23
internal/porter_app/v2/yaml.go

@@ -63,13 +63,14 @@ func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte) (AppWithPrevi
 
 // PorterApp represents all the possible fields in a Porter YAML file
 type PorterApp struct {
+	Version  string             `yaml:"version,omitempty"`
 	Name     string             `yaml:"name"`
 	Services map[string]Service `yaml:"services"`
-	Image    *Image             `yaml:"image"`
-	Build    *Build             `yaml:"build"`
-	Env      map[string]string  `yaml:"env"`
+	Image    *Image             `yaml:"image,omitempty"`
+	Build    *Build             `yaml:"build,omitempty"`
+	Env      map[string]string  `yaml:"env,omitempty"`
 
-	Predeploy *Service `yaml:"predeploy"`
+	Predeploy *Service `yaml:"predeploy,omitempty"`
 	EnvGroups []string `yaml:"envGroups,omitempty"`
 }
 
@@ -88,21 +89,29 @@ type Build struct {
 	Dockerfile string   `yaml:"dockerfile" validate:"required_if=Method docker"`
 }
 
+// Image is the repository and tag for an app's build image
+type Image struct {
+	Repository string `yaml:"repository"`
+	Tag        string `yaml:"tag"`
+}
+
 // Service represents a single service in a porter app
 type Service struct {
 	Run               *string      `yaml:"run,omitempty"`
-	Type              string       `yaml:"type" validate:"required, oneof=web worker job"`
-	Instances         int          `yaml:"instances"`
-	CpuCores          float32      `yaml:"cpuCores"`
-	RamMegabytes      int          `yaml:"ramMegabytes"`
-	SmartOptimization *bool        `yaml:"smartOptimization"`
-	Port              int          `yaml:"port"`
+	Type              string       `yaml:"type,omitempty" validate:"required, oneof=web worker job"`
+	Instances         int          `yaml:"instances,omitempty"`
+	CpuCores          float32      `yaml:"cpuCores,omitempty"`
+	RamMegabytes      int          `yaml:"ramMegabytes,omitempty"`
+	SmartOptimization *bool        `yaml:"smartOptimization,omitempty"`
+	Port              int          `yaml:"port,omitempty"`
 	Autoscaling       *AutoScaling `yaml:"autoscaling,omitempty" validate:"excluded_if=Type job"`
-	Domains           []Domains    `yaml:"domains" validate:"excluded_unless=Type web"`
+	Domains           []Domains    `yaml:"domains,omitempty" validate:"excluded_unless=Type web"`
 	HealthCheck       *HealthCheck `yaml:"healthCheck,omitempty" validate:"excluded_unless=Type web"`
-	AllowConcurrent   bool         `yaml:"allowConcurrent" validate:"excluded_unless=Type job"`
-	Cron              string       `yaml:"cron" validate:"excluded_unless=Type job"`
-	Private           *bool        `yaml:"private" validate:"excluded_unless=Type web"`
+	AllowConcurrent   *bool        `yaml:"allowConcurrent,omitempty" validate:"excluded_unless=Type job"`
+	Cron              string       `yaml:"cron,omitempty" validate:"excluded_unless=Type job"`
+	SuspendCron       *bool        `yaml:"suspendCron,omitempty" validate:"excluded_unless=Type job"`
+	TimeoutSeconds    int          `yaml:"timeoutSeconds,omitempty" validate:"excluded_unless=Type job"`
+	Private           *bool        `yaml:"private,omitempty" validate:"excluded_unless=Type web"`
 }
 
 // AutoScaling represents the autoscaling settings for web services
@@ -125,12 +134,6 @@ type HealthCheck struct {
 	HttpPath string `yaml:"httpPath"`
 }
 
-// Image is the repository and tag for an app's build image
-type Image struct {
-	Repository string `yaml:"repository"`
-	Tag        string `yaml:"tag"`
-}
-
 func buildAppProto(ctx context.Context, porterApp PorterApp) (*porterv1.PorterApp, map[string]string, error) {
 	ctx, span := telemetry.NewSpan(ctx, "build-app-proto")
 	defer span.End()
@@ -222,12 +225,12 @@ func protoEnumFromType(name string, service Service) porterv1.ServiceType {
 func serviceProtoFromConfig(service Service, serviceType porterv1.ServiceType) (*porterv1.Service, error) {
 	serviceProto := &porterv1.Service{
 		RunOptional:       service.Run,
-		Type:              serviceType,
 		Instances:         int32(service.Instances),
 		CpuCores:          service.CpuCores,
 		RamMegabytes:      int32(service.RamMegabytes),
 		Port:              int32(service.Port),
 		SmartOptimization: service.SmartOptimization,
+		Type:              serviceType,
 	}
 
 	switch serviceType {
@@ -294,8 +297,14 @@ func serviceProtoFromConfig(service Service, serviceType porterv1.ServiceType) (
 		}
 	case porterv1.ServiceType_SERVICE_TYPE_JOB:
 		jobConfig := &porterv1.JobServiceConfig{
-			AllowConcurrent: service.AllowConcurrent,
-			Cron:            service.Cron,
+			AllowConcurrentOptional: service.AllowConcurrent,
+			Cron:                    service.Cron,
+		}
+		if service.SuspendCron != nil {
+			jobConfig.SuspendCron = service.SuspendCron
+		}
+		if service.TimeoutSeconds != 0 {
+			jobConfig.TimeoutSeconds = int64(service.TimeoutSeconds)
 		}
 
 		serviceProto.Config = &porterv1.Service_JobConfig{
@@ -305,3 +314,133 @@ func serviceProtoFromConfig(service Service, serviceType porterv1.ServiceType) (
 
 	return serviceProto, nil
 }
+
+// AppFromProto converts a PorterApp proto object into a PorterApp struct
+func AppFromProto(appProto *porterv1.PorterApp) (PorterApp, error) {
+	porterApp := PorterApp{
+		Version: "v2",
+		Name:    appProto.Name,
+	}
+
+	if appProto.Build != nil {
+		porterApp.Build = &Build{
+			Context:    appProto.Build.Context,
+			Method:     appProto.Build.Method,
+			Builder:    appProto.Build.Builder,
+			Buildpacks: appProto.Build.Buildpacks,
+			Dockerfile: appProto.Build.Dockerfile,
+		}
+	}
+
+	if appProto.Image != nil {
+		porterApp.Image = &Image{
+			Repository: appProto.Image.Repository,
+			Tag:        appProto.Image.Tag,
+		}
+	}
+
+	porterApp.Services = make(map[string]Service, 0)
+	for name, service := range appProto.Services {
+		appService, err := appServiceFromProto(service)
+		if err != nil {
+			return porterApp, err
+		}
+
+		porterApp.Services[name] = appService
+	}
+
+	if appProto.Predeploy != nil {
+		appPredeploy, err := appServiceFromProto(appProto.Predeploy)
+		if err != nil {
+			return porterApp, err
+		}
+
+		porterApp.Predeploy = &appPredeploy
+	}
+
+	porterApp.EnvGroups = make([]string, 0)
+	for _, envGroup := range appProto.EnvGroups {
+		porterApp.EnvGroups = append(porterApp.EnvGroups, envGroup.Name)
+	}
+
+	return porterApp, nil
+}
+
+func appServiceFromProto(service *porterv1.Service) (Service, error) {
+	appService := Service{
+		Run:               service.RunOptional,
+		Instances:         int(service.Instances),
+		CpuCores:          service.CpuCores,
+		RamMegabytes:      int(service.RamMegabytes),
+		Port:              int(service.Port),
+		SmartOptimization: service.SmartOptimization,
+	}
+
+	switch service.Type {
+	default:
+		return appService, fmt.Errorf("invalid service type '%s'", service.Type)
+	case porterv1.ServiceType_SERVICE_TYPE_UNSPECIFIED:
+		return appService, errors.New("Service type unspecified")
+	case porterv1.ServiceType_SERVICE_TYPE_WEB:
+		webConfig := service.GetWebConfig()
+		appService.Type = "web"
+
+		var autoscaling *AutoScaling
+		if webConfig.Autoscaling != nil {
+			autoscaling = &AutoScaling{
+				Enabled:                webConfig.Autoscaling.Enabled,
+				MinInstances:           int(webConfig.Autoscaling.MinInstances),
+				MaxInstances:           int(webConfig.Autoscaling.MaxInstances),
+				CpuThresholdPercent:    int(webConfig.Autoscaling.CpuThresholdPercent),
+				MemoryThresholdPercent: int(webConfig.Autoscaling.MemoryThresholdPercent),
+			}
+		}
+		appService.Autoscaling = autoscaling
+
+		var healthCheck *HealthCheck
+		if webConfig.HealthCheck != nil {
+			healthCheck = &HealthCheck{
+				Enabled:  webConfig.HealthCheck.Enabled,
+				HttpPath: webConfig.HealthCheck.HttpPath,
+			}
+		}
+		appService.HealthCheck = healthCheck
+
+		domains := make([]Domains, 0)
+		for _, domain := range webConfig.Domains {
+			domains = append(domains, Domains{
+				Name: domain.Name,
+			})
+		}
+		appService.Domains = domains
+
+		if webConfig.Private != nil {
+			appService.Private = webConfig.Private
+		}
+	case porterv1.ServiceType_SERVICE_TYPE_WORKER:
+		workerConfig := service.GetWorkerConfig()
+		appService.Type = "worker"
+
+		var autoscaling *AutoScaling
+		if workerConfig.Autoscaling != nil {
+			autoscaling = &AutoScaling{
+				Enabled:                workerConfig.Autoscaling.Enabled,
+				MinInstances:           int(workerConfig.Autoscaling.MinInstances),
+				MaxInstances:           int(workerConfig.Autoscaling.MaxInstances),
+				CpuThresholdPercent:    int(workerConfig.Autoscaling.CpuThresholdPercent),
+				MemoryThresholdPercent: int(workerConfig.Autoscaling.MemoryThresholdPercent),
+			}
+		}
+		appService.Autoscaling = autoscaling
+	case porterv1.ServiceType_SERVICE_TYPE_JOB:
+		jobConfig := service.GetJobConfig()
+		appService.Type = "job"
+
+		appService.AllowConcurrent = jobConfig.AllowConcurrentOptional
+		appService.Cron = jobConfig.Cron
+		appService.SuspendCron = jobConfig.SuspendCron
+		appService.TimeoutSeconds = int(jobConfig.TimeoutSeconds)
+	}
+
+	return appService, nil
+}