Sfoglia il codice sorgente

define services as list (#3818)

ianedwards 2 anni fa
parent
commit
46c9b94d8e

+ 7 - 9
api/server/handlers/gitinstallation/get_porter_yaml.go

@@ -5,8 +5,6 @@ import (
 	b64 "encoding/base64"
 	"net/http"
 
-	"github.com/porter-dev/porter/api/server/handlers/porter_app"
-
 	"github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -16,6 +14,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"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"
 	"gopkg.in/yaml.v2"
 )
@@ -94,16 +93,16 @@ func (c *GithubGetPorterYamlHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 		return
 	}
 
-	parsed := &porter_app.PorterStackYAML{}
-	err = yaml.Unmarshal([]byte(fileData), parsed)
+	version := &porter_app.YamlVersion{}
+	err = yaml.Unmarshal([]byte(fileData), version)
 	if err != nil {
-		err = telemetry.Error(ctx, span, err, "invalid porter yaml format")
+		err = telemetry.Error(ctx, span, err, "invalid porter yaml version")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
 
 	if project.GetFeatureFlag(models.ValidateApplyV2, c.Config().LaunchDarklyClient) {
-		if parsed.Version != nil && *parsed.Version != "v2" {
+		if version.Version != "" && version.Version != "v2" {
 			err = telemetry.Error(ctx, span, nil, "porter YAML version is not supported")
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 			return
@@ -111,9 +110,8 @@ func (c *GithubGetPorterYamlHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 	}
 
 	// backwards compatibility so that old porter yamls are no longer valid
-	if !project.GetFeatureFlag(models.ValidateApplyV2, c.Config().LaunchDarklyClient) && parsed.Version != nil {
-		version := *parsed.Version
-		if version != "v1stack" {
+	if !project.GetFeatureFlag(models.ValidateApplyV2, c.Config().LaunchDarklyClient) {
+		if version.Version != "" && version.Version != "v1stack" {
 			err = telemetry.Error(ctx, span, nil, "porter YAML version is not supported")
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 			return

+ 27 - 13
api/server/handlers/porter_app/apply.go

@@ -15,6 +15,7 @@ import (
 
 	"github.com/porter-dev/porter/internal/deployment_target"
 	"github.com/porter-dev/porter/internal/porter_app"
+	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"
@@ -113,6 +114,13 @@ func (c *ApplyPorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 			return
 		}
 
+		app, err := v2.AppFromProto(appProto)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error converting app proto to app")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
 		if request.DeploymentTargetId == "" {
 			err := telemetry.Error(ctx, span, err, "deployment target id is empty")
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
@@ -145,19 +153,26 @@ func (c *ApplyPorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		}
 
 		subdomainCreateInput := porter_app.CreatePorterSubdomainInput{
-			AppName:             appProto.Name,
+			AppName:             app.Name,
 			RootDomain:          c.Config().ServerConf.AppRootDomain,
 			DNSClient:           c.Config().DNSClient,
 			DNSRecordRepository: c.Repo().DNSRecord(),
 			KubernetesAgent:     agent,
 		}
 
-		appProto, err = addPorterSubdomainsIfNecessary(ctx, appProto, deploymentTargetDetails, subdomainCreateInput)
+		appWithDomains, err := addPorterSubdomainsIfNecessary(ctx, app, deploymentTargetDetails, subdomainCreateInput)
 		if err != nil {
 			err := telemetry.Error(ctx, span, err, "error adding porter subdomains")
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 			return
 		}
+
+		appProto, _, err = v2.ProtoFromApp(ctx, appWithDomains)
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "error converting app to proto")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
 	}
 
 	applyReq := connect.NewRequest(&porterv1.ApplyPorterAppRequest{
@@ -210,16 +225,17 @@ func (c *ApplyPorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 }
 
 // addPorterSubdomainsIfNecessary adds porter subdomains to the app proto if a web service is changed to private and has no domains
-func addPorterSubdomainsIfNecessary(ctx context.Context, app *porterv1.PorterApp, deploymentTarget deployment_target.DeploymentTarget, createSubdomainInput porter_app.CreatePorterSubdomainInput) (*porterv1.PorterApp, error) {
-	for serviceName, service := range app.Services {
-		if service.Type == porterv1.ServiceType_SERVICE_TYPE_WEB {
-			if service.GetWebConfig() == nil {
-				return app, fmt.Errorf("web service %s does not contain web config", serviceName)
-			}
+func addPorterSubdomainsIfNecessary(ctx context.Context, app v2.PorterApp, deploymentTarget deployment_target.DeploymentTarget, createSubdomainInput porter_app.CreatePorterSubdomainInput) (v2.PorterApp, error) {
+	ctx, span := telemetry.NewSpan(ctx, "add-porter-subdomains-if-necessary")
+	defer span.End()
 
-			webConfig := service.GetWebConfig()
+	for _, service := range app.Services {
+		if service.Type == v2.ServiceType_Web {
+			if service.Private == nil || !*service.Private {
+				continue
+			}
 
-			if !webConfig.GetPrivate() && len(webConfig.Domains) == 0 {
+			if service.Domains != nil && len(service.Domains) == 0 {
 				if deploymentTarget.Namespace != DeploymentTargetSelector_Default {
 					createSubdomainInput.AppName = fmt.Sprintf("%s-%s", createSubdomainInput.AppName, deploymentTarget.ID[:6])
 				}
@@ -233,11 +249,9 @@ func addPorterSubdomainsIfNecessary(ctx context.Context, app *porterv1.PorterApp
 					return app, errors.New("response subdomain is empty")
 				}
 
-				webConfig.Domains = []*porterv1.Domain{
+				service.Domains = []v2.Domains{
 					{Name: subdomain},
 				}
-
-				service.Config = &porterv1.Service_WebConfig{WebConfig: webConfig}
 			}
 		}
 	}

+ 9 - 8
api/server/handlers/porter_app/report_status.go

@@ -20,6 +20,7 @@ import (
 	"github.com/porter-dev/porter/internal/deployment_target"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/porter_app"
+	v2 "github.com/porter-dev/porter/internal/porter_app/v2"
 	"github.com/porter-dev/porter/internal/telemetry"
 	"k8s.io/utils/pointer"
 )
@@ -230,6 +231,11 @@ func writePRComment(ctx context.Context, inp writePRCommentInput) error {
 		return telemetry.Error(ctx, span, err, "error unmarshalling app proto")
 	}
 
+	app, err := v2.AppFromProto(appProto)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error converting app proto to app")
+	}
+
 	body := "## Porter Preview Environments\n"
 	porterURL := fmt.Sprintf("%s/preview-environments/apps/%s?target=%s", inp.serverURL, inp.porterApp.Name, inp.revision.DeploymentTargetID)
 
@@ -244,14 +250,9 @@ func writePRComment(ctx context.Context, inp writePRCommentInput) error {
 		return nil
 	}
 
-	for _, service := range appProto.Services {
-		webConfig := service.GetWebConfig()
-		if webConfig != nil {
-			domains := webConfig.GetDomains()
-
-			if len(domains) > 0 {
-				body = fmt.Sprintf("%s\n\n**Preview URL**: https://%s", body, domains[0].Name)
-			}
+	for _, service := range app.Services {
+		if service.Domains != nil && len(service.Domains) > 0 {
+			body = fmt.Sprintf("%s\n\n**Preview URL**: https://%s", body, service.Domains[0].Name)
 		}
 	}
 

+ 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.2.17",
+        "@porter-dev/api-contracts": "^0.2.18",
         "@react-spring/web": "^9.6.1",
         "@sentry/react": "^6.13.2",
         "@sentry/tracing": "^6.13.2",
@@ -2455,9 +2455,9 @@
       }
     },
     "node_modules/@porter-dev/api-contracts": {
-      "version": "0.2.17",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.17.tgz",
-      "integrity": "sha512-E5VEAS7ovim2G7EfmGb6co/aFQvZiHPQzw8EKEdWuzcLrn4DErSNsGJJzVyqm/86FuZkHrYtec89hyPj8u2rbw==",
+      "version": "0.2.18",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.18.tgz",
+      "integrity": "sha512-9njYA7i92K6iY5wxRy8eqVuTMHcEWjlUXpH0z+3Iji0Qe4pEybY7DYeoPXfwfVigOvOCC/rwDUgOz5fYzv8YEQ==",
       "dependencies": {
         "@bufbuild/protobuf": "^1.1.0"
       }
@@ -16956,9 +16956,9 @@
       "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
     },
     "@porter-dev/api-contracts": {
-      "version": "0.2.17",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.17.tgz",
-      "integrity": "sha512-E5VEAS7ovim2G7EfmGb6co/aFQvZiHPQzw8EKEdWuzcLrn4DErSNsGJJzVyqm/86FuZkHrYtec89hyPj8u2rbw==",
+      "version": "0.2.18",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.18.tgz",
+      "integrity": "sha512-9njYA7i92K6iY5wxRy8eqVuTMHcEWjlUXpH0z+3Iji0Qe4pEybY7DYeoPXfwfVigOvOCC/rwDUgOz5fYzv8YEQ==",
       "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.2.17",
+    "@porter-dev/api-contracts": "^0.2.18",
     "@react-spring/web": "^9.6.1",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",

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

@@ -127,6 +127,7 @@ export const usePorterYaml = ({
               .optional(),
           })
           .parseAsync(res.data);
+
         const proto = PorterApp.fromJsonString(atob(data.b64_app_proto), {
           ignoreUnknownFields: true,
         });

+ 58 - 26
dashboard/src/lib/porter-apps/index.ts

@@ -8,8 +8,14 @@ import {
   serializedServiceFromProto,
   serviceProto,
   serviceValidator,
+  uniqueServices,
 } from "./services";
-import { Build, HelmOverrides, PorterApp, Service } from "@porter-dev/api-contracts";
+import {
+  Build,
+  HelmOverrides,
+  PorterApp,
+  Service,
+} from "@porter-dev/api-contracts";
 import { match } from "ts-pattern";
 import { KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
 import { BuildOptions, buildValidator } from "./build";
@@ -104,16 +110,27 @@ export const porterAppFormValidator = z
 
       return true;
     },
-    { message: "if building with buildpacks, all services must include a run command. Make sure all services contain a run command or change your build method to Docker in build settings", path: ["app", "services"] }
-  ).refine(
+    {
+      message:
+        "if building with buildpacks, all services must include a run command. Make sure all services contain a run command or change your build method to Docker in build settings",
+      path: ["app", "services"],
+    }
+  )
+  .refine(
     ({ app, source }) => {
       if (source.type === "docker-registry" || app.build.method === "docker") {
-        return app.services.every((svc) => !svc.run.value.startsWith("docker run"));
+        return app.services.every(
+          (svc) => !svc.run.value.startsWith("docker run")
+        );
       }
 
       return true;
     },
-    { message: "if using Docker registry or building via a Dockerfile, service must not include `docker run` in its start command; instead, leave the start command empty", path: ["app", "services"] }
+    {
+      message:
+        "if using Docker registry or building via a Dockerfile, service must not include `docker run` in its start command; instead, leave the start command empty",
+      path: ["app", "services"],
+    }
   );
 export type PorterAppFormData = z.infer<typeof porterAppFormValidator>;
 
@@ -130,8 +147,8 @@ export function serviceOverrides({
   defaultCPU?: number;
   defaultRAM?: number;
 }): DetectedServices {
-  const services = Object.entries(overrides.services)
-    .map(([name, service]) => serializedServiceFromProto({ name, service }))
+  const services = uniqueServices(overrides)
+    .map((service) => serializedServiceFromProto({ service }))
     .map((svc) => {
       if (useDefaults) {
         return deserializeService({
@@ -178,8 +195,10 @@ export function serviceOverrides({
           defaultRAM,
         }),
         override: serializedServiceFromProto({
-          name: "pre-deploy",
-          service: overrides.predeploy,
+          service: new Service({
+            ...overrides.predeploy,
+            name: "pre-deploy",
+          }),
           isPredeploy: true,
         }),
         expanded: true,
@@ -192,8 +211,10 @@ export function serviceOverrides({
     services,
     predeploy: deserializeService({
       service: serializedServiceFromProto({
-        name: "pre-deploy",
-        service: overrides.predeploy,
+        service: new Service({
+          ...overrides.predeploy,
+          name: "pre-deploy",
+        }),
         isPredeploy: true,
       }),
     }),
@@ -246,7 +267,10 @@ export function clientAppToProto(data: PorterAppFormData): PorterApp {
           build: clientBuildToProto(app.build),
           ...(predeploy && {
             predeploy: serviceProto(serializeService(predeploy)),
-            helmOverrides: app.helmOverrides != null ? new HelmOverrides({ b64Values: btoa(app.helmOverrides) }) : undefined,
+            helmOverrides:
+              app.helmOverrides != null
+                ? new HelmOverrides({ b64Values: btoa(app.helmOverrides) })
+                : undefined,
           }),
         })
     )
@@ -264,7 +288,10 @@ export function clientAppToProto(data: PorterAppFormData): PorterApp {
             repository: src.image.repository,
             tag: src.image.tag,
           },
-          helmOverrides: app.helmOverrides != null ? new HelmOverrides({ b64Values: btoa(app.helmOverrides) }) : undefined,
+          helmOverrides:
+            app.helmOverrides != null
+              ? new HelmOverrides({ b64Values: btoa(app.helmOverrides) })
+              : undefined,
         })
     )
     .exhaustive();
@@ -332,8 +359,8 @@ export function clientAppFromProto({
   variables?: Record<string, string>;
   secrets?: Record<string, string>;
 }): ClientPorterApp {
-  const services = Object.entries(proto.services)
-    .map(([name, service]) => serializedServiceFromProto({ name, service }))
+  const services = uniqueServices(proto)
+    .map((service) => serializedServiceFromProto({ service }))
     .map((svc) => {
       const override = overrides?.services.find(
         (s) => s.name.value === svc.name
@@ -367,14 +394,17 @@ export function clientAppFromProto({
     })),
   ];
 
-  const helmOverrides = proto.helmOverrides == null ? "" : atob(proto.helmOverrides.b64Values);
+  const helmOverrides =
+    proto.helmOverrides == null ? "" : atob(proto.helmOverrides.b64Values);
 
   if (proto.predeploy) {
     predeployList.push(
       deserializeService({
         service: serializedServiceFromProto({
-          name: "pre-deploy",
-          service: proto.predeploy,
+          service: new Service({
+            ...proto.predeploy,
+            name: "pre-deploy",
+          }),
           isPredeploy: true,
         }),
       })
@@ -406,15 +436,17 @@ export function clientAppFromProto({
   const predeployOverrides = serializeService(overrides.predeploy);
   const predeploy = proto.predeploy
     ? [
-      deserializeService({
-        service: serializedServiceFromProto({
-          name: "pre-deploy",
-          service: proto.predeploy,
-          isPredeploy: true,
+        deserializeService({
+          service: serializedServiceFromProto({
+            service: new Service({
+              ...proto.predeploy,
+              name: "pre-deploy",
+            }),
+            isPredeploy: true,
+          }),
+          override: predeployOverrides,
         }),
-        override: predeployOverrides,
-      }),
-    ]
+      ]
     : undefined;
 
   return {

+ 49 - 34
dashboard/src/lib/porter-apps/services.ts

@@ -1,4 +1,4 @@
-import { Service, ServiceType } from "@porter-dev/api-contracts";
+import { PorterApp, Service, ServiceType } from "@porter-dev/api-contracts";
 import { match } from "ts-pattern";
 import { z } from "zod";
 
@@ -87,9 +87,12 @@ export const serviceValidator = z.object({
     })
     .array()
     .default([]),
-  ingressAnnotationDeletions: z.object({
-    key: z.string(),
-  }).array().default([])
+  ingressAnnotationDeletions: z
+    .object({
+      key: z.string(),
+    })
+    .array()
+    .default([]),
 });
 
 export type ClientService = z.infer<typeof serviceValidator>;
@@ -142,6 +145,24 @@ export function prefixSubdomain(subdomain: string) {
   return "https://" + subdomain;
 }
 
+export function uniqueServices(app: PorterApp): Service[] {
+  if (app.serviceList?.length) {
+    // dedupe services by name, favoring the first instance
+    return _.uniqBy(app.serviceList, "name");
+  }
+
+  const servicesFromMap = Object.entries(app.services ?? {}).map(
+    ([name, service]) => {
+      return new Service({
+        ...service,
+        name,
+      });
+    }
+  );
+
+  return servicesFromMap;
+}
+
 export function defaultSerialized({
   name,
   type,
@@ -229,42 +250,42 @@ export function serializeService(service: ClientService): SerializedService {
     config: match(service.config)
       .with({ type: "web" }, (config) =>
         Object.freeze({
-            type: "web" as const,
-            autoscaling: serializeAutoscaling({
-              autoscaling: config.autoscaling,
-            }),
-            healthCheck: serializeHealth({ health: config.healthCheck }),
-            domains: config.domains.map((domain) => ({
-              name: domain.name.value,
-            })),
-            ingressAnnotations: Object.fromEntries(
-              config.ingressAnnotations
-                .filter((a) => a.key.length > 0 && a.value.length > 0)
-                .map((annotation) => [annotation.key, annotation.value])
-            ),
-            private: config.private?.value,
+          type: "web" as const,
+          autoscaling: serializeAutoscaling({
+            autoscaling: config.autoscaling,
+          }),
+          healthCheck: serializeHealth({ health: config.healthCheck }),
+          domains: config.domains.map((domain) => ({
+            name: domain.name.value,
+          })),
+          ingressAnnotations: Object.fromEntries(
+            config.ingressAnnotations
+              .filter((a) => a.key.length > 0 && a.value.length > 0)
+              .map((annotation) => [annotation.key, annotation.value])
+          ),
+          private: config.private?.value,
         })
       )
       .with({ type: "worker" }, (config) =>
         Object.freeze({
-            type: "worker" as const,
-            autoscaling: serializeAutoscaling({
-              autoscaling: config.autoscaling,
-            }),
+          type: "worker" as const,
+          autoscaling: serializeAutoscaling({
+            autoscaling: config.autoscaling,
+          }),
         })
       )
       .with({ type: "job" }, (config) =>
         Object.freeze({
-            type: "job" as const,
-            allowConcurrent: config.allowConcurrent?.value,
-            cron: config.cron.value,
-            suspendCron: config.suspendCron?.value,
-            timeoutSeconds: config.timeoutSeconds.value,
+          type: "job" as const,
+          allowConcurrent: config.allowConcurrent?.value,
+          cron: config.cron.value,
+          suspendCron: config.suspendCron?.value,
+          timeoutSeconds: config.timeoutSeconds.value,
         })
       )
       .with({ type: "predeploy" }, () =>
         Object.freeze({
-            type: "predeploy" as const,
+          type: "predeploy" as const,
         })
       )
       .exhaustive(),
@@ -519,11 +540,9 @@ export function serviceProto(service: SerializedService): Service {
 // This is used as an intermediate step to convert a protobuf Service to a ClientService
 export function serializedServiceFromProto({
   service,
-  name,
   isPredeploy,
 }: {
   service: Service;
-  name: string;
   isPredeploy?: boolean;
 }): SerializedService {
   const config = service.config;
@@ -534,7 +553,6 @@ export function serializedServiceFromProto({
   return match(config)
     .with({ case: "webConfig" }, ({ value }) => ({
       ...service,
-      name,
       run: service.runOptional ?? service.run,
       config: {
         type: "web" as const,
@@ -545,7 +563,6 @@ export function serializedServiceFromProto({
     }))
     .with({ case: "workerConfig" }, ({ value }) => ({
       ...service,
-      name,
       run: service.runOptional ?? service.run,
       config: {
         type: "worker" as const,
@@ -557,7 +574,6 @@ export function serializedServiceFromProto({
       isPredeploy
         ? {
             ...service,
-            name,
             run: service.runOptional ?? service.run,
             config: {
               type: "predeploy" as const,
@@ -565,7 +581,6 @@ export function serializedServiceFromProto({
           }
         : {
             ...service,
-            name,
             run: service.runOptional ?? service.run,
             config: {
               type: "job" as const,

+ 0 - 1
dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx

@@ -1,4 +1,3 @@
-import { PorterApp } from "@porter-dev/api-contracts";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import { PorterAppFormData } from "lib/porter-apps";

+ 5 - 6
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -207,7 +207,6 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
   const resetAllExceptName = () => {
     setIsNameHighlight(true);
 
-
     // Get the current name value before the reset
     setStep(0);
     const currentNameValue = porterAppFormMethods.getValues("app.name");
@@ -216,7 +215,6 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
     porterAppFormMethods.reset();
     // Set the name back to its original value
     porterAppFormMethods.setValue("app.name", currentNameValue);
-
   };
   const onSubmit = handleSubmit(async (data) => {
     try {
@@ -370,10 +368,10 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
   useEffect(() => {
     // set step to 1 if name is filled out
     if (isNameValid(name.value) && name.value) {
-      setIsNameHighlight(false);  // Reset highlight when the name is valid
+      setIsNameHighlight(false); // Reset highlight when the name is valid
       setStep((prev) => Math.max(prev, 1));
     } else {
-      resetAllExceptName()
+      resetAllExceptName();
     }
 
     // set step to 2 if source is filled out
@@ -665,8 +663,9 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                             }
                           >
                             {detectedServices.count > 0
-                              ? `Detected ${detectedServices.count} service${detectedServices.count > 1 ? "s" : ""
-                              } from porter.yaml.`
+                              ? `Detected ${detectedServices.count} service${
+                                  detectedServices.count > 1 ? "s" : ""
+                                } from porter.yaml.`
                               : `Could not detect any services from porter.yaml. Make sure it exists in the root of your repo.`}
                           </Text>
                         </AppearingDiv>

+ 1 - 1
go.mod

@@ -82,7 +82,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.2.17
+	github.com/porter-dev/api-contracts v0.2.18
 	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

@@ -1516,8 +1516,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.2.17 h1:VG7iRdl5GZ0DzPFUnyfo8BAkWDTQx3SH2bH2nYj6icA=
-github.com/porter-dev/api-contracts v0.2.17/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.18 h1:nqGQGOXAnqAaDSVG578YpkgmQBFTLK3dmLYjPEgDLS0=
+github.com/porter-dev/api-contracts v0.2.18/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=

+ 2 - 2
internal/porter_app/parse.go

@@ -32,7 +32,7 @@ func ParseYAML(ctx context.Context, porterYaml []byte, appName string) (v2.AppWi
 		return appDefinition, telemetry.Error(ctx, span, nil, "porter yaml input is nil")
 	}
 
-	version := &yamlVersion{}
+	version := &YamlVersion{}
 	err := yaml.Unmarshal(porterYaml, version)
 	if err != nil {
 		return appDefinition, telemetry.Error(ctx, span, err, "error unmarshaling porter yaml")
@@ -77,6 +77,6 @@ func ParseYAML(ctx context.Context, porterYaml []byte, appName string) (v2.AppWi
 }
 
 // yamlVersion is a struct used to unmarshal the version field of a Porter YAML file
-type yamlVersion struct {
+type YamlVersion struct {
 	Version PorterYamlVersion `yaml:"version"`
 }

+ 131 - 0
internal/porter_app/test/parse_test.go

@@ -49,6 +49,7 @@ var result_nobuild = &porterv1.PorterApp{
 	Name: "test-app",
 	Services: map[string]*porterv1.Service{
 		"example-web": {
+			Name:         "example-web",
 			RunOptional:  pointer.String("node index.js"),
 			Instances:    0,
 			Port:         8080,
@@ -80,6 +81,7 @@ var result_nobuild = &porterv1.PorterApp{
 			Type: 1,
 		},
 		"example-wkr": {
+			Name:         "example-wkr",
 			RunOptional:  pointer.String("echo 'work'"),
 			Instances:    1,
 			Port:         80,
@@ -93,6 +95,70 @@ var result_nobuild = &porterv1.PorterApp{
 			Type: 2,
 		},
 		"example-job": {
+			Name:         "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,
+		},
+	},
+	ServiceList: []*porterv1.Service{
+		{
+			Name:         "example-web",
+			RunOptional:  pointer.String("node index.js"),
+			Instances:    0,
+			Port:         8080,
+			CpuCores:     0.1,
+			RamMegabytes: 256,
+			Config: &porterv1.Service_WebConfig{
+				WebConfig: &porterv1.WebServiceConfig{
+					Autoscaling: &porterv1.Autoscaling{
+						Enabled:                true,
+						MinInstances:           1,
+						MaxInstances:           3,
+						CpuThresholdPercent:    60,
+						MemoryThresholdPercent: 60,
+					},
+					Domains: []*porterv1.Domain{
+						{
+							Name: "test1.example.com",
+						},
+						{
+							Name: "test2.example.com",
+						},
+					},
+					HealthCheck: &porterv1.HealthCheck{
+						Enabled:  true,
+						HttpPath: "/healthz",
+					},
+				},
+			},
+			Type: 1,
+		},
+		{
+			Name:         "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,
+		},
+		{
+			Name:         "example-job",
 			RunOptional:  pointer.String("echo 'hello world'"),
 			CpuCores:     0.1,
 			RamMegabytes: 256,
@@ -126,6 +192,7 @@ var v1_result_nobuild_no_image = &porterv1.PorterApp{
 	Name: "test-app",
 	Services: map[string]*porterv1.Service{
 		"example-job": {
+			Name:         "example-job",
 			RunOptional:  pointer.String("echo 'hello world'"),
 			CpuCores:     0.1,
 			RamMegabytes: 256,
@@ -138,6 +205,7 @@ var v1_result_nobuild_no_image = &porterv1.PorterApp{
 			Type: 3,
 		},
 		"example-wkr": {
+			Name:         "example-wkr",
 			RunOptional:  pointer.String("echo 'work'"),
 			Instances:    1,
 			Port:         80,
@@ -151,6 +219,69 @@ var v1_result_nobuild_no_image = &porterv1.PorterApp{
 			Type: 2,
 		},
 		"example-web": {
+			Name:         "example-web",
+			RunOptional:  pointer.String("node index.js"),
+			Instances:    0,
+			Port:         8080,
+			CpuCores:     0.1,
+			RamMegabytes: 256,
+			Config: &porterv1.Service_WebConfig{
+				WebConfig: &porterv1.WebServiceConfig{
+					Autoscaling: &porterv1.Autoscaling{
+						Enabled:                true,
+						MinInstances:           1,
+						MaxInstances:           3,
+						CpuThresholdPercent:    60,
+						MemoryThresholdPercent: 60,
+					},
+					Domains: []*porterv1.Domain{
+						{
+							Name: "test1.example.com",
+						},
+						{
+							Name: "test2.example.com",
+						},
+					},
+					HealthCheck: &porterv1.HealthCheck{
+						Enabled:  true,
+						HttpPath: "/healthz",
+					},
+					Private: pointer.Bool(false),
+				},
+			},
+			Type: 1,
+		},
+	},
+	ServiceList: []*porterv1.Service{
+		{
+			Name:         "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,
+		},
+		{
+			Name:         "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,
+		},
+		{
+			Name:         "example-web",
 			RunOptional:  pointer.String("node index.js"),
 			Instances:    0,
 			Port:         8080,

+ 1 - 1
internal/porter_app/testdata/v1_input_no_build_no_image.yaml

@@ -92,4 +92,4 @@ release:
   run: ls
 env:
   PORT: '8080'
-  NODE_ENV: 'production'
+  NODE_ENV: 'production'

+ 3 - 3
internal/porter_app/testdata/v2_input_no_build_no_env.yaml

@@ -4,7 +4,7 @@ image:
   repository: nginx
   tag: latest
 services:
-  example-web:
+  - name: example-web
     type: web
     run: node index.js
     port: 8080
@@ -22,14 +22,14 @@ services:
     healthCheck:
       enabled: true
       httpPath: /healthz
-  example-wkr:
+  - name: example-wkr
     type: worker
     run: echo 'work'
     port: 80
     cpuCores: 0.1
     ramMegabytes: 256
     instances: 1
-  example-job:
+  - name: example-job
     type: job
     run: echo 'hello world'
     allowConcurrent: true

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

@@ -4,7 +4,7 @@ image:
   repository: nginx
   tag: latest
 services:
-  example-web:
+  - name: example-web
     type: web
     run: node index.js
     port: 8080
@@ -22,14 +22,14 @@ services:
     healthCheck:
       enabled: true
       httpPath: /healthz
-  example-wkr:
+  - name: example-wkr
     type: worker
     run: echo 'work'
     port: 80
     cpuCores: 0.1
     ramMegabytes: 256
     instances: 1
-  example-job:
+  - name: example-job
     type: job
     run: echo 'hello world'
     allowConcurrent: true

+ 9 - 3
internal/porter_app/v1/yaml.go

@@ -70,7 +70,10 @@ func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte) (*porterv1.Po
 		return nil, nil, telemetry.Error(ctx, span, nil, "porter yaml is missing services")
 	}
 
-	serviceProtoMap := make(map[string]*porterv1.Service, 0)
+	// service map is only needed for backwards compatibility at this time
+	serviceMap := make(map[string]*porterv1.Service)
+	var serviceList []*porterv1.Service
+
 	for name, service := range services {
 		serviceType := protoEnumFromType(name, service)
 
@@ -79,10 +82,13 @@ func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte) (*porterv1.Po
 			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "failing-service-name", Value: name})
 			return nil, nil, telemetry.Error(ctx, span, err, "error casting service config")
 		}
+		serviceProto.Name = name
 
-		serviceProtoMap[name] = serviceProto
+		serviceList = append(serviceList, serviceProto)
+		serviceMap[name] = serviceProto
 	}
-	appProto.Services = serviceProtoMap
+	appProto.ServiceList = serviceList
+	appProto.Services = serviceMap // nolint:staticcheck // temporarily using deprecated field for backwards compatibility
 
 	if porterYaml.Release != nil {
 		predeployProto, err := serviceProtoFromConfig(*porterYaml.Release, porterv1.ServiceType_SERVICE_TYPE_JOB)

+ 101 - 40
internal/porter_app/v2/yaml.go

@@ -40,7 +40,7 @@ func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte) (AppWithPrevi
 		return out, telemetry.Error(ctx, span, err, "error unmarshaling porter yaml")
 	}
 
-	appProto, envVariables, err := buildAppProto(ctx, porterYaml.PorterApp)
+	appProto, envVariables, err := ProtoFromApp(ctx, porterYaml.PorterApp)
 	if err != nil {
 		return out, telemetry.Error(ctx, span, err, "error converting porter yaml to proto")
 	}
@@ -48,7 +48,7 @@ func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte) (AppWithPrevi
 	out.EnvVariables = envVariables
 
 	if porterYaml.Previews != nil {
-		previewAppProto, previewEnvVariables, err := buildAppProto(ctx, *porterYaml.Previews)
+		previewAppProto, previewEnvVariables, err := ProtoFromApp(ctx, *porterYaml.Previews)
 		if err != nil {
 			return out, telemetry.Error(ctx, span, err, "error converting preview porter yaml to proto")
 		}
@@ -61,17 +61,35 @@ func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte) (AppWithPrevi
 	return out, nil
 }
 
+// ServiceType is the type of a service in a Porter YAML file
+type ServiceType string
+
+const (
+	// ServiceType_Web is type for web services specified in Porter YAML
+	ServiceType_Web ServiceType = "web"
+	// ServiceType_Worker is type for worker services specified in Porter YAML
+	ServiceType_Worker ServiceType = "worker"
+	// ServiceType_Job is type for job services specified in Porter YAML
+	ServiceType_Job ServiceType = "job"
+)
+
+// EnvGroup is a struct containing the name and version of an environment group
+type EnvGroup struct {
+	Name    string `yaml:"name"`
+	Version int    `yaml:"version"`
+}
+
 // 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,omitempty"`
-	Build    *Build             `yaml:"build,omitempty"`
-	Env      map[string]string  `yaml:"env,omitempty"`
-
-	Predeploy *Service `yaml:"predeploy,omitempty"`
-	EnvGroups []string `yaml:"envGroups,omitempty"`
+	Version  string            `yaml:"version,omitempty"`
+	Name     string            `yaml:"name"`
+	Services []Service         `yaml:"services"`
+	Image    *Image            `yaml:"image,omitempty"`
+	Build    *Build            `yaml:"build,omitempty"`
+	Env      map[string]string `yaml:"env,omitempty"`
+
+	Predeploy *Service   `yaml:"predeploy,omitempty"`
+	EnvGroups []EnvGroup `yaml:"envGroups,omitempty"`
 }
 
 // PorterYAML represents all the possible fields in a Porter YAML file
@@ -87,6 +105,7 @@ type Build struct {
 	Builder    string   `yaml:"builder" validate:"required_if=Method pack"`
 	Buildpacks []string `yaml:"buildpacks"`
 	Dockerfile string   `yaml:"dockerfile" validate:"required_if=Method docker"`
+	CommitSHA  string   `yaml:"commitSha"`
 }
 
 // Image is the repository and tag for an app's build image
@@ -97,21 +116,23 @@ type Image struct {
 
 // Service represents a single service in a porter app
 type Service struct {
-	Run               *string      `yaml:"run,omitempty"`
-	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,omitempty" validate:"excluded_unless=Type web"`
-	HealthCheck       *HealthCheck `yaml:"healthCheck,omitempty" 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"`
+	Name               string            `yaml:"name,omitempty"`
+	Run                *string           `yaml:"run,omitempty"`
+	Type               ServiceType       `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,omitempty" validate:"excluded_unless=Type web"`
+	HealthCheck        *HealthCheck      `yaml:"healthCheck,omitempty" 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"`
+	IngressAnnotations map[string]string `yaml:"ingressAnnotations,omitempty" validate:"excluded_unless=Type web"`
 }
 
 // AutoScaling represents the autoscaling settings for web services
@@ -134,7 +155,8 @@ type HealthCheck struct {
 	HttpPath string `yaml:"httpPath"`
 }
 
-func buildAppProto(ctx context.Context, porterApp PorterApp) (*porterv1.PorterApp, map[string]string, error) {
+// ProtoFromApp converts a PorterApp type to a base PorterApp proto type and returns env variables
+func ProtoFromApp(ctx context.Context, porterApp PorterApp) (*porterv1.PorterApp, map[string]string, error) {
 	ctx, span := telemetry.NewSpan(ctx, "build-app-proto")
 	defer span.End()
 
@@ -149,6 +171,7 @@ func buildAppProto(ctx context.Context, porterApp PorterApp) (*porterv1.PorterAp
 			Builder:    porterApp.Build.Builder,
 			Buildpacks: porterApp.Build.Buildpacks,
 			Dockerfile: porterApp.Build.Dockerfile,
+			CommitSha:  porterApp.Build.CommitSHA,
 		}
 	}
 
@@ -163,18 +186,26 @@ func buildAppProto(ctx context.Context, porterApp PorterApp) (*porterv1.PorterAp
 		return appProto, nil, telemetry.Error(ctx, span, nil, "porter yaml is missing services")
 	}
 
-	services := make(map[string]*porterv1.Service, 0)
-	for name, service := range porterApp.Services {
-		serviceType := protoEnumFromType(name, service)
+	// service map is only needed for backwards compatibility at this time
+	serviceMap := make(map[string]*porterv1.Service)
+	var services []*porterv1.Service
+
+	for _, service := range porterApp.Services {
+		serviceType := protoEnumFromType(service.Name, service)
 
 		serviceProto, err := serviceProtoFromConfig(service, serviceType)
 		if err != nil {
 			return appProto, nil, telemetry.Error(ctx, span, err, "error casting service config")
 		}
+		if service.Name == "" {
+			return appProto, nil, telemetry.Error(ctx, span, nil, "service found with no name")
+		}
 
-		services[name] = serviceProto
+		services = append(services, serviceProto)
+		serviceMap[service.Name] = serviceProto
 	}
-	appProto.Services = services
+	appProto.ServiceList = services
+	appProto.Services = serviceMap // nolint:staticcheck // temporarily using deprecated field for backwards compatibility
 
 	if porterApp.Predeploy != nil {
 		predeployProto, err := serviceProtoFromConfig(*porterApp.Predeploy, porterv1.ServiceType_SERVICE_TYPE_JOB)
@@ -186,9 +217,10 @@ func buildAppProto(ctx context.Context, porterApp PorterApp) (*porterv1.PorterAp
 
 	envGroups := make([]*porterv1.EnvGroup, 0)
 	if porterApp.EnvGroups != nil {
-		for _, envGroupName := range porterApp.EnvGroups {
+		for _, envGroup := range porterApp.EnvGroups {
 			envGroups = append(envGroups, &porterv1.EnvGroup{
-				Name: envGroupName,
+				Name:    envGroup.Name,
+				Version: int64(envGroup.Version),
 			})
 		}
 	}
@@ -224,6 +256,7 @@ func protoEnumFromType(name string, service Service) porterv1.ServiceType {
 
 func serviceProtoFromConfig(service Service, serviceType porterv1.ServiceType) (*porterv1.Service, error) {
 	serviceProto := &porterv1.Service{
+		Name:              service.Name,
 		RunOptional:       service.Run,
 		Instances:         int32(service.Instances),
 		CpuCores:          service.CpuCores,
@@ -270,6 +303,8 @@ func serviceProtoFromConfig(service Service, serviceType porterv1.ServiceType) (
 		}
 		webConfig.Domains = domains
 
+		webConfig.IngressAnnotations = service.IngressAnnotations
+
 		if service.Private != nil {
 			webConfig.Private = service.Private
 		}
@@ -329,6 +364,7 @@ func AppFromProto(appProto *porterv1.PorterApp) (PorterApp, error) {
 			Builder:    appProto.Build.Builder,
 			Buildpacks: appProto.Build.Buildpacks,
 			Dockerfile: appProto.Build.Dockerfile,
+			CommitSHA:  appProto.Build.CommitSha,
 		}
 	}
 
@@ -339,14 +375,13 @@ func AppFromProto(appProto *porterv1.PorterApp) (PorterApp, error) {
 		}
 	}
 
-	porterApp.Services = make(map[string]Service, 0)
-	for name, service := range appProto.Services {
+	uniqueServices := uniqueServices(appProto.Services, appProto.ServiceList) // nolint:staticcheck // temporarily using deprecated field for backwards compatibility
+	for _, service := range uniqueServices {
 		appService, err := appServiceFromProto(service)
 		if err != nil {
 			return porterApp, err
 		}
-
-		porterApp.Services[name] = appService
+		porterApp.Services = append(porterApp.Services, appService)
 	}
 
 	if appProto.Predeploy != nil {
@@ -358,9 +393,12 @@ func AppFromProto(appProto *porterv1.PorterApp) (PorterApp, error) {
 		porterApp.Predeploy = &appPredeploy
 	}
 
-	porterApp.EnvGroups = make([]string, 0)
+	porterApp.EnvGroups = make([]EnvGroup, 0)
 	for _, envGroup := range appProto.EnvGroups {
-		porterApp.EnvGroups = append(porterApp.EnvGroups, envGroup.Name)
+		porterApp.EnvGroups = append(porterApp.EnvGroups, EnvGroup{
+			Name:    envGroup.Name,
+			Version: int(envGroup.Version),
+		})
 	}
 
 	return porterApp, nil
@@ -368,6 +406,7 @@ func AppFromProto(appProto *porterv1.PorterApp) (PorterApp, error) {
 
 func appServiceFromProto(service *porterv1.Service) (Service, error) {
 	appService := Service{
+		Name:              service.Name,
 		Run:               service.RunOptional,
 		Instances:         int(service.Instances),
 		CpuCores:          service.CpuCores,
@@ -414,6 +453,8 @@ func appServiceFromProto(service *porterv1.Service) (Service, error) {
 		}
 		appService.Domains = domains
 
+		appService.IngressAnnotations = webConfig.IngressAnnotations
+
 		if webConfig.Private != nil {
 			appService.Private = webConfig.Private
 		}
@@ -444,3 +485,23 @@ func appServiceFromProto(service *porterv1.Service) (Service, error) {
 
 	return appService, nil
 }
+
+func uniqueServices(serviceMap map[string]*porterv1.Service, serviceList []*porterv1.Service) []*porterv1.Service {
+	if serviceList != nil {
+		return serviceList
+	}
+
+	// deduplicate services by name, favoring whatever was defined first
+	uniqueServices := make(map[string]*porterv1.Service)
+	for name, service := range serviceMap {
+		service.Name = name
+		uniqueServices[service.Name] = service
+	}
+
+	mergedServiceList := make([]*porterv1.Service, 0)
+	for _, service := range uniqueServices {
+		mergedServiceList = append(mergedServiceList, service)
+	}
+
+	return mergedServiceList
+}