Ian Edwards 2 лет назад
Родитель
Сommit
ce1e34ca8d

+ 77 - 0
api/server/handlers/porter_app/validate.go

@@ -0,0 +1,77 @@
+package porter_app
+
+import (
+	"encoding/base64"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/handlers/porter_app/validate"
+	"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"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+type ValidatePorterAppRequest struct {
+	PorterYAMLBase64 string `json:"porter_yaml"`
+	LatestCommit     string `json:"latest_commit"`
+}
+
+type ValidatePorterAppHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewValidatePorterAppHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ValidatePorterAppHandler {
+	return &ValidatePorterAppHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *ValidatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-validate-porter-app")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "project-id", Value: project.ID})
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID})
+
+	// read the request body
+	request := &ValidatePorterAppRequest{}
+	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
+	}
+
+	fmt.Println(request)
+
+	porterYamlBase64 := request.PorterYAMLBase64
+	porterYaml, err := base64.StdEncoding.DecodeString(porterYamlBase64)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error decoding porter yaml")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	// validate the porter yaml
+	apps, err := validate.ValidatePorterYAML(porterYaml, c.Repo().Revision())
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error validating porter yaml")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	c.WriteResult(w, r, apps)
+}

+ 143 - 0
api/server/handlers/porter_app/validate/application.go

@@ -0,0 +1,143 @@
+package validate
+
+import (
+	"fmt"
+)
+
+type ServiceDiff struct {
+	Name     string
+	current  Service
+	previous Service
+}
+
+func HydrateApplication(current, previous Application) (Application, error) {
+	application := Application{}
+
+	// handle service validation
+	serviceDiffs := mergeApplicationsServices(current, previous)
+	application.Services = make(map[string]Service)
+	for _, serviceDiff := range serviceDiffs {
+		service, err := HydrateService(serviceDiff.current, serviceDiff.previous, serviceDiff.Name)
+		if err != nil {
+			return application, err
+		}
+		application.Services[serviceDiff.Name] = service
+	}
+
+	if current.Image != nil {
+		return application, nil
+	}
+
+	// handle build validation
+	build, err := validateBuild(current.Build, previous.Build)
+	if err != nil {
+		return application, err
+	}
+	application.Build = build
+
+	// handle env merge
+	env := mergeEnv(current.Env, previous.Env)
+	application.Env = env
+
+	// handle release validation
+	release, err := validateRelease(current.Release, previous.Release)
+	if err != nil {
+		return application, err
+	}
+	application.Release = release
+
+	return application, nil
+}
+
+func validateBuild(current, previous *Build) (*Build, error) {
+	// if current and previous are nil, return nil
+	if current == nil && previous == nil {
+		return nil, fmt.Errorf("Build settings must exist to create application")
+	}
+
+	// if current is nil and previous is not, return previous
+	if current == nil && previous != nil {
+		return previous, nil
+	}
+
+	return current, nil
+}
+
+func validateRelease(current, previous *Service) (*Service, error) {
+	// handle release validation
+	if current != nil && previous != nil {
+		release, err := HydrateService(*current, *previous, "release")
+		if err != nil {
+			return nil, err
+		}
+
+		return &release, nil
+	}
+
+	if current != nil && previous == nil {
+		release, err := HydrateService(*current, Service{}, "release")
+		if err != nil {
+			return nil, err
+		}
+
+		return &release, nil
+	}
+
+	if current == nil && previous != nil {
+		release, err := HydrateService(Service{}, *previous, "release")
+		if err != nil {
+			return nil, err
+		}
+
+		return &release, nil
+	}
+
+	return nil, nil
+}
+
+func mergeApplicationsServices(current Application, previous Application) []ServiceDiff {
+	serviceDiffs := []ServiceDiff{}
+
+	for serviceName, serviceInCurrent := range current.Services {
+		serviceInPrev, found := previous.Services[serviceName]
+		if !found {
+			serviceDiffs = append(serviceDiffs, ServiceDiff{
+				Name:     serviceName,
+				current:  serviceInCurrent,
+				previous: Service{},
+			})
+		} else {
+			serviceDiffs = append(serviceDiffs, ServiceDiff{
+				Name:     serviceName,
+				current:  serviceInCurrent,
+				previous: serviceInPrev,
+			})
+		}
+	}
+
+	for serviceName, serviceInPrev := range previous.Services {
+		if _, found := current.Services[serviceName]; !found {
+			serviceDiffs = append(serviceDiffs, ServiceDiff{
+				Name:     serviceName,
+				current:  Service{},
+				previous: serviceInPrev,
+			})
+		}
+	}
+
+	return serviceDiffs
+}
+
+func mergeEnv(current map[string]string, previous map[string]string) map[string]string {
+	env := map[string]string{}
+
+	for key, value := range previous {
+		env[key] = value
+	}
+
+	for key, value := range current {
+		env[key] = value
+	}
+
+	return env
+}

+ 171 - 0
api/server/handlers/porter_app/validate/porter_yaml.go

@@ -0,0 +1,171 @@
+package validate
+
+import (
+	"context"
+	"encoding/base64"
+	"errors"
+	"fmt"
+
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/telemetry"
+	"gopkg.in/yaml.v2"
+	"gorm.io/gorm"
+)
+
+type AppDefinition struct {
+	Name             string `json:"name"`
+	PorterYAMLBase64 string `json:"porter_yaml"`
+}
+
+func ValidatePorterYAML(file []byte, revisionRepo repository.RevisionRepository) ([]AppDefinition, error) {
+	ctx, span := telemetry.NewSpan(context.Background(), "hydrate-porter-yaml")
+	defer span.End()
+
+	porterYaml := &PorterStackYAML{}
+	validApps := make([]AppDefinition, 0)
+
+	fmt.Println(string(file))
+
+	err := yaml.Unmarshal(file, porterYaml)
+	if err != nil {
+		return validApps, telemetry.Error(ctx, span, err, "error unmarshaling porter yaml")
+	}
+
+	fmt.Printf("%+v\n", porterYaml)
+
+	// get all apps as list
+	apps, err := extractAppsFromPorterYaml(*porterYaml)
+	fmt.Printf("apps: %+v\n", apps)
+	if err != nil {
+		fmt.Println("failing here")
+		err = telemetry.Error(ctx, span, err, "error extracting apps from porter yaml")
+		return validApps, err
+	}
+
+	// verify that at least one app is defined
+	if len(apps) == 0 {
+		fmt.Println("failing here")
+		err = telemetry.Error(ctx, span, err, "no apps defined in porter yaml")
+		return validApps, err
+	}
+
+	for name, app := range apps {
+		previousYAML := PorterStackYAML{}
+		latestRevision, err := revisionRepo.GetLatestRevision(name)
+		if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
+			err = telemetry.Error(ctx, span, err, "error getting latest revision")
+			return validApps, err
+		}
+
+		previousApp := Application{}
+		fmt.Printf("latest revision: %+v\n", latestRevision)
+		if latestRevision != nil {
+			decoded, err := base64.StdEncoding.DecodeString(latestRevision.PorterYAML)
+			if err != nil {
+				return validApps, telemetry.Error(ctx, span, err, "error decoding porter yaml from revision")
+			}
+
+			err = yaml.Unmarshal([]byte(decoded), previousYAML)
+			if err != nil {
+				return validApps, telemetry.Error(ctx, span, err, "error unmarshaling porter yaml from revision")
+			}
+
+			previousApp, err = castYamltoApp(previousYAML)
+			if err != nil {
+				return validApps, telemetry.Error(ctx, span, err, "error casting revision yaml to app")
+			}
+		}
+
+		app, err = HydrateApplication(app, previousApp)
+		if err != nil {
+			return validApps, telemetry.Error(ctx, span, err, "error hydrating application")
+		}
+
+		finalYaml := PorterStackYAML{
+			Name:     name,
+			Services: app.Services,
+			Build:    app.Build,
+			Release:  app.Release,
+			Env:      app.Env,
+		}
+
+		finalYamlBytes, err := yaml.Marshal(finalYaml)
+		if err != nil {
+			return validApps, telemetry.Error(ctx, span, err, "error marshaling final yaml")
+		}
+
+		finalYamlBase64 := base64.StdEncoding.EncodeToString(finalYamlBytes)
+
+		validApps = append(validApps, AppDefinition{
+			Name:             name,
+			PorterYAMLBase64: finalYamlBase64,
+		})
+	}
+
+	return validApps, nil
+}
+
+func castYamltoApp(porterYaml PorterStackYAML) (Application, error) {
+	application := Application{}
+
+	var services map[string]Service
+	fmt.Printf("no apps or services defined in porter yaml: %+v\n", porterYaml.Services == nil && porterYaml.Apps == nil)
+	if porterYaml.Services == nil && porterYaml.Apps == nil {
+		return application, fmt.Errorf("no apps or services defined in porter yaml")
+	}
+
+	if porterYaml.Services != nil && porterYaml.Apps != nil {
+		return application, fmt.Errorf("both apps and services defined in porter yaml")
+	}
+
+	if porterYaml.Apps != nil {
+		services = porterYaml.Apps
+	}
+
+	if porterYaml.Services != nil {
+		services = porterYaml.Services
+	}
+
+	application = Application{
+		Env:      porterYaml.Env,
+		Services: services,
+		Build:    porterYaml.Build,
+		Release:  porterYaml.Release,
+	}
+
+	return application, nil
+}
+
+func extractAppsFromPorterYaml(porterYaml PorterStackYAML) (map[string]Application, error) {
+	ctx, span := telemetry.NewSpan(context.Background(), "extract-apps-from-porter-yaml")
+	defer span.End()
+
+	apps := make(map[string]Application)
+
+	if porterYaml.Applications == nil {
+		app, err := castYamltoApp(porterYaml)
+		if err != nil {
+			return nil, telemetry.Error(ctx, span, err, "error getting single app from porter yaml")
+		}
+
+		fmt.Printf("assigning app: %+v to %v\n", app, porterYaml.Name)
+		apps[porterYaml.Name] = app
+
+		return apps, nil
+	}
+
+	for name, app := range porterYaml.Applications {
+		if app.Services == nil {
+			return nil, telemetry.Error(ctx, span, nil, "no services defined for an app in porter yaml")
+		}
+
+		apps[name] = Application{
+			Env:      app.Env,
+			Services: app.Services,
+			Build:    app.Build,
+			Release:  app.Release,
+		}
+	}
+
+	return apps, nil
+}

+ 453 - 0
api/server/handlers/porter_app/validate/service.go

@@ -0,0 +1,453 @@
+package validate
+
+import (
+	"fmt"
+	"strings"
+)
+
+type RequestResources struct {
+	cpu    string
+	memory string
+}
+
+type Resources struct {
+	requests RequestResources
+}
+
+type Container struct {
+	command string
+	port    string
+}
+
+type ServiceDef struct {
+	port string
+}
+
+type Ingress struct {
+	enabled       bool
+	custom_domain bool
+	hosts         []string
+	porter_hosts  []string
+	annotations   map[string]string
+}
+
+type HealthProbe struct {
+	enabled          bool
+	failureThreshold int
+	path             string
+	periodSeconds    int
+}
+
+type HealthChecks struct {
+	startupProbe   HealthProbe
+	livenessProbe  HealthProbe
+	readinessProbe HealthProbe
+}
+
+type AutoScaling struct {
+	enabled                           bool
+	minReplicas                       int
+	maxReplicas                       int
+	targetCPUUtilizationPercentage    int
+	targetMemoryUtilizationPercentage int
+}
+
+type CloudSql struct {
+	enabled            bool
+	connectionName     string
+	dbPort             int
+	serviceAccountJSON string
+}
+
+type JobSchedule struct {
+	enabled bool
+	value   string
+}
+
+type WebServiceConfig struct {
+	replicaCount *string
+	resources    *Resources
+	container    *Container
+	autoscaling  *AutoScaling
+	ingress      *Ingress
+	service      *ServiceDef
+	health       *HealthChecks
+	cloudsql     *CloudSql
+}
+
+type WorkerServiceConfig struct {
+	replicaCount *string
+	container    *Container
+	resources    *Resources
+	autoscaling  *AutoScaling
+	cloudsql     *CloudSql
+}
+
+type JobServiceConfig struct {
+	allowConcurrent *bool
+	container       *Container
+	resources       *Resources
+	schedule        *JobSchedule
+	paused          *bool
+	cloudsql        *CloudSql
+}
+
+func getType(name string, service Service) string {
+	if service.Type != "" {
+		return service.Type
+	}
+	if strings.Contains(name, "web") {
+		return "web"
+	}
+
+	if strings.Contains(name, "wkr") {
+		return "worker"
+	}
+
+	return "job"
+}
+
+func HydrateService(current Service, previous Service, name string) (Service, error) {
+	service := Service{}
+	serviceType := getType(name, current)
+
+	switch serviceType {
+	case "web":
+		service, err := hydrateWebService(current, previous)
+		if err != nil {
+			return service, err
+		}
+	case "worker":
+		service, err := hydrateWorkerService(current, previous)
+		if err != nil {
+			return service, err
+		}
+	case "job":
+		service, err := hydrateJobService(current, previous)
+		if err != nil {
+			return service, err
+		}
+	}
+
+	return service, nil
+}
+
+func hydrateWebService(current, previous Service) (Service, error) {
+	service := Service{
+		Type: "web",
+		Run: previous.Run,
+	}
+
+	if current.Run != "" {
+		service.Run = current.Run
+	}
+
+	validatedConfig := WebServiceConfig{}
+
+	currentConfig := WebServiceConfig{}
+	// check if current.Config exists
+	if current.Config != nil {
+		// cast current.Config to WebServiceConfig
+		config, ok := current.Config.(WebServiceConfig)
+		if !ok {
+			return service, fmt.Errorf("unable to cast current service config to web service config")
+		}
+		currentConfig = config
+	}
+
+	previousConfig := WebServiceConfig{}
+	// check if previous.Config exists
+	if previous.Config != nil {
+		// cast previous.Config to WebServiceConfig
+		config, ok := previous.Config.(WebServiceConfig)
+		if !ok {
+			return service, fmt.Errorf("unable to cast existing service config to web service config")
+		}
+		previousConfig = config
+	}
+
+	container, err := validateContainer(currentConfig.container, previousConfig.container)
+	if err != nil {
+		return service, fmt.Errorf("unable to validate container for web service: %w", err)
+	}
+	validatedConfig.container = container
+
+	resources, err := validateResources(currentConfig.resources, previousConfig.resources)
+	if err != nil {
+		return service, fmt.Errorf("unable to validate resources for web service: %w", err)
+	}
+	validatedConfig.resources = resources
+
+	autoScaling, err := validateAutoScaling(currentConfig.autoscaling, previousConfig.autoscaling)
+	if err != nil {
+		return service, fmt.Errorf("unable to validate autoscaling for web service: %w", err)
+	}
+	validatedConfig.autoscaling = autoScaling
+
+	ingress, err := validateIngress(currentConfig.ingress, previousConfig.ingress)
+	if err != nil {
+		return service, fmt.Errorf("unable to validate ingress for web service: %w", err)
+	}
+	validatedConfig.ingress = ingress
+
+	serviceDef, err := validateService(currentConfig.service, previousConfig.service)
+	if err != nil {
+		return service, fmt.Errorf("unable to validate service for web service: %w", err)
+	}
+	validatedConfig.service = serviceDef
+
+	fmt.Printf("validatedConfig: %+v\n", validatedConfig)
+
+	service.Config = validatedConfig
+
+	return service, nil
+}
+
+func hydrateWorkerService(current, previous Service) (Service, error) {
+	service := Service{
+		Type: "worker",
+		Run: previous.Run,
+	}
+
+	if current.Run != "" {
+		service.Run = current.Run
+	}
+
+	validatedConfig := WorkerServiceConfig{}
+
+	currentConfig := WorkerServiceConfig{}
+	// check if current.Config exists
+	if current.Config != nil {
+		// cast current.Config to WorkerServiceConfig
+		config, ok := current.Config.(WorkerServiceConfig)
+		if !ok {
+			return service, fmt.Errorf("unable to cast current config to worker service config")
+		}
+		currentConfig = config
+	}
+
+	previousConfig := WorkerServiceConfig{}
+	// check if previous.Config exists
+	if previous.Config != nil {
+		// cast previous.Config to WorkerServiceConfig
+		config, ok := previous.Config.(WorkerServiceConfig)
+		if !ok {
+			return service, fmt.Errorf("unable to cast previous config to worker service config")
+		}
+		previousConfig = config
+	}
+
+	container, err := validateContainer(currentConfig.container, previousConfig.container)
+	if err != nil {
+		return service, fmt.Errorf("unable to validate container for worker service: %w", err)
+	}
+	validatedConfig.container = container
+
+	resources, err := validateResources(currentConfig.resources, previousConfig.resources)
+	if err != nil {
+		return service, fmt.Errorf("unable to validate resources for worker service: %w", err)
+	}
+	validatedConfig.resources = resources
+
+	autoScaling, err := validateAutoScaling(currentConfig.autoscaling, previousConfig.autoscaling)
+	if err != nil {
+		return service, fmt.Errorf("unable to validate autoscaling for worker service: %w", err)
+	}
+	validatedConfig.autoscaling = autoScaling
+
+	cloudsql, err := validateCloudSql(currentConfig.cloudsql, previousConfig.cloudsql)
+	if err != nil {
+		return service, fmt.Errorf("unable to validate cloudsql for worker service: %w", err)
+	}
+	validatedConfig.cloudsql = cloudsql
+
+	service.Config = validatedConfig
+
+	return service, nil
+}
+
+func hydrateJobService(current, previous Service) (Service, error) {
+	service := Service{}
+
+	return service, nil
+}
+
+func validateContainer(current, previous *Container) (*Container, error) {
+	container := &Container{
+		command: "",
+		port:    "80",
+	}
+
+	if previous != nil {
+		container = previous
+	}
+
+	// merge current into container
+	if current != nil {
+		if current.command != "" {
+			container.command = current.command
+		}
+
+		if current.port != "" {
+			container.port = current.port
+		}
+	}
+
+	return container, nil
+}
+
+func validateResources(current, previous *Resources) (*Resources, error) {
+	resources := &Resources{
+		requests: RequestResources{
+			cpu:    "100m",
+			memory: "256Mi",
+		},
+	}
+
+	if previous != nil {
+		resources = previous
+	}
+
+	// merge current into resources
+	if current != nil {
+		if current.requests.cpu != "" {
+			resources.requests.cpu = current.requests.cpu
+		}
+
+		if current.requests.memory != "" {
+			resources.requests.memory = current.requests.memory
+		}
+	}
+
+	return resources, nil
+}
+
+func validateAutoScaling(current, previous *AutoScaling) (*AutoScaling, error) {
+	autoScaling := &AutoScaling{
+		enabled:                           false,
+		minReplicas:                       1,
+		maxReplicas:                       10,
+		targetCPUUtilizationPercentage:    50,
+		targetMemoryUtilizationPercentage: 50,
+	}
+
+	if previous != nil {
+		autoScaling = previous
+	}
+
+	// merge current into autoScaling
+	if current != nil {
+		if current.enabled {
+			autoScaling.enabled = current.enabled
+		}
+
+		if current.minReplicas != 0 {
+			autoScaling.minReplicas = current.minReplicas
+		}
+
+		if current.maxReplicas != 0 {
+			autoScaling.maxReplicas = current.maxReplicas
+		}
+
+		if current.targetCPUUtilizationPercentage != 0 {
+			autoScaling.targetCPUUtilizationPercentage = current.targetCPUUtilizationPercentage
+		}
+
+		if current.targetMemoryUtilizationPercentage != 0 {
+			autoScaling.targetMemoryUtilizationPercentage = current.targetMemoryUtilizationPercentage
+		}
+	}
+
+	return autoScaling, nil
+}
+
+func validateIngress(current, previous *Ingress) (*Ingress, error) {
+	ingress := &Ingress{
+		enabled:       false,
+		custom_domain: false,
+		hosts:         []string{},
+		porter_hosts:  []string{},
+		annotations:   map[string]string{},
+	}
+
+	if previous != nil {
+		ingress = previous
+	}
+
+	// merge current into ingress
+	if current != nil {
+		if current.enabled {
+			ingress.enabled = current.enabled
+		}
+
+		if current.custom_domain {
+			ingress.custom_domain = current.custom_domain
+		}
+
+		if len(current.hosts) > 0 {
+			ingress.hosts = current.hosts
+		}
+
+		if len(current.porter_hosts) > 0 {
+			ingress.porter_hosts = current.porter_hosts
+		}
+
+		if len(current.annotations) > 0 {
+			ingress.annotations = current.annotations
+		}
+	}
+
+	return ingress, nil
+}
+
+func validateService(current, previous *ServiceDef) (*ServiceDef, error) {
+	service := &ServiceDef{
+		port: "80",
+	}
+
+	if previous != nil {
+		service = previous
+	}
+
+	// merge current into service
+	if current != nil {
+		if current.port != "" {
+			service.port = current.port
+		}
+	}
+
+	return service, nil
+}
+
+func validateCloudSql(current, previous *CloudSql) (*CloudSql, error) {
+	cloudsql := &CloudSql{
+		enabled:            false,
+		connectionName:     "",
+		dbPort:             5432,
+		serviceAccountJSON: "",
+	}
+
+	if previous != nil {
+		cloudsql = previous
+	}
+
+	// merge current into cloudsql
+	if current != nil {
+		if current.enabled {
+			cloudsql.enabled = current.enabled
+		}
+
+		if current.connectionName != "" {
+			cloudsql.connectionName = current.connectionName
+		}
+
+		if current.dbPort != 0 {
+			cloudsql.dbPort = current.dbPort
+		}
+
+		if current.serviceAccountJSON != "" {
+			cloudsql.serviceAccountJSON = current.serviceAccountJSON
+		}
+	}
+	return cloudsql, nil
+}

+ 55 - 0
api/server/handlers/porter_app/validate/types.go

@@ -0,0 +1,55 @@
+package validate
+
+type PorterStackYAML struct {
+	Applications map[string]*Application `yaml:"applications" validate:"required_without=Services Apps"`
+	Name         string                  `yaml:"name" validate:"required_without=Applications"`
+	Version      *string                 `yaml:"version"`
+	Image        *Image                  `yaml:"image"`
+	Build        *Build                  `yaml:"build"`
+	Env          map[string]string       `yaml:"env"`
+	SyncedEnv    []*SyncedEnvSection     `yaml:"synced_env"`
+	Apps         map[string]Service     `yaml:"apps" validate:"required_without=Applications Services"`
+	Services     map[string]Service     `yaml:"services" validate:"required_without=Applications Apps"`
+
+	Release *Service `yaml:"release"`
+}
+
+type Application struct {
+	Services map[string]Service `yaml:"services" validate:"required"`
+	Image    *Image              `yaml:"image"`
+	Build    *Build              `yaml:"build"`
+	Env      map[string]string   `yaml:"env"`
+
+	Release *Service `yaml:"release"`
+}
+
+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"`
+}
+
+type Service struct {
+	Run    string      `yaml:"run"`
+	Config interface{} `yaml:"config"`
+	Type   string      `yaml:"type" validate:"required, oneof=web worker job"`
+}
+
+type SyncedEnvSection struct {
+	Name    string                `json:"name" yaml:"name"`
+	Version uint                  `json:"version" yaml:"version"`
+	Keys    []SyncedEnvSectionKey `json:"keys" yaml:"keys"`
+}
+
+type SyncedEnvSectionKey struct {
+	Name   string `json:"name" yaml:"name"`
+	Secret bool   `json:"secret" yaml:"secret"`
+}
+
+type Image struct {
+	Repository string `yaml:"repository"`
+	Tag        string `yaml:"tag"`
+}

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

@@ -370,5 +370,34 @@ func getStackRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/validate -> porter_app.NewValidatePorterAppHandler
+	validatePorterAppEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/validate", relPath),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	validatePorterAppHandler := porter_app.NewValidatePorterAppHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: validatePorterAppEndpoint,
+		Handler:  validatePorterAppHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 8 - 0
api/types/revision.go

@@ -0,0 +1,8 @@
+package types
+
+type Revision struct {
+	ID          uint   `json:"id"`
+	Version     uint   `json:"version"`
+	PorterAppID uint   `json:"porter_app_id"`
+	PorterYAML  string `json:"porter_yaml"`
+}

+ 27 - 0
internal/models/revision.go

@@ -0,0 +1,27 @@
+package models
+
+import (
+	"github.com/porter-dev/porter/api/types"
+	"gorm.io/gorm"
+)
+
+type Revision struct {
+	gorm.Model
+
+	Version uint
+
+	PorterAppID uint
+	PorterApp   PorterApp
+
+	PorterYAML string
+}
+
+// ToRevisionType generates an external types.Revision to be shared over REST
+func (r *Revision) ToRevisionType() *types.Revision {
+	return &types.Revision{
+		ID:          r.ID,
+		Version:     r.Version,
+		PorterAppID: r.PorterAppID,
+		PorterYAML:  r.PorterYAML,
+	}
+}

+ 1 - 0
internal/repository/gorm/migrate.go

@@ -62,6 +62,7 @@ func AutoMigrate(db *gorm.DB, debug bool) error {
 		&models.AWSAssumeRoleChain{},
 		&models.PorterApp{},
 		&models.PorterAppEvent{},
+		&models.Revision{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 6 - 0
internal/repository/gorm/repository.go

@@ -53,6 +53,7 @@ type GormRepository struct {
 	awsAssumeRoleChainer      repository.AWSAssumeRoleChainer
 	porterApp                 repository.PorterAppRepository
 	porterAppEvent            repository.PorterAppEventRepository
+	revision repository.RevisionRepository
 }
 
 func (t *GormRepository) User() repository.UserRepository {
@@ -239,6 +240,10 @@ func (t *GormRepository) PorterAppEvent() repository.PorterAppEventRepository {
 	return t.porterAppEvent
 }
 
+func (t *GormRepository) Revision() repository.RevisionRepository {
+	return t.revision
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.CredentialStorage) repository.Repository {
@@ -289,5 +294,6 @@ func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.Creden
 		awsAssumeRoleChainer:      NewAWSAssumeRoleChainer(db),
 		porterApp:                 NewPorterAppRepository(db),
 		porterAppEvent:            NewPorterAppEventRepository(db),
+		revision: NewRevisionRepository(db),
 	}
 }

+ 36 - 0
internal/repository/gorm/revision.go

@@ -0,0 +1,36 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+type RevisionRepository struct {
+	db *gorm.DB
+}
+
+func NewRevisionRepository(db *gorm.DB) repository.RevisionRepository {
+	return &RevisionRepository{db}
+}
+
+func (repo *RevisionRepository) CreateRevision(revision *models.Revision) (*models.Revision, error) {
+	if err := repo.db.Create(revision).Error; err != nil {
+		return nil, err
+	}
+	return revision, nil
+}
+
+func (repo *RevisionRepository) GetLatestRevision(appName string) (*models.Revision, error) {
+	revision := &models.Revision{}
+
+	// get latest revision joined with a porter_app of this name
+	if err := repo.db.
+		Joins("JOIN porter_apps ON porter_apps.id = revisions.porter_app_id").
+		Where("porter_apps.name = ?", appName).
+		Order("revisions.version DESC").First(&revision).Error; err != nil {
+		return nil, err
+	}
+
+	return revision, nil
+}

+ 1 - 0
internal/repository/repository.go

@@ -47,4 +47,5 @@ type Repository interface {
 	AWSAssumeRoleChainer() AWSAssumeRoleChainer
 	PorterApp() PorterAppRepository
 	PorterAppEvent() PorterAppEventRepository
+	Revision() RevisionRepository
 }

+ 8 - 0
internal/repository/revision.go

@@ -0,0 +1,8 @@
+package repository
+
+import "github.com/porter-dev/porter/internal/models"
+
+type RevisionRepository interface {
+	CreateRevision(revision *models.Revision) (*models.Revision, error)
+	GetLatestRevision(appName string) (*models.Revision, error)
+}

+ 6 - 0
internal/repository/test/repository.go

@@ -51,6 +51,7 @@ type TestRepository struct {
 	awsAssumeRoleChainer      repository.AWSAssumeRoleChainer
 	porterApp                 repository.PorterAppRepository
 	porterAppEvent            repository.PorterAppEventRepository
+	revision                  repository.RevisionRepository
 }
 
 func (t *TestRepository) User() repository.UserRepository {
@@ -237,6 +238,10 @@ func (t *TestRepository) PorterAppEvent() repository.PorterAppEventRepository {
 	return t.porterAppEvent
 }
 
+func (t *TestRepository) Revision() repository.RevisionRepository {
+	return t.revision
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(canQuery bool, failingMethods ...string) repository.Repository {
@@ -287,5 +292,6 @@ func NewRepository(canQuery bool, failingMethods ...string) repository.Repositor
 		awsAssumeRoleChainer:      NewAWSAssumeRoleChainer(),
 		porterApp:                 NewPorterAppRepository(canQuery, failingMethods...),
 		porterAppEvent:            NewPorterAppEventRepository(canQuery),
+		revision:                  NewRevisionRepository(canQuery),
 	}
 }

+ 26 - 0
internal/repository/test/revision.go

@@ -0,0 +1,26 @@
+package test
+
+import (
+	"errors"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+type RevisionRepository struct {
+	canQuery       bool
+	failingMethods string
+}
+
+func NewRevisionRepository(canQuery bool, failingMethods ...string) repository.RevisionRepository {
+	return &RevisionRepository{canQuery, strings.Join(failingMethods, ",")}
+}
+
+func (repo *RevisionRepository) CreateRevision(revision *models.Revision) (*models.Revision, error) {
+	return nil, errors.New("cannot write database")
+}
+
+func (repo *RevisionRepository) GetLatestRevision(appName string) (*models.Revision, error) {
+	return nil, errors.New("cannot write database")
+}