2
0
Ian Edwards 2 жил өмнө
parent
commit
80f1dd4f8d

+ 33 - 20
api/server/handlers/porter_app/parse_yaml.go

@@ -49,13 +49,18 @@ type EncodedAppWithEnv struct {
 	EnvSecrets   map[string]string `json:"env_secrets"`
 }
 
-// ParsePorterYAMLToProtoResponse is the response object for the /apps/parse endpoint
-type ParsePorterYAMLToProtoResponse struct {
+// EncodedAppDefinition is a full app definition with encoded app proto and env variables
+type EncodedAppDefinition struct {
 	EncodedAppWithEnv
 	// PreviewApp contains preview environment specific overrides, if they exist
 	PreviewApp *EncodedAppWithEnv `json:"preview_app,omitempty"`
 }
 
+// ParsePorterYAMLToProtoResponse is the response object for the /apps/parse endpoint
+type ParsePorterYAMLToProtoResponse struct {
+	ParsedApps []EncodedAppDefinition `json:"parsed_apps"`
+}
+
 // ServeHTTP receives a base64-encoded porter.yaml, parses the version, and then translates it into a base64-encoded app proto object
 func (c *ParsePorterYAMLToProtoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	ctx, span := telemetry.NewSpan(r.Context(), "serve-parse-porter-yaml")
@@ -94,41 +99,49 @@ func (c *ParsePorterYAMLToProtoHandler) ServeHTTP(w http.ResponseWriter, r *http
 		return
 	}
 
-	appDefinition, err := porter_app.ParseYAML(ctx, yaml, request.AppName)
+	appDefinitions, err := porter_app.ParseYAML(ctx, yaml, request.AppName)
 	if err != nil {
 		err := telemetry.Error(ctx, span, err, "error parsing yaml")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
-	if appDefinition.AppProto == nil {
+	if appDefinitions == nil {
 		err := telemetry.Error(ctx, span, nil, "app proto is nil")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	response := &ParsePorterYAMLToProtoResponse{}
-
-	encodedApp, err := encodeAppProto(ctx, appDefinition.AppProto)
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error encoding app proto")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
+	response := &ParsePorterYAMLToProtoResponse{
+		ParsedApps: make([]EncodedAppDefinition, 0),
 	}
-	response.B64AppProto = encodedApp
-	response.EnvVariables = appDefinition.EnvVariables
 
-	if appDefinition.PreviewApp != nil {
-		encodedPreviewApp, err := encodeAppProto(ctx, appDefinition.PreviewApp.AppProto)
+	for _, appDefinition := range appDefinitions {
+		var app EncodedAppDefinition
+
+		encodedApp, err := encodeAppProto(ctx, appDefinition.AppProto)
 		if err != nil {
-			err := telemetry.Error(ctx, span, err, "error encoding preview app proto")
+			err := telemetry.Error(ctx, span, err, "error encoding app proto")
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 			return
 		}
-		response.PreviewApp = &EncodedAppWithEnv{
-			B64AppProto:  encodedPreviewApp,
-			EnvVariables: appDefinition.PreviewApp.EnvVariables,
+		app.B64AppProto = encodedApp
+		app.EnvVariables = appDefinition.EnvVariables
+
+		if appDefinition.PreviewApp != nil {
+			encodedPreviewApp, err := encodeAppProto(ctx, appDefinition.PreviewApp.AppProto)
+			if err != nil {
+				err := telemetry.Error(ctx, span, err, "error encoding preview app proto")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+				return
+			}
+			app.PreviewApp = &EncodedAppWithEnv{
+				B64AppProto:  encodedPreviewApp,
+				EnvVariables: appDefinition.PreviewApp.EnvVariables,
+			}
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "includes-preview-app", Value: true})
 		}
-		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "includes-preview-app", Value: true})
+
+		response.ParsedApps = append(response.ParsedApps, app)
 	}
 
 	c.WriteResult(w, r, response)

+ 17 - 8
cli/cmd/v2/apply.go

@@ -93,19 +93,28 @@ func Apply(ctx context.Context, inp ApplyInput) error {
 			return fmt.Errorf("error calling parse yaml endpoint: %w", err)
 		}
 
-		if parseResp.B64AppProto == "" {
+		if len(parseResp.ParsedApps) == 0 {
+			return errors.New("parsed apps is empty")
+		}
+		if len(parseResp.ParsedApps) > 1 {
+			return errors.New("multiple apps are currently not supported in a single porter yaml")
+		}
+
+		parsedApp := parseResp.ParsedApps[0]
+
+		if parsedApp.B64AppProto == "" {
 			return errors.New("b64 app proto is empty")
 		}
-		b64AppProto = parseResp.B64AppProto
+		b64AppProto = parsedApp.B64AppProto
 
 		// override app name if provided
-		appName, err = appNameFromB64AppProto(parseResp.B64AppProto)
+		appName, err = appNameFromB64AppProto(parsedApp.B64AppProto)
 		if err != nil {
 			return fmt.Errorf("error getting app name from porter.yaml: %w", err)
 		}
 
 		// we only need to create the app if a porter yaml is provided (otherwise it must already exist)
-		createPorterAppDBEntryInp, err := createPorterAppDbEntryInputFromProtoAndEnv(parseResp.B64AppProto)
+		createPorterAppDBEntryInp, err := createPorterAppDbEntryInputFromProtoAndEnv(parsedApp.B64AppProto)
 		if err != nil {
 			return fmt.Errorf("unable to form porter app creation input from yaml: %w", err)
 		}
@@ -118,7 +127,7 @@ func Apply(ctx context.Context, inp ApplyInput) error {
 			return fmt.Errorf("unable to create porter app from yaml: %w", err)
 		}
 
-		envGroupResp, err := client.CreateOrUpdateAppEnvironment(ctx, cliConf.Project, cliConf.Cluster, appName, deploymentTargetID, parseResp.EnvVariables, parseResp.EnvSecrets, parseResp.B64AppProto)
+		envGroupResp, err := client.CreateOrUpdateAppEnvironment(ctx, cliConf.Project, cliConf.Cluster, appName, deploymentTargetID, parsedApp.EnvVariables, parsedApp.EnvSecrets, parsedApp.B64AppProto)
 		if err != nil {
 			return fmt.Errorf("error calling create or update app environment group endpoint: %w", err)
 		}
@@ -128,10 +137,10 @@ func Apply(ctx context.Context, inp ApplyInput) error {
 			return fmt.Errorf("error updating app env group in proto: %w", err)
 		}
 
-		if inp.PreviewApply && parseResp.PreviewApp != nil {
-			b64AppOverrides = parseResp.PreviewApp.B64AppProto
+		if inp.PreviewApply && parsedApp.PreviewApp != nil {
+			b64AppOverrides = parsedApp.PreviewApp.B64AppProto
 
-			envGroupResp, err := client.CreateOrUpdateAppEnvironment(ctx, cliConf.Project, cliConf.Cluster, appName, deploymentTargetID, parseResp.PreviewApp.EnvVariables, parseResp.PreviewApp.EnvSecrets, parseResp.PreviewApp.B64AppProto)
+			envGroupResp, err := client.CreateOrUpdateAppEnvironment(ctx, cliConf.Project, cliConf.Cluster, appName, deploymentTargetID, parsedApp.PreviewApp.EnvVariables, parsedApp.PreviewApp.EnvSecrets, parsedApp.PreviewApp.B64AppProto)
 			if err != nil {
 				return fmt.Errorf("error calling create or update app environment group endpoint: %w", err)
 			}

+ 27 - 16
dashboard/src/lib/hooks/usePorterYaml.ts

@@ -115,22 +115,33 @@ export const usePorterYaml = ({
 
         const data = await z
           .object({
-            b64_app_proto: z.string(),
-            env_variables: z.record(z.string()).nullable(),
-            env_secrets: z.record(z.string()).nullable(),
-            preview_app: z
-              .object({
-                b64_app_proto: z.string(),
-                env_variables: z.record(z.string()).nullable(),
-                env_secrets: z.record(z.string()).nullable(),
-              })
-              .optional(),
+            parsed_apps: z
+              .array(
+                z.object({
+                  b64_app_proto: z.string(),
+                  env_variables: z.record(z.string()).nullable(),
+                  env_secrets: z.record(z.string()).nullable(),
+                  preview_app: z
+                    .object({
+                      b64_app_proto: z.string(),
+                      env_variables: z.record(z.string()).nullable(),
+                      env_secrets: z.record(z.string()).nullable(),
+                    })
+                    .optional(),
+                })
+              )
+              .min(1),
           })
           .parseAsync(res.data);
 
-        const proto = PorterApp.fromJsonString(atob(data.b64_app_proto), {
-          ignoreUnknownFields: true,
-        });
+        const appDefinition = data.parsed_apps[0];
+
+        const proto = PorterApp.fromJsonString(
+          atob(appDefinition.b64_app_proto),
+          {
+            ignoreUnknownFields: true,
+          }
+        );
 
         const { services, predeploy, build } = serviceOverrides({
           overrides: proto,
@@ -147,9 +158,9 @@ export const usePorterYaml = ({
           });
         }
 
-        if (data.preview_app) {
+        if (appDefinition.preview_app) {
           const previewProto = PorterApp.fromJsonString(
-            atob(data.preview_app.b64_app_proto),
+            atob(appDefinition.preview_app.b64_app_proto),
             {
               ignoreUnknownFields: true,
             }
@@ -171,7 +182,7 @@ export const usePorterYaml = ({
                 services: previewServices,
                 predeploy: previewPredeploy,
                 build: previewBuild,
-                variables: data.preview_app?.env_variables ?? {},
+                variables: appDefinition.preview_app?.env_variables ?? {},
               },
             }));
           }

+ 32 - 19
internal/porter_app/parse.go

@@ -22,58 +22,71 @@ const (
 )
 
 // ParseYAML converts a Porter YAML file into a PorterApp proto object
-func ParseYAML(ctx context.Context, porterYaml []byte, appName string) (v2.AppWithPreviewOverrides, error) {
+func ParseYAML(ctx context.Context, porterYaml []byte, appName string) ([]v2.AppWithPreviewOverrides, error) {
 	ctx, span := telemetry.NewSpan(ctx, "porter-app-parse-yaml")
 	defer span.End()
 
-	var appDefinition v2.AppWithPreviewOverrides
+	var appDefinitions []v2.AppWithPreviewOverrides
 
 	if porterYaml == nil {
-		return appDefinition, telemetry.Error(ctx, span, nil, "porter yaml input is nil")
+		return appDefinitions, telemetry.Error(ctx, span, nil, "porter yaml input is nil")
 	}
 
 	version := &YamlVersion{}
 	err := yaml.Unmarshal(porterYaml, version)
 	if err != nil {
-		return appDefinition, telemetry.Error(ctx, span, err, "error unmarshaling porter yaml")
+		return appDefinitions, telemetry.Error(ctx, span, err, "error unmarshaling porter yaml")
 	}
 
 	switch version.Version {
 	case PorterYamlVersion_V2:
-		appDefinition, err = v2.AppProtoFromYaml(ctx, porterYaml)
+		appDefinitions, err = v2.AppProtoFromYaml(ctx, porterYaml, appName)
 		if err != nil {
-			return appDefinition, telemetry.Error(ctx, span, err, "error converting v2 yaml to proto")
+			return appDefinitions, telemetry.Error(ctx, span, err, "error converting v2 yaml to proto")
 		}
 	// backwards compatibility for old porter.yaml files
 	// track this span in telemetry and reach out to customers who are still using old porter.yaml if they exist.
 	// once no one is converting from old porter.yaml, we can remove this code
 	case PorterYamlVersion_V1, "":
-		appProto, envVariables, err := v1.AppProtoFromYaml(ctx, porterYaml)
+		appProto, envVariables, err := v1.AppProtoFromYaml(ctx, porterYaml, appName)
 		if err != nil {
-			return appDefinition, telemetry.Error(ctx, span, err, "error converting v1 yaml to proto")
+			return appDefinitions, telemetry.Error(ctx, span, err, "error converting v1 yaml to proto")
 		}
 
-		appDefinition.AppProto = appProto
-		appDefinition.EnvVariables = envVariables
+		appDefinition := v2.AppWithPreviewOverrides{
+			AppProtoWithEnv: v2.AppProtoWithEnv{
+				AppProto:     appProto,
+				EnvVariables: envVariables,
+			},
+		}
+
+		appDefinitions = append(appDefinitions, appDefinition)
 	default:
-		return appDefinition, telemetry.Error(ctx, span, nil, "porter yaml version not supported")
+		return appDefinitions, telemetry.Error(ctx, span, nil, "porter yaml version not supported")
 	}
 
-	if appDefinition.AppProto == nil {
-		return appDefinition, telemetry.Error(ctx, span, nil, "porter yaml output is nil")
+	if appDefinitions == nil {
+		return appDefinitions, telemetry.Error(ctx, span, nil, "porter yaml output is nil")
+	}
+
+	var found bool
+	for _, appDefinition := range appDefinitions {
+		if appDefinition.AppProto.Name == "" {
+			return appDefinitions, telemetry.Error(ctx, span, nil, "all apps must have a name")
+		}
+		if appDefinition.AppProto.Name == appName {
+			found = true
+		}
 	}
 
 	if appName != "" {
 		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "override-name", Value: appName})
-		if appDefinition.AppProto.Name != "" && appDefinition.AppProto.Name != appName {
-			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "parsed-name", Value: appDefinition.AppProto.Name})
-			return appDefinition, telemetry.Error(ctx, span, nil, "name specified in porter.yaml does not match app name")
+		if !found {
+			return appDefinitions, telemetry.Error(ctx, span, nil, "no app names specified in porter.yaml match provided app name")
 		}
-
-		appDefinition.AppProto.Name = appName
 	}
 
-	return appDefinition, nil
+	return appDefinitions, nil
 }
 
 // yamlVersion is a struct used to unmarshal the version field of a Porter YAML file

+ 92 - 9
internal/porter_app/test/parse_test.go

@@ -19,9 +19,10 @@ import (
 func TestParseYAML(t *testing.T) {
 	tests := []struct {
 		porterYamlFileName string
-		want               *porterv1.PorterApp
+		want               []*porterv1.PorterApp
 	}{
 		{"v2_input_nobuild", result_nobuild},
+		{"v2_input_multi_app", result_multi_app},
 		{"v1_input_no_build_no_image", v1_result_nobuild_no_image},
 	}
 
@@ -35,17 +36,19 @@ func TestParseYAML(t *testing.T) {
 			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)
+			for i, app := range got {
+				diffProtoWithFailTest(t, is, tt.want[i], app.AppProto)
 
-			is.Equal(got.EnvVariables, map[string]string{
-				"PORT":     "8080",
-				"NODE_ENV": "production",
-			})
+				is.Equal(app.EnvVariables, map[string]string{
+					"PORT":     "8080",
+					"NODE_ENV": "production",
+				})
+			}
 		})
 	}
 }
 
-var result_nobuild = &porterv1.PorterApp{
+var result_nobuild = []*porterv1.PorterApp{{
 	Name: "test-app",
 	Services: map[string]*porterv1.Service{
 		"example-web": {
@@ -186,9 +189,89 @@ var result_nobuild = &porterv1.PorterApp{
 		Repository: "nginx",
 		Tag:        "latest",
 	},
+}}
+
+var result_multi_app = []*porterv1.PorterApp{
+	result_nobuild[0],
+	{
+		Name: "next-test",
+		Services: map[string]*porterv1.Service{
+			"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",
+						},
+					},
+				},
+				Type: 1,
+			},
+		},
+		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,
+			},
+		},
+		Build: &porterv1.Build{
+			Method:     "docker",
+			Context:    "./",
+			Dockerfile: "Dockerfile",
+		},
+	},
 }
 
-var v1_result_nobuild_no_image = &porterv1.PorterApp{
+var v1_result_nobuild_no_image = []*porterv1.PorterApp{{
 	Name: "test-app",
 	Services: map[string]*porterv1.Service{
 		"example-job": {
@@ -323,7 +406,7 @@ var v1_result_nobuild_no_image = &porterv1.PorterApp{
 		Config:       &porterv1.Service_JobConfig{},
 		Type:         3,
 	},
-}
+}}
 
 func diffProtoWithFailTest(t *testing.T, is *is.I, want, got *porterv1.PorterApp) {
 	t.Helper()

+ 3 - 3
internal/porter_app/test/porter_app_to_yaml_test.go

@@ -17,7 +17,7 @@ import (
 func TestPorterAppToYAML(t *testing.T) {
 	tests := []struct {
 		porterYamlFileName string
-		want               *porterv1.PorterApp
+		want               []*porterv1.PorterApp
 	}{
 		{"v2_input_no_build_no_env", result_nobuild},
 	}
@@ -29,10 +29,10 @@ func TestPorterAppToYAML(t *testing.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")
+			porterAppProtos, 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)
+			porterApp, err := v2.AppFromProto(porterAppProtos[0].AppProto)
 			is.NoErr(err) // app proto should be converted back to porter app representation (unmarshaled porter yaml) without issues
 
 			diffPorterAppWithOriginalYamlTest(t, is, originalYaml, porterApp)

+ 74 - 0
internal/porter_app/testdata/v2_input_multi_app.yaml

@@ -0,0 +1,74 @@
+version: v2
+apps:
+  - name: test-app
+    image:
+      repository: nginx
+      tag: latest
+    services:
+      - name: 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
+      - name: example-wkr
+        type: worker
+        run: echo 'work'
+        port: 80
+        cpuCores: 0.1
+        ramMegabytes: 256
+        instances: 1
+      - name: 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
+    env:
+      PORT: 8080
+      NODE_ENV: production
+  - name: next-test
+    build:
+      method: docker
+      context: ./
+      dockerfile: Dockerfile
+    services:
+      - name: 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
+    env:
+      PORT: 8080
+      NODE_ENV: production

+ 4 - 1
internal/porter_app/v1/yaml.go

@@ -15,7 +15,7 @@ import (
 )
 
 // AppProtoFromYaml converts an old version Porter YAML file into a PorterApp proto object
-func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte) (*porterv1.PorterApp, map[string]string, error) {
+func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte, providedName string) (*porterv1.PorterApp, map[string]string, error) {
 	ctx, span := telemetry.NewSpan(ctx, "v1-app-proto-from-yaml")
 	defer span.End()
 
@@ -31,6 +31,9 @@ func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte) (*porterv1.Po
 
 	appProto := &porterv1.PorterApp{}
 
+	if providedName != "" {
+		appProto.Name = providedName
+	}
 	if porterYaml.Build != nil {
 		appProto.Build = &porterv1.Build{
 			Context:    porterYaml.Build.Context,

+ 68 - 17
internal/porter_app/v2/yaml.go

@@ -24,43 +24,83 @@ type AppWithPreviewOverrides struct {
 }
 
 // AppProtoFromYaml converts a Porter YAML file into a PorterApp proto object
-func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte) (AppWithPreviewOverrides, error) {
+func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte, providedName string) ([]AppWithPreviewOverrides, error) {
 	ctx, span := telemetry.NewSpan(ctx, "v2-app-proto-from-yaml")
 	defer span.End()
 
-	var out AppWithPreviewOverrides
+	var out []AppWithPreviewOverrides
 
 	if porterYamlBytes == nil {
 		return out, telemetry.Error(ctx, span, nil, "porter yaml is nil")
 	}
 
-	porterYaml := &PorterYAML{}
+	porterYaml := &PorterYAMLMultiApp{}
 	err := yaml.Unmarshal(porterYamlBytes, porterYaml)
-	if err != nil {
-		return out, telemetry.Error(ctx, span, err, "error unmarshaling porter yaml")
-	}
+	if err != nil || len(porterYaml.Apps) == 0 {
+		singleApp := &PorterYAML{}
+		err = yaml.Unmarshal(porterYamlBytes, singleApp)
+		if err != nil {
+			return out, telemetry.Error(ctx, span, err, "error unmarshaling porter yaml")
+		}
 
-	appProto, envVariables, err := ProtoFromApp(ctx, porterYaml.PorterApp)
-	if err != nil {
-		return out, telemetry.Error(ctx, span, err, "error converting porter yaml to proto")
+		if singleApp.PorterApp.Name == "" {
+			singleApp.PorterApp.Name = providedName
+		}
+		porterYaml.Apps = []PorterApp{singleApp.PorterApp}
+
+		if singleApp.Previews != nil {
+			porterYaml.Previews = &PorterAppWithAddons{
+				Apps: []PorterApp{*singleApp.Previews},
+			}
+		}
 	}
-	out.AppProto = appProto
-	out.EnvVariables = envVariables
 
-	if porterYaml.Previews != nil {
-		previewAppProto, previewEnvVariables, err := ProtoFromApp(ctx, *porterYaml.Previews)
+	for _, app := range porterYaml.Apps {
+		if app.Name == "" {
+			return out, telemetry.Error(ctx, span, nil, "app name is required")
+		}
+
+		appDefinition := AppWithPreviewOverrides{}
+		appProto, envVariables, err := ProtoFromApp(ctx, app)
 		if err != nil {
-			return out, telemetry.Error(ctx, span, err, "error converting preview porter yaml to proto")
+			return out, telemetry.Error(ctx, span, err, "error converting porter yaml to proto")
 		}
-		out.PreviewApp = &AppProtoWithEnv{
-			AppProto:     previewAppProto,
-			EnvVariables: previewEnvVariables,
+		appDefinition.AppProto = appProto
+		appDefinition.EnvVariables = envVariables
+
+		if porterYaml.Previews != nil {
+			correspondingOverrides := findPreviewApp(porterYaml.Previews.Apps, app.Name)
+			if correspondingOverrides != nil {
+				previewAppProto, previewEnvVariables, err := ProtoFromApp(ctx, *correspondingOverrides)
+				if err != nil {
+					return out, telemetry.Error(ctx, span, err, "error converting preview porter yaml to proto")
+				}
+				appDefinition.PreviewApp = &AppProtoWithEnv{
+					AppProto:     previewAppProto,
+					EnvVariables: previewEnvVariables,
+				}
+			}
 		}
+
+		out = append(out, appDefinition)
 	}
 
 	return out, nil
 }
 
+func findPreviewApp(previews []PorterApp, name string) *PorterApp {
+	var previewOverrides *PorterApp
+
+	for _, preview := range previews {
+		if preview.Name == name {
+			previewOverrides = &preview
+			break
+		}
+	}
+
+	return previewOverrides
+}
+
 // ServiceType is the type of a service in a Porter YAML file
 type ServiceType string
 
@@ -92,6 +132,17 @@ type PorterApp struct {
 	EnvGroups []EnvGroup `yaml:"envGroups,omitempty"`
 }
 
+// PorterAppWithAddons represents a list of porter app definitions and includes other dependencies, such as add ons or env group definitions
+type PorterAppWithAddons struct {
+	Apps []PorterApp `yaml:"apps"`
+}
+
+// PorterYAMLMultiApp represents a Porter YAML file that contains multiple apps
+type PorterYAMLMultiApp struct {
+	PorterAppWithAddons `yaml:",inline"`
+	Previews            *PorterAppWithAddons `yaml:"previews,omitempty"`
+}
+
 // PorterYAML represents all the possible fields in a Porter YAML file
 type PorterYAML struct {
 	PorterApp `yaml:",inline"`