Explorar el Código

initial yaml parse for cli (#3389)

Co-authored-by: David Townley <davidtownley@Davids-MacBook-Air.local>
d-g-town hace 2 años
padre
commit
5ea156464a

+ 26 - 0
api/client/porter_app.go

@@ -4,6 +4,8 @@ import (
 	"context"
 	"fmt"
 
+	"github.com/porter-dev/porter/api/server/handlers/porter_app"
+
 	"github.com/porter-dev/porter/api/types"
 )
 
@@ -146,3 +148,27 @@ func (c *Client) ListEnvGroups(
 
 	return *resp, err
 }
+
+// ParseYAML takes in a base64 encoded porter yaml and returns an app proto
+func (c *Client) ParseYAML(
+	ctx context.Context,
+	projectID, clusterID uint,
+	b64Yaml string,
+) (*porter_app.ParsePorterYAMLToProtoResponse, error) {
+	resp := &porter_app.ParsePorterYAMLToProtoResponse{}
+
+	req := &porter_app.ParsePorterYAMLToProtoRequest{
+		B64Yaml: b64Yaml,
+	}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/apps/parse",
+			projectID, clusterID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}

+ 111 - 0
api/server/handlers/porter_app/parse_yaml.go

@@ -0,0 +1,111 @@
+package porter_app
+
+import (
+	"encoding/base64"
+	"net/http"
+
+	"github.com/porter-dev/api-contracts/generated/go/helpers"
+
+	"github.com/porter-dev/porter/internal/porter_app"
+
+	"github.com/porter-dev/porter/internal/telemetry"
+
+	"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/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// ParsePorterYAMLToProtoHandler is the handler for the /app/parse endpoint
+type ParsePorterYAMLToProtoHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewParsePorterYAMLToProtoHandler returns a new ParsePorterYAMLToProtoHandler
+func NewParsePorterYAMLToProtoHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ParsePorterYAMLToProtoHandler {
+	return &ParsePorterYAMLToProtoHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// ParsePorterYAMLToProtoRequest is the request object for the /app/parse endpoint
+type ParsePorterYAMLToProtoRequest struct {
+	B64Yaml string `json:"b64_yaml"`
+}
+
+// ParsePorterYAMLToProtoResponse is the response object for the /app/parse endpoint
+type ParsePorterYAMLToProtoResponse struct {
+	B64AppProto string `json:"b64_app_proto"`
+}
+
+// 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")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	if !project.ValidateApplyV2 {
+		err := telemetry.Error(ctx, span, nil, "project does not have apply v2 enabled")
+		c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	request := &ParsePorterYAMLToProtoRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	if request.B64Yaml == "" {
+		err := telemetry.Error(ctx, span, nil, "b64 yaml is empty")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	yaml, err := base64.StdEncoding.DecodeString(request.B64Yaml)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error decoding b64 yaml")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	if yaml == nil {
+		err := telemetry.Error(ctx, span, nil, "decoded yaml is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	appProto, err := porter_app.ParseYAML(ctx, yaml)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error parsing yaml")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	if appProto == nil {
+		err := telemetry.Error(ctx, span, nil, "app proto is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	by, err := helpers.MarshalContractObject(ctx, appProto)
+	if err != nil {
+		err := telemetry.Error(ctx, span, nil, "error marshalling app proto")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	b64 := base64.StdEncoding.EncodeToString(by)
+
+	response := &ParsePorterYAMLToProtoResponse{
+		B64AppProto: b64,
+	}
+
+	c.WriteResult(w, r, response)
+}

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

@@ -542,5 +542,34 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/app/parse -> porter_app.NewParsePorterYAMLToProtoHandler
+	parsePorterYAMLToProto := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/apps/parse",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	parsePorterYAMLToProtoHandler := porter_app.NewParsePorterYAMLToProtoHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: parsePorterYAMLToProto,
+		Handler:  parsePorterYAMLToProtoHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 1 - 1
cli/cmd/apply.go

@@ -120,7 +120,7 @@ func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string
 	}
 
 	if project.ValidateApplyV2 {
-		err = v2.Apply(ctx)
+		err = v2.Apply(ctx, cliConf, client, porterYAML)
 		if err != nil {
 			return err
 		}

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

@@ -2,11 +2,53 @@ package v2
 
 import (
 	"context"
+	"encoding/base64"
 	"fmt"
+	"os"
+	"path/filepath"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/api-contracts/generated/go/helpers"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/cli/cmd/config"
 )
 
 // Apply implements the functionality of the `porter apply` command for validate apply v2 projects
-func Apply(ctx context.Context) error {
-	fmt.Println("Coming soon!")
+func Apply(ctx context.Context, cliConf *config.CLIConfig, client *api.Client, porterYamlPath string) error {
+	appProto := &porterv1.PorterApp{}
+
+	if len(porterYamlPath) == 0 {
+		return fmt.Errorf("porter yaml is empty")
+	}
+
+	porterYaml, err := os.ReadFile(filepath.Clean(porterYamlPath))
+	if err != nil {
+		return fmt.Errorf("could not read porter yaml file: %w", err)
+	}
+
+	b64YAML := base64.StdEncoding.EncodeToString(porterYaml)
+
+	resp, err := client.ParseYAML(ctx, cliConf.Project, cliConf.Cluster, b64YAML)
+	if err != nil {
+		return fmt.Errorf("error calling parse yaml endpoint: %w", err)
+	}
+
+	if resp.B64AppProto == "" {
+		return fmt.Errorf("b64 app proto is empty")
+	}
+
+	decoded, err := base64.StdEncoding.DecodeString(resp.B64AppProto)
+	if err != nil {
+		return fmt.Errorf("unable to decode b64 app: %w", err)
+	}
+
+	err = helpers.UnmarshalContractObject(decoded, appProto)
+	if err != nil {
+		return fmt.Errorf("unable to unmarshal app: %w", err)
+	}
+
+	color.New(color.FgGreen).Printf("Successfully parsed Porter YAML file %+v\n", appProto) // nolint:errcheck,gosec
+
 	return nil
 }

+ 4 - 3
go.mod

@@ -76,9 +76,10 @@ require (
 	github.com/glebarez/sqlite v1.6.0
 	github.com/go-chi/chi/v5 v5.0.8
 	github.com/honeycombio/otel-config-go v1.11.0
+	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.0.79
+	github.com/porter-dev/api-contracts v0.0.85
 	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
@@ -217,7 +218,7 @@ require (
 	github.com/fsnotify/fsnotify v1.5.4 // indirect
 	github.com/gdamore/encoding v1.0.0 // indirect
 	github.com/gdamore/tcell/v2 v2.5.1 // indirect
-	github.com/ghodss/yaml v1.0.0 // indirect
+	github.com/ghodss/yaml v1.0.0
 	github.com/go-errors/errors v1.4.2 // indirect
 	github.com/go-logr/logr v1.2.4 // indirect
 	github.com/go-openapi/jsonpointer v0.19.5 // indirect
@@ -230,7 +231,7 @@ require (
 	github.com/gogo/protobuf v1.3.2 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/google/btree v1.1.2 // indirect
-	github.com/google/go-cmp v0.5.9 // indirect
+	github.com/google/go-cmp v0.5.9
 	github.com/google/go-containerregistry v0.9.0 // indirect
 	github.com/google/go-querystring v1.1.0 // indirect
 	github.com/google/gofuzz v1.2.0 // indirect

+ 3 - 2
go.sum

@@ -1228,6 +1228,7 @@ github.com/markbates/safe v1.0.1 h1:yjZkbvRM6IzKj9tlu/zMJLS0n/V351OZWRnF3QfaUxI=
 github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0=
 github.com/marstr/guid v1.1.0/go.mod h1:74gB1z2wpxxInTG6yaqA7KrtM0NZ+RbrcqDvYHefzho=
 github.com/matoous/godox v0.0.0-20210227103229-6504466cf951/go.mod h1:1BELzlh859Sh1c6+90blK8lbYy0kwQf1bYlBhBysy1s=
+github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
 github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
 github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
 github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
@@ -1488,8 +1489,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.0.79 h1:NnhvXKMOzunzQpre9dX7WPmPEwltFP0/m0xiNJnfOpg=
-github.com/porter-dev/api-contracts v0.0.79/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.0.85 h1:OYFDEeiZ3HUMu1Qm6JHUPhwkfOsUi1xYKeYW51KWubU=
+github.com/porter-dev/api-contracts v0.0.85/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=

+ 58 - 0
internal/porter_app/parse.go

@@ -0,0 +1,58 @@
+package porter_app
+
+import (
+	"context"
+
+	v2 "github.com/porter-dev/porter/internal/porter_app/v2"
+
+	"sigs.k8s.io/yaml"
+
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// PorterYamlVersion is a string type for the version of the porter yaml
+type PorterYamlVersion string
+
+const (
+	// PorterYamlVersion_V2 is the v2 version of the porter yaml
+	PorterYamlVersion_V2 PorterYamlVersion = "v2"
+)
+
+// ParseYAML converts a Porter YAML file into a PorterApp proto object
+func ParseYAML(ctx context.Context, porterYaml []byte) (*porterv1.PorterApp, error) {
+	ctx, span := telemetry.NewSpan(ctx, "porter-app-parse-yaml")
+	defer span.End()
+
+	if porterYaml == nil {
+		return nil, telemetry.Error(ctx, span, nil, "porter yaml is nil")
+	}
+
+	version := &yamlVersion{}
+	err := yaml.Unmarshal(porterYaml, version)
+	if err != nil {
+		return nil, telemetry.Error(ctx, span, err, "error unmarshaling porter yaml")
+	}
+
+	var appProto *porterv1.PorterApp
+	switch version.Version {
+	case PorterYamlVersion_V2:
+		appProto, err = v2.AppProtoFromYaml(ctx, porterYaml)
+		if err != nil {
+			return nil, telemetry.Error(ctx, span, err, "error converting v2 yaml to proto")
+		}
+	default:
+		return nil, telemetry.Error(ctx, span, nil, "porter yaml version not supported")
+	}
+
+	if appProto == nil {
+		return nil, telemetry.Error(ctx, span, nil, "porter yaml is nil")
+	}
+
+	return appProto, nil
+}
+
+// yamlVersion is a struct used to unmarshal the version field of a Porter YAML file
+type yamlVersion struct {
+	Version PorterYamlVersion `yaml:"version"`
+}

+ 142 - 0
internal/porter_app/parse_test.go

@@ -0,0 +1,142 @@
+package porter_app
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"testing"
+
+	"google.golang.org/protobuf/encoding/protojson"
+
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/sergi/go-diff/diffmatchpatch"
+
+	"github.com/matryer/is"
+)
+
+func TestParseYAML(t *testing.T) {
+	tests := []struct {
+		porterYamlFileName string
+		want               *porterv1.PorterApp
+	}{
+		{"v2_input_nobuild", result_nobuild},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.porterYamlFileName, func(t *testing.T) {
+			is := is.New(t)
+
+			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)
+			is.NoErr(err) // umbrella chart values should convert to map[string]any without issues
+
+			diffProtoWithFailTest(t, is, tt.want, got)
+		})
+	}
+}
+
+var result_nobuild = &porterv1.PorterApp{
+	Name: "js-test-app",
+	Services: map[string]*porterv1.Service{
+		"example-job": {
+			Run:          "echo 'hello world'",
+			CpuCores:     0.1,
+			RamMegabytes: 256,
+			Config: &porterv1.Service_JobConfig{
+				JobConfig: &porterv1.JobServiceConfig{
+					AllowConcurrent: true,
+					Cron:            "*/10 * * * *",
+				},
+			},
+			Type: 3,
+		},
+		"example-wkr": {
+			Run:          "echo 'work'",
+			Instances:    1,
+			Port:         80,
+			CpuCores:     0.1,
+			RamMegabytes: 256,
+			Config: &porterv1.Service_WorkerConfig{
+				WorkerConfig: &porterv1.WorkerServiceConfig{
+					Autoscaling: &porterv1.Autoscaling{
+						Enabled:                false,
+						MinInstances:           0,
+						MaxInstances:           0,
+						CpuThresholdPercent:    0,
+						MemoryThresholdPercent: 0,
+					},
+				},
+			},
+			Type: 2,
+		},
+		"example-web": {
+			Run:          "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,
+		},
+	},
+	Env: map[string]string{
+		"PORT":     "8080",
+		"NODE_ENV": "production",
+	},
+	Predeploy: &porterv1.Service{
+		Run:          "ls",
+		Instances:    0,
+		Port:         0,
+		CpuCores:     0,
+		RamMegabytes: 0,
+		Config:       &porterv1.Service_JobConfig{},
+		Type:         3,
+	},
+	Image: &porterv1.AppImage{
+		Repository: "nginx",
+		Tag:        "latest",
+	},
+}
+
+func diffProtoWithFailTest(t *testing.T, is *is.I, want, got *porterv1.PorterApp) {
+	t.Helper()
+
+	opts := protojson.MarshalOptions{Multiline: true}
+
+	wantJson, err := opts.Marshal(want)
+	is.NoErr(err) // no error expected marshalling want
+
+	gotJson, err := opts.Marshal(got)
+	is.NoErr(err) // no error expected marshalling got
+
+	if string(wantJson) != string(gotJson) {
+		dmp := diffmatchpatch.New()
+		diffs := dmp.DiffMain(string(wantJson), string(gotJson), false)
+
+		t.Errorf("diff between want and got: %s", dmp.DiffPrettyText(diffs))
+	}
+}

+ 44 - 0
internal/porter_app/testdata/v2_input_nobuild.yaml

@@ -0,0 +1,44 @@
+version: v2
+name: "js-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 * * * *"
+predeploy:
+  type: job
+  run: ls
+env:
+  PORT: 8080
+  NODE_ENV: production

+ 246 - 0
internal/porter_app/v2/yaml.go

@@ -0,0 +1,246 @@
+package v2
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"strings"
+
+	"github.com/ghodss/yaml"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// AppProtoFromYaml converts a Porter YAML file into a PorterApp proto object
+func AppProtoFromYaml(ctx context.Context, porterYamlBytes []byte) (*porterv1.PorterApp, error) {
+	ctx, span := telemetry.NewSpan(ctx, "v2-app-proto-from-yaml")
+	defer span.End()
+
+	if porterYamlBytes == nil {
+		return nil, telemetry.Error(ctx, span, nil, "porter yaml is nil")
+	}
+
+	porterYaml := &PorterYAML{}
+	err := yaml.Unmarshal(porterYamlBytes, porterYaml)
+	if err != nil {
+		return nil, telemetry.Error(ctx, span, err, "error unmarshaling porter yaml")
+	}
+
+	appProto := &porterv1.PorterApp{
+		Name: porterYaml.Name,
+		Env:  porterYaml.Env,
+	}
+
+	if porterYaml.Build != nil {
+		appProto.Build = &porterv1.Build{
+			Context:    porterYaml.Build.Context,
+			Method:     porterYaml.Build.Method,
+			Builder:    porterYaml.Build.Builder,
+			Buildpacks: porterYaml.Build.Buildpacks,
+			Dockerfile: porterYaml.Build.Dockerfile,
+		}
+	}
+
+	if porterYaml.Image != nil {
+		appProto.Image = &porterv1.AppImage{
+			Repository: porterYaml.Image.Repository,
+			Tag:        porterYaml.Image.Tag,
+		}
+	}
+
+	if porterYaml.Services == nil {
+		return nil, telemetry.Error(ctx, span, nil, "porter yaml is missing services")
+	}
+
+	services := make(map[string]*porterv1.Service, 0)
+	for name, service := range porterYaml.Services {
+		serviceType, err := protoEnumFromType(name, service)
+		if err != nil {
+			return nil, telemetry.Error(ctx, span, err, "error getting service type")
+		}
+
+		serviceProto, err := serviceProtoFromConfig(service, serviceType)
+		if err != nil {
+			return nil, telemetry.Error(ctx, span, err, "error casting service config")
+		}
+
+		services[name] = serviceProto
+	}
+	appProto.Services = services
+
+	if porterYaml.Predeploy != nil {
+		predeployProto, err := serviceProtoFromConfig(*porterYaml.Predeploy, porterv1.ServiceType_SERVICE_TYPE_JOB)
+		if err != nil {
+			return nil, telemetry.Error(ctx, span, err, "error casting predeploy config")
+		}
+		appProto.Predeploy = predeployProto
+	}
+
+	return appProto, nil
+}
+
+// PorterYAML represents all the possible fields in a Porter YAML file
+type PorterYAML struct {
+	Name     string             `yaml:"name"`
+	Services map[string]Service `yaml:"services"`
+	Image    *Image             `yaml:"image"`
+	Build    *Build             `yaml:"build"`
+	Env      map[string]string  `yaml:"env"`
+
+	Predeploy *Service `yaml:"predeploy"`
+}
+
+// Build represents the build settings for a Porter app
+type Build struct {
+	Context    string   `yaml:"context" validate:"dir"`
+	Method     string   `yaml:"method" validate:"required,oneof=pack docker registry"`
+	Builder    string   `yaml:"builder" validate:"required_if=Method pack"`
+	Buildpacks []string `yaml:"buildpacks"`
+	Dockerfile string   `yaml:"dockerfile" validate:"required_if=Method docker"`
+	Image      string   `yaml:"image" validate:"required_if=Method registry"`
+}
+
+// Service represents a single service in a porter app
+type Service struct {
+	Run             string      `yaml:"run"`
+	Type            string      `yaml:"type" validate:"required, oneof=web worker job"`
+	Instances       int         `yaml:"instances"`
+	CpuCores        float32     `yaml:"cpuCores"`
+	RamMegabytes    int         `yaml:"ramMegabytes"`
+	Port            int         `yaml:"port"`
+	Autoscaling     AutoScaling `yaml:"autoscaling" validate:"excluded_if=Type job"`
+	Domains         []Domains   `yaml:"domains" validate:"excluded_unless=Type web"`
+	HealthCheck     HealthCheck `yaml:"healthCheck" validate:"excluded_unless=Type web"`
+	AllowConcurrent bool        `yaml:"allowConcurrent" validate:"excluded_unless=Type job"`
+	Cron            string      `yaml:"cron" validate:"excluded_unless=Type job"`
+}
+
+// AutoScaling represents the autoscaling settings for web services
+type AutoScaling struct {
+	Enabled                bool `yaml:"enabled"`
+	MinInstances           int  `yaml:"minInstances"`
+	MaxInstances           int  `yaml:"maxInstances"`
+	CpuThresholdPercent    int  `yaml:"cpuThresholdPercent"`
+	MemoryThresholdPercent int  `yaml:"memoryThresholdPercent"`
+}
+
+// Domains are the custom domains for a web service
+type Domains struct {
+	Name string `yaml:"name"`
+}
+
+// HealthCheck is the health check settings for a web service
+type HealthCheck struct {
+	Enabled  bool   `yaml:"enabled"`
+	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 protoEnumFromType(name string, service Service) (porterv1.ServiceType, error) {
+	var serviceType porterv1.ServiceType
+
+	if service.Type != "" {
+		if service.Type == "web" {
+			return porterv1.ServiceType_SERVICE_TYPE_WEB, nil
+		}
+		if service.Type == "worker" {
+			return porterv1.ServiceType_SERVICE_TYPE_WORKER, nil
+		}
+		if service.Type == "job" {
+			return porterv1.ServiceType_SERVICE_TYPE_JOB, nil
+		}
+
+		return serviceType, fmt.Errorf("invalid service type '%s'", service.Type)
+	}
+
+	if strings.Contains(name, "web") {
+		return porterv1.ServiceType_SERVICE_TYPE_WEB, nil
+	}
+
+	if strings.Contains(name, "wkr") {
+		return porterv1.ServiceType_SERVICE_TYPE_WORKER, nil
+	}
+
+	if strings.Contains(name, "job") {
+		return porterv1.ServiceType_SERVICE_TYPE_JOB, nil
+	}
+
+	return serviceType, errors.New("no type provided and could not parse service type from name")
+}
+
+func serviceProtoFromConfig(service Service, serviceType porterv1.ServiceType) (*porterv1.Service, error) {
+	serviceProto := &porterv1.Service{
+		Run:          service.Run,
+		Type:         serviceType,
+		Instances:    int32(service.Instances),
+		CpuCores:     service.CpuCores,
+		RamMegabytes: int32(service.RamMegabytes),
+		Port:         int32(service.Port),
+	}
+
+	switch serviceType {
+	default:
+		return nil, fmt.Errorf("invalid service type '%s'", serviceType)
+	case porterv1.ServiceType_SERVICE_TYPE_UNSPECIFIED:
+		return nil, errors.New("Service type unspecified")
+	case porterv1.ServiceType_SERVICE_TYPE_WEB:
+		webConfig := &porterv1.WebServiceConfig{
+			HealthCheck: &porterv1.HealthCheck{
+				Enabled:  service.HealthCheck.Enabled,
+				HttpPath: service.HealthCheck.HttpPath,
+			},
+		}
+
+		autoscaling := &porterv1.Autoscaling{
+			Enabled:                service.Autoscaling.Enabled,
+			MinInstances:           int32(service.Autoscaling.MinInstances),
+			MaxInstances:           int32(service.Autoscaling.MaxInstances),
+			CpuThresholdPercent:    int32(service.Autoscaling.CpuThresholdPercent),
+			MemoryThresholdPercent: int32(service.Autoscaling.MemoryThresholdPercent),
+		}
+
+		webConfig.Autoscaling = autoscaling
+
+		domains := make([]*porterv1.Domain, 0)
+		for _, domain := range service.Domains {
+			domains = append(domains, &porterv1.Domain{
+				Name: domain.Name,
+			})
+		}
+		webConfig.Domains = domains
+
+		serviceProto.Config = &porterv1.Service_WebConfig{
+			WebConfig: webConfig,
+		}
+	case porterv1.ServiceType_SERVICE_TYPE_WORKER:
+		workerConfig := &porterv1.WorkerServiceConfig{}
+		autoscaling := &porterv1.Autoscaling{
+			Enabled:                service.Autoscaling.Enabled,
+			MinInstances:           int32(service.Autoscaling.MinInstances),
+			MaxInstances:           int32(service.Autoscaling.MaxInstances),
+			CpuThresholdPercent:    int32(service.Autoscaling.CpuThresholdPercent),
+			MemoryThresholdPercent: int32(service.Autoscaling.MemoryThresholdPercent),
+		}
+		workerConfig.Autoscaling = autoscaling
+
+		serviceProto.Config = &porterv1.Service_WorkerConfig{
+			WorkerConfig: workerConfig,
+		}
+	case porterv1.ServiceType_SERVICE_TYPE_JOB:
+		jobConfig := &porterv1.JobServiceConfig{
+			AllowConcurrent: service.AllowConcurrent,
+			Cron:            service.Cron,
+		}
+
+		serviceProto.Config = &porterv1.Service_JobConfig{
+			JobConfig: jobConfig,
+		}
+	}
+
+	return serviceProto, nil
+}