فهرست منبع

Merge branch 'preview-env-v2-fe' of github.com-meehawk:porter-dev/porter into preview-env-v2-fe

Soham Parekh 3 سال پیش
والد
کامیت
fc005735e8

+ 128 - 0
api/server/handlers/environment/validate_porter_yaml.go

@@ -0,0 +1,128 @@
+package environment
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/google/go-github/v41/github"
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/integrations/preview"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type ValidatePorterYAMLHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewValidatePorterYAMLHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ValidatePorterYAMLHandler {
+	return &ValidatePorterYAMLHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *ValidatePorterYAMLHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	envID, reqErr := requestutils.GetURLParamUint(r, "environment_id")
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	req := &types.ValidatePorterYAMLRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, envID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no such environment with ID: %d", envID)))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error reading environment with ID: %d. Error: %w", envID, err)))
+		return
+	}
+
+	ghClient, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := &types.ValidatePorterYAMLResponse{
+		Errors: []string{},
+	}
+
+	if req.Branch == "" { // get the default branch name
+		repo, _, err := ghClient.Repositories.Get(r.Context(), env.GitRepoOwner, env.GitRepoName)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		req.Branch = repo.GetDefaultBranch()
+	}
+
+	fileContents, _, ghResp, err := ghClient.Repositories.GetContents(
+		context.Background(), env.GitRepoOwner, env.GitRepoName, "porter.yaml",
+		&github.RepositoryContentGetOptions{
+			Ref: req.Branch,
+		},
+	)
+
+	if ghResp.StatusCode == 404 {
+		res.Errors = append(res.Errors, preview.ErrNoPorterYAMLFile.Error())
+		c.WriteResult(w, r, res)
+		return
+	}
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	contents, err := fileContents.GetContent()
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if strings.TrimSpace(contents) == "" {
+		res.Errors = append(res.Errors, preview.ErrEmptyPorterYAMLFile.Error())
+		c.WriteResult(w, r, res)
+		return
+	}
+
+	for _, err := range preview.Validate(contents) {
+		if err != nil {
+			res.Errors = append(res.Errors, err.Error())
+		}
+	}
+
+	c.WriteResult(w, r, res)
+}

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

@@ -347,7 +347,7 @@ func getClusterRoutes(
 			Router:   r,
 		})
 
-		// PATCH /api/projects/{project_id}/clusters/{cluster_id}/environment/{environment_id}/toggle_new_comment -> environment.NewToggleNewCommentHandler
+		// PATCH /api/projects/{project_id}/clusters/{cluster_id}/environments/{environment_id}/toggle_new_comment -> environment.NewToggleNewCommentHandler
 		toggleNewCommentEndpoint := factory.NewAPIEndpoint(
 			&types.APIRequestMetadata{
 				Verb:   types.APIVerbUpdate,
@@ -376,6 +376,35 @@ func getClusterRoutes(
 			Router:   r,
 		})
 
+		// GET /api/projects/{project_id}/clusters/{cluster_id}/environments/{environment_id}/validate_porter_yaml -> environment.NewValidatePorterYAMLHandler
+		validtatePorterYAMLEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbGet,
+				Method: types.HTTPVerbGet,
+				Path: &types.Path{
+					Parent:       basePath,
+					RelativePath: relPath + "/environments/{environment_id}/validate_porter_yaml",
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		validatePorterYAMLHandler := environment.NewValidatePorterYAMLHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &router.Route{
+			Endpoint: validtatePorterYAMLEndpoint,
+			Handler:  validatePorterYAMLHandler,
+			Router:   r,
+		})
+
 		// GET /api/projects/{project_id}/clusters/{cluster_id}/deployments -> environment.NewListDeploymentsByClusterHandler
 		listDeploymentsEndpoint := factory.NewAPIEndpoint(
 			&types.APIRequestMetadata{

+ 8 - 0
api/types/environment.go

@@ -129,3 +129,11 @@ type ToggleNewCommentRequest struct {
 }
 
 type ListEnvironmentsResponse []*Environment
+
+type ValidatePorterYAMLRequest struct {
+	Branch string `schema:"branch"`
+}
+
+type ValidatePorterYAMLResponse struct {
+	Errors []string `json:"errors"`
+}

+ 57 - 44
cli/cmd/apply.go

@@ -20,6 +20,7 @@ import (
 	"github.com/porter-dev/porter/cli/cmd/deploy"
 	"github.com/porter-dev/porter/cli/cmd/deploy/wait"
 	"github.com/porter-dev/porter/cli/cmd/preview"
+	previewInt "github.com/porter-dev/porter/internal/integrations/preview"
 	"github.com/porter-dev/porter/internal/templater/utils"
 	"github.com/porter-dev/switchboard/pkg/drivers"
 	"github.com/porter-dev/switchboard/pkg/models"
@@ -71,16 +72,41 @@ applying a configuration:
 	},
 }
 
+// applyValidateCmd represents the "porter apply validate" command when called
+// with a porter.yaml file as an argument
+var applyValidateCmd = &cobra.Command{
+	Use:   "validate",
+	Short: "Validates a porter.yaml",
+	Run: func(*cobra.Command, []string) {
+		err := applyValidate()
+
+		if err != nil {
+			color.New(color.FgRed).Printf("Error: %s\n", err.Error())
+			os.Exit(1)
+		} else {
+			color.New(color.FgGreen).Printf("The porter.yaml file is valid!\n")
+		}
+	},
+}
+
 var porterYAML string
 
 func init() {
 	rootCmd.AddCommand(applyCmd)
 
-	applyCmd.Flags().StringVarP(&porterYAML, "file", "f", "", "path to porter.yaml")
+	applyCmd.AddCommand(applyValidateCmd)
+
+	applyCmd.PersistentFlags().StringVarP(&porterYAML, "file", "f", "", "path to porter.yaml")
 	applyCmd.MarkFlagRequired("file")
 }
 
 func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
+	err := applyValidate()
+
+	if err != nil {
+		return err
+	}
+
 	fileBytes, err := ioutil.ReadFile(porterYAML)
 
 	if err != nil {
@@ -134,6 +160,28 @@ func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string
 	})
 }
 
+func applyValidate() error {
+	fileBytes, err := ioutil.ReadFile(porterYAML)
+
+	if err != nil {
+		return fmt.Errorf("error reading porter.yaml: %w", err)
+	}
+
+	validationErrors := previewInt.Validate(string(fileBytes))
+
+	if len(validationErrors) > 0 {
+		errString := "the following error(s) were found while validating the porter.yaml file:"
+
+		for _, err := range validationErrors {
+			errString += "\n- " + strings.ReplaceAll(err.Error(), "\n\n*", "\n  *")
+		}
+
+		return fmt.Errorf(errString)
+	}
+
+	return nil
+}
+
 func hasDeploymentHookEnvVars() bool {
 	if ghIDStr := os.Getenv("PORTER_GIT_INSTALLATION_ID"); ghIDStr == "" {
 		return false
@@ -170,32 +218,9 @@ func hasDeploymentHookEnvVars() bool {
 	return true
 }
 
-type ApplicationConfig struct {
-	WaitForJob bool
-
-	// If set to true, this does not run an update, it only creates the initial application and job,
-	// skipping subsequent updates
-	OnlyCreate bool
-
-	Build struct {
-		UseCache   bool `mapstructure:"use_cache"`
-		Method     string
-		Context    string
-		Dockerfile string
-		Image      string
-		Builder    string
-		Buildpacks []string
-		Env        map[string]string
-	}
-
-	EnvGroups []types.EnvGroupMeta `mapstructure:"env_groups"`
-
-	Values map[string]interface{}
-}
-
 type DeployDriver struct {
-	source      *preview.Source
-	target      *preview.Target
+	source      *previewInt.Source
+	target      *previewInt.Target
 	output      map[string]interface{}
 	lookupTable *map[string]drivers.Driver
 	logger      *zerolog.Logger
@@ -233,11 +258,6 @@ func (d *DeployDriver) ShouldApply(_ *models.Resource) bool {
 
 func (d *DeployDriver) Apply(resource *models.Resource) (*models.Resource, error) {
 	client := config.GetAPIClient()
-	name := resource.Name
-
-	if name == "" {
-		return nil, fmt.Errorf("empty resource name")
-	}
 
 	_, err := client.GetRelease(
 		context.Background(),
@@ -331,13 +351,6 @@ func (d *DeployDriver) applyApplication(resource *models.Resource, client *api.C
 		return nil, err
 	}
 
-	method := appConfig.Build.Method
-
-	if method != "pack" && method != "docker" && method != "registry" {
-		return nil, fmt.Errorf("for resource %s, config.build.method should either be \"docker\", \"pack\" or \"registry\"",
-			resourceName)
-	}
-
 	fullPath, err := filepath.Abs(appConfig.Build.Context)
 
 	if err != nil {
@@ -382,7 +395,7 @@ func (d *DeployDriver) applyApplication(resource *models.Resource, client *api.C
 		LocalPath:       fullPath,
 		LocalDockerfile: appConfig.Build.Dockerfile,
 		OverrideTag:     tag,
-		Method:          deploy.DeployBuildType(method),
+		Method:          deploy.DeployBuildType(appConfig.Build.Method),
 		EnvGroups:       appConfig.EnvGroups,
 		UseCache:        appConfig.Build.UseCache,
 	}
@@ -447,7 +460,7 @@ func (d *DeployDriver) applyApplication(resource *models.Resource, client *api.C
 	return resource, err
 }
 
-func (d *DeployDriver) createApplication(resource *models.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *ApplicationConfig) (*models.Resource, error) {
+func (d *DeployDriver) createApplication(resource *models.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *previewInt.ApplicationConfig) (*models.Resource, error) {
 	// create new release
 	color.New(color.FgGreen).Printf("Creating %s release: %s\n", d.source.Name, resource.Name)
 
@@ -533,7 +546,7 @@ func (d *DeployDriver) createApplication(resource *models.Resource, client *api.
 	return resource, handleSubdomainCreate(subdomain, err)
 }
 
-func (d *DeployDriver) updateApplication(resource *models.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *ApplicationConfig) (*models.Resource, error) {
+func (d *DeployDriver) updateApplication(resource *models.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *previewInt.ApplicationConfig) (*models.Resource, error) {
 	color.New(color.FgGreen).Println("Updating existing release:", resource.Name)
 
 	if len(appConf.Build.Env) > 0 {
@@ -621,7 +634,7 @@ func (d *DeployDriver) Output() (map[string]interface{}, error) {
 	return d.output, nil
 }
 
-func (d *DeployDriver) getApplicationConfig(resource *models.Resource) (*ApplicationConfig, error) {
+func (d *DeployDriver) getApplicationConfig(resource *models.Resource) (*previewInt.ApplicationConfig, error) {
 	populatedConf, err := drivers.ConstructConfig(&drivers.ConstructConfigOpts{
 		RawConf:      resource.Config,
 		LookupTable:  *d.lookupTable,
@@ -632,7 +645,7 @@ func (d *DeployDriver) getApplicationConfig(resource *models.Resource) (*Applica
 		return nil, err
 	}
 
-	appConf := &ApplicationConfig{}
+	appConf := &previewInt.ApplicationConfig{}
 
 	err = mapstructure.Decode(populatedConf, appConf)
 
@@ -993,7 +1006,7 @@ func (t *CloneEnvGroupHook) PreApply() error {
 			continue
 		}
 
-		appConf := &ApplicationConfig{}
+		appConf := &previewInt.ApplicationConfig{}
 
 		err := mapstructure.Decode(res.Config, &appConf)
 		if err != nil {

+ 6 - 26
cli/cmd/preview/build_image_driver.go

@@ -14,31 +14,15 @@ import (
 	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/porter-dev/porter/cli/cmd/deploy"
 	"github.com/porter-dev/porter/cli/cmd/docker"
+	"github.com/porter-dev/porter/internal/integrations/preview"
 	"github.com/porter-dev/switchboard/pkg/drivers"
 	"github.com/porter-dev/switchboard/pkg/models"
 )
 
-type BuildDriverConfig struct {
-	Build struct {
-		UsePackCache bool `mapstructure:"use_pack_cache"`
-		Method       string
-		Context      string
-		Dockerfile   string
-		Builder      string
-		Buildpacks   []string
-		Image        string
-		Env          map[string]string
-	}
-
-	EnvGroups []types.EnvGroupMeta `mapstructure:"env_groups"`
-
-	Values map[string]interface{}
-}
-
 type BuildDriver struct {
-	source      *Source
-	target      *Target
-	config      *BuildDriverConfig
+	source      *preview.Source
+	target      *preview.Target
+	config      *preview.BuildDriverConfig
 	lookupTable *map[string]drivers.Driver
 	output      map[string]interface{}
 }
@@ -61,10 +45,6 @@ func NewBuildDriver(resource *models.Resource, opts *drivers.SharedDriverOpts) (
 		return nil, err
 	}
 
-	if target.AppName == "" {
-		return nil, fmt.Errorf("target app_name is missing")
-	}
-
 	driver.target = target
 
 	return driver, nil
@@ -335,7 +315,7 @@ func (d *BuildDriver) Output() (map[string]interface{}, error) {
 	return d.output, nil
 }
 
-func (d *BuildDriver) getConfig(resource *models.Resource) (*BuildDriverConfig, error) {
+func (d *BuildDriver) getConfig(resource *models.Resource) (*preview.BuildDriverConfig, error) {
 	populatedConf, err := drivers.ConstructConfig(&drivers.ConstructConfigOpts{
 		RawConf:      resource.Config,
 		LookupTable:  *d.lookupTable,
@@ -346,7 +326,7 @@ func (d *BuildDriver) getConfig(resource *models.Resource) (*BuildDriverConfig,
 		return nil, err
 	}
 
-	config := &BuildDriverConfig{}
+	config := &preview.BuildDriverConfig{}
 
 	err = mapstructure.Decode(populatedConf, config)
 

+ 5 - 8
cli/cmd/preview/env_group_driver.go

@@ -8,19 +8,16 @@ import (
 	"github.com/mitchellh/mapstructure"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/cli/cmd/config"
+	"github.com/porter-dev/porter/internal/integrations/preview"
 	"github.com/porter-dev/switchboard/pkg/drivers"
 	"github.com/porter-dev/switchboard/pkg/models"
 )
 
-type EnvGroupDriverConfig struct {
-	EnvGroups []*types.EnvGroup `mapstructure:"env_groups"`
-}
-
 type EnvGroupDriver struct {
 	output      map[string]interface{}
 	lookupTable *map[string]drivers.Driver
-	target      *Target
-	config      *EnvGroupDriverConfig
+	target      *preview.Target
+	config      *preview.EnvGroupDriverConfig
 }
 
 func NewEnvGroupDriver(resource *models.Resource, opts *drivers.SharedDriverOpts) (drivers.Driver, error) {
@@ -112,7 +109,7 @@ func (d *EnvGroupDriver) Output() (map[string]interface{}, error) {
 	return d.output, nil
 }
 
-func (d *EnvGroupDriver) getConfig(resource *models.Resource) (*EnvGroupDriverConfig, error) {
+func (d *EnvGroupDriver) getConfig(resource *models.Resource) (*preview.EnvGroupDriverConfig, error) {
 	populatedConf, err := drivers.ConstructConfig(&drivers.ConstructConfigOpts{
 		RawConf:      resource.Config,
 		LookupTable:  *d.lookupTable,
@@ -123,7 +120,7 @@ func (d *EnvGroupDriver) getConfig(resource *models.Resource) (*EnvGroupDriverCo
 		return nil, err
 	}
 
-	config := &EnvGroupDriverConfig{}
+	config := &preview.EnvGroupDriverConfig{}
 
 	err = mapstructure.Decode(populatedConf, config)
 

+ 5 - 15
cli/cmd/preview/push_image_driver.go

@@ -11,20 +11,14 @@ import (
 	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/porter-dev/porter/cli/cmd/deploy"
 	"github.com/porter-dev/porter/cli/cmd/docker"
+	"github.com/porter-dev/porter/internal/integrations/preview"
 	"github.com/porter-dev/switchboard/pkg/drivers"
 	"github.com/porter-dev/switchboard/pkg/models"
 )
 
-type PushDriverConfig struct {
-	Push struct {
-		UsePackCache bool `mapstructure:"use_pack_cache"`
-		Image        string
-	}
-}
-
 type PushDriver struct {
-	target      *Target
-	config      *PushDriverConfig
+	target      *preview.Target
+	config      *preview.PushDriverConfig
 	lookupTable *map[string]drivers.Driver
 	output      map[string]interface{}
 }
@@ -40,10 +34,6 @@ func NewPushDriver(resource *models.Resource, opts *drivers.SharedDriverOpts) (d
 		return nil, err
 	}
 
-	if target.AppName == "" {
-		return nil, fmt.Errorf("target app_name is missing")
-	}
-
 	driver.target = target
 
 	return driver, nil
@@ -157,7 +147,7 @@ func (d *PushDriver) Output() (map[string]interface{}, error) {
 	return d.output, nil
 }
 
-func (d *PushDriver) getConfig(resource *models.Resource) (*PushDriverConfig, error) {
+func (d *PushDriver) getConfig(resource *models.Resource) (*preview.PushDriverConfig, error) {
 	populatedConf, err := drivers.ConstructConfig(&drivers.ConstructConfigOpts{
 		RawConf:      resource.Config,
 		LookupTable:  *d.lookupTable,
@@ -168,7 +158,7 @@ func (d *PushDriver) getConfig(resource *models.Resource) (*PushDriverConfig, er
 		return nil, err
 	}
 
-	config := &PushDriverConfig{}
+	config := &preview.PushDriverConfig{}
 
 	err = mapstructure.Decode(populatedConf, config)
 

+ 4 - 8
cli/cmd/preview/random_string_driver.go

@@ -4,6 +4,7 @@ import (
 	"crypto/rand"
 
 	"github.com/mitchellh/mapstructure"
+	"github.com/porter-dev/porter/internal/integrations/preview"
 	"github.com/porter-dev/switchboard/pkg/drivers"
 	"github.com/porter-dev/switchboard/pkg/models"
 )
@@ -11,14 +12,9 @@ import (
 const defaultCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
 const lowerCharset = "abcdefghijklmnopqrstuvwxyz"
 
-type RandomStringDriverConfig struct {
-	Length int
-	Lower  bool
-}
-
 type RandomStringDriver struct {
 	output map[string]interface{}
-	config *RandomStringDriverConfig
+	config *preview.RandomStringDriverConfig
 }
 
 func NewRandomStringDriver(resource *models.Resource, opts *drivers.SharedDriverOpts) (drivers.Driver, error) {
@@ -26,7 +22,7 @@ func NewRandomStringDriver(resource *models.Resource, opts *drivers.SharedDriver
 		output: make(map[string]interface{}),
 	}
 
-	driverConfig := &RandomStringDriverConfig{}
+	driverConfig := &preview.RandomStringDriverConfig{}
 
 	err := mapstructure.Decode(resource.Config, driverConfig)
 
@@ -54,7 +50,7 @@ func (d *RandomStringDriver) Apply(resource *models.Resource) (*models.Resource,
 		useCharset = lowerCharset
 	}
 
-	d.output["value"] = randomString(d.config.Length, useCharset)
+	d.output["value"] = randomString(int(d.config.Length), useCharset)
 
 	return resource, nil
 }

+ 6 - 26
cli/cmd/preview/update_config_driver.go

@@ -14,32 +14,16 @@ import (
 	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/porter-dev/porter/cli/cmd/deploy"
 	"github.com/porter-dev/porter/cli/cmd/deploy/wait"
+	"github.com/porter-dev/porter/internal/integrations/preview"
 	"github.com/porter-dev/porter/internal/templater/utils"
 	"github.com/porter-dev/switchboard/pkg/drivers"
 	"github.com/porter-dev/switchboard/pkg/models"
 )
 
-type UpdateConfigDriverConfig struct {
-	WaitForJob bool
-
-	// If set to true, this does not run an update, it only creates the initial application and job,
-	// skipping subsequent updates
-	OnlyCreate bool
-
-	UpdateConfig struct {
-		Image string
-		Tag   string
-	} `mapstructure:"update_config"`
-
-	EnvGroups []types.EnvGroupMeta `mapstructure:"env_groups"`
-
-	Values map[string]interface{}
-}
-
 type UpdateConfigDriver struct {
-	source      *Source
-	target      *Target
-	config      *UpdateConfigDriverConfig
+	source      *preview.Source
+	target      *preview.Target
+	config      *preview.UpdateConfigDriverConfig
 	lookupTable *map[string]drivers.Driver
 	output      map[string]interface{}
 }
@@ -62,10 +46,6 @@ func NewUpdateConfigDriver(resource *models.Resource, opts *drivers.SharedDriver
 		return nil, err
 	}
 
-	if target.AppName == "" {
-		return nil, fmt.Errorf("target app_name is missing")
-	}
-
 	driver.target = target
 
 	return driver, nil
@@ -225,7 +205,7 @@ func (d *UpdateConfigDriver) Output() (map[string]interface{}, error) {
 	return d.output, nil
 }
 
-func (d *UpdateConfigDriver) getConfig(resource *models.Resource) (*UpdateConfigDriverConfig, error) {
+func (d *UpdateConfigDriver) getConfig(resource *models.Resource) (*preview.UpdateConfigDriverConfig, error) {
 	populatedConf, err := drivers.ConstructConfig(&drivers.ConstructConfigOpts{
 		RawConf:      resource.Config,
 		LookupTable:  *d.lookupTable,
@@ -236,7 +216,7 @@ func (d *UpdateConfigDriver) getConfig(resource *models.Resource) (*UpdateConfig
 		return nil, err
 	}
 
-	config := &UpdateConfigDriverConfig{}
+	config := &preview.UpdateConfigDriverConfig{}
 
 	err = mapstructure.Decode(populatedConf, config)
 

+ 5 - 19
cli/cmd/preview/utils.go

@@ -8,25 +8,11 @@ import (
 
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/cli/cmd/config"
+	"github.com/porter-dev/porter/internal/integrations/preview"
 )
 
-type Source struct {
-	Name          string
-	Repo          string
-	Version       string
-	IsApplication bool
-	SourceValues  map[string]interface{}
-}
-
-type Target struct {
-	AppName   string
-	Project   uint
-	Cluster   uint
-	Namespace string
-}
-
-func GetSource(resourceName string, input map[string]interface{}) (*Source, error) {
-	output := &Source{}
+func GetSource(resourceName string, input map[string]interface{}) (*preview.Source, error) {
+	output := &preview.Source{}
 
 	// first read from env vars
 	output.Name = os.Getenv("PORTER_SOURCE_NAME")
@@ -113,8 +99,8 @@ func GetSource(resourceName string, input map[string]interface{}) (*Source, erro
 		resourceName, output.Name, output.Repo)
 }
 
-func GetTarget(resourceName string, input map[string]interface{}) (*Target, error) {
-	output := &Target{}
+func GetTarget(resourceName string, input map[string]interface{}) (*preview.Target, error) {
+	output := &preview.Target{}
 
 	// first read from env vars
 	if projectEnv := os.Getenv("PORTER_PROJECT"); projectEnv != "" {

+ 161 - 71
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentDetail.tsx

@@ -13,6 +13,9 @@ import github from "assets/github-white.png";
 import { integrationList } from "shared/common";
 import { capitalize } from "shared/string_utils";
 import leftArrow from "assets/left-arrow.svg";
+import Banner from "components/Banner";
+import Modal from "main/home/modals/Modal";
+import document from "assets/document.svg";
 
 const DeploymentDetail = () => {
   const { params } = useRouteMatch<{ namespace: string }>();
@@ -20,6 +23,10 @@ const DeploymentDetail = () => {
   const [prDeployment, setPRDeployment] = useState<PRDeployment>(null);
   const [environmentId, setEnvironmentId] = useState("");
   const [showRepoTooltip, setShowRepoTooltip] = useState(false);
+  const [porterYAMLErrors, setPorterYAMLErrors] = useState<string[]>([]);
+  const [expandedPorterYAMLErrors, setExpandedPorterYAMLErrors] = useState(
+    null
+  );
 
   const { currentProject, currentCluster } = useContext(Context);
 
@@ -61,84 +68,146 @@ const DeploymentDetail = () => {
     return <Loading />;
   }
 
+  useEffect(() => {
+    let isSubscribed = true;
+    let environment_id = parseInt(searchParams.get("environment_id"));
+
+    api
+      .validatePorterYAML(
+        "<token>",
+        {
+          branch: prDeployment.gh_pr_branch_from,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          environment_id: environment_id,
+        }
+      )
+      .then(({ data }) => {
+        if (!isSubscribed) {
+          return;
+        }
+
+        setPorterYAMLErrors(data.errors);
+      })
+      .catch((err) => {
+        console.error(err);
+        if (isSubscribed) {
+          setPorterYAMLErrors([]);
+        }
+      });
+  });
+
   let repository = `${prDeployment.gh_repo_owner}/${prDeployment.gh_repo_name}`;
 
   return (
-    <StyledExpandedChart>
-      <BreadcrumbRow>
-        <Breadcrumb
-          to={`/preview-environments/deployments/${environmentId}/${repository}`}
+    <>
+      {expandedPorterYAMLErrors && (
+        <Modal
+          onRequestClose={() => setExpandedPorterYAMLErrors(null)}
+          height="auto"
         >
-          <ArrowIcon src={leftArrow} />
-          <Wrap>Back</Wrap>
-        </Breadcrumb>
-      </BreadcrumbRow>
-      <HeaderWrapper>
-        <Title icon={pr_icon} iconWidth="25px">
-          {prDeployment.gh_pr_name}
-        </Title>
-        <InfoWrapper>
-          {prDeployment.subdomain && (
-            <PRLink to={prDeployment.subdomain} target="_blank">
-              <i className="material-icons">link</i>
-              {prDeployment.subdomain}
-            </PRLink>
-          )}
-          <TagWrapper>
-            Namespace <NamespaceTag>{params.namespace}</NamespaceTag>
-          </TagWrapper>
-        </InfoWrapper>
-        <Flex>
-          <Status>
-            <StatusDot status={prDeployment.status} />
-            {capitalize(prDeployment.status)}
-          </Status>
-          <Dot>•</Dot>
-          <DeploymentImageContainer>
-            <DeploymentTypeIcon src={integrationList.repo.icon} />
-            <RepositoryName
-              onMouseOver={() => {
-                setShowRepoTooltip(true);
-              }}
-              onMouseOut={() => {
-                setShowRepoTooltip(false);
-              }}
-            >
-              {repository}
-            </RepositoryName>
-            {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
-          </DeploymentImageContainer>
-          <Dot>•</Dot>
-          <GHALink
-            to={`https://github.com/${repository}/pull/${prDeployment.pull_request_id}`}
-            target="_blank"
+          <Message>
+            <img src={document} />
+            {expandedPorterYAMLErrors}
+          </Message>
+        </Modal>
+      )}
+      <StyledExpandedChart>
+        <BreadcrumbRow>
+          <Breadcrumb
+            to={`/preview-environments/deployments/${environmentId}/${repository}`}
           >
-            <img src={github} /> GitHub PR
-            <i className="material-icons">open_in_new</i>
-          </GHALink>
-          {prDeployment.last_workflow_run_url ? (
-            <GHALink to={prDeployment.last_workflow_run_url} target="_blank">
-              <span className="material-icons-outlined">
-                play_circle_outline
-              </span>
-              Last workflow run
+            <ArrowIcon src={leftArrow} />
+            <Wrap>Back</Wrap>
+          </Breadcrumb>
+        </BreadcrumbRow>
+        <HeaderWrapper>
+          <Title icon={pr_icon} iconWidth="25px">
+            {prDeployment.gh_pr_name}
+          </Title>
+          <InfoWrapper>
+            {prDeployment.subdomain && (
+              <PRLink to={prDeployment.subdomain} target="_blank">
+                <i className="material-icons">link</i>
+                {prDeployment.subdomain}
+              </PRLink>
+            )}
+            <TagWrapper>
+              Namespace <NamespaceTag>{params.namespace}</NamespaceTag>
+            </TagWrapper>
+          </InfoWrapper>
+          <Flex>
+            <Status>
+              <StatusDot status={prDeployment.status} />
+              {capitalize(prDeployment.status)}
+            </Status>
+            <Dot>•</Dot>
+            <DeploymentImageContainer>
+              <DeploymentTypeIcon src={integrationList.repo.icon} />
+              <RepositoryName
+                onMouseOver={() => {
+                  setShowRepoTooltip(true);
+                }}
+                onMouseOut={() => {
+                  setShowRepoTooltip(false);
+                }}
+              >
+                {repository}
+              </RepositoryName>
+              {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
+            </DeploymentImageContainer>
+            <Dot>•</Dot>
+            <GHALink
+              to={`https://github.com/${repository}/pull/${prDeployment.pull_request_id}`}
+              target="_blank"
+            >
+              <img src={github} /> GitHub PR
               <i className="material-icons">open_in_new</i>
             </GHALink>
-          ) : null}
-        </Flex>
-        <LinkToActionsWrapper></LinkToActionsWrapper>
-      </HeaderWrapper>
-      <ChartListWrapper>
-        <ChartList
-          currentCluster={context.currentCluster}
-          currentView="cluster-dashboard"
-          sortType="Newest"
-          namespace={params.namespace}
-          disableBottomPadding
-          closeChartRedirectUrl={`${window.location.pathname}${window.location.search}`}
-        />
-      </ChartListWrapper>
-    </StyledExpandedChart>
+            {prDeployment.last_workflow_run_url ? (
+              <GHALink to={prDeployment.last_workflow_run_url} target="_blank">
+                <span className="material-icons-outlined">
+                  play_circle_outline
+                </span>
+                Last workflow run
+                <i className="material-icons">open_in_new</i>
+              </GHALink>
+            ) : null}
+          </Flex>
+          <LinkToActionsWrapper></LinkToActionsWrapper>
+        </HeaderWrapper>
+        {porterYAMLErrors.length > 0 ? (
+          <Banner type="error">
+            Your porter.yaml file has errors. Please fix them before deploying.
+            <LinkButton
+              onClick={() => {
+                let yamlErrors = "";
+
+                porterYAMLErrors.forEach((err) => {
+                  yamlErrors += "- " + err + "\n";
+                });
+
+                setExpandedPorterYAMLErrors(yamlErrors);
+              }}
+            >
+              View details
+            </LinkButton>
+          </Banner>
+        ) : null}
+        <ChartListWrapper>
+          <ChartList
+            currentCluster={context.currentCluster}
+            currentView="cluster-dashboard"
+            sortType="Newest"
+            namespace={params.namespace}
+            disableBottomPadding
+            closeChartRedirectUrl={`${window.location.pathname}${window.location.search}`}
+          />
+        </ChartListWrapper>
+      </StyledExpandedChart>
+    </>
   );
 };
 
@@ -150,6 +219,27 @@ const ArrowIcon = styled.img`
   opacity: 50%;
 `;
 
+const LinkButton = styled.a`
+  text-decoration: underline;
+  margin-left: 7px;
+  cursor: pointer;
+`;
+
+const Message = styled.div`
+  padding: 20px;
+  background: #26292e;
+  border-radius: 5px;
+  line-height: 1.5em;
+  border: 1px solid #aaaabb33;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  > img {
+    width: 13px;
+    margin-right: 20px;
+  }
+`;
+
 const BreadcrumbRow = styled.div`
   width: 100%;
   display: flex;

+ 108 - 20
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx

@@ -13,7 +13,10 @@ import DynamicLink from "components/DynamicLink";
 import DashboardHeader from "../../DashboardHeader";
 import RadioFilter from "components/RadioFilter";
 import Placeholder from "components/Placeholder";
+import Banner from "components/Banner";
+import Modal from "main/home/modals/Modal";
 
+import document from "assets/document.svg";
 import pullRequestIcon from "assets/pull_request_icon.svg";
 import filterOutline from "assets/filter-outline.svg";
 import sort from "assets/sort.svg";
@@ -74,6 +77,10 @@ const DeploymentList = () => {
   const [pullRequests, setPullRequests] = useState<PullRequest[]>([]);
   const [searchValue, setSearchValue] = useState("");
   const [newCommentsDisabled, setNewCommentsDisabled] = useState(false);
+  const [porterYAMLErrors, setPorterYAMLErrors] = useState<string[]>([]);
+  const [expandedPorterYAMLErrors, setExpandedPorterYAMLErrors] = useState(
+    null
+  );
 
   const [
     statusSelectorVal,
@@ -118,6 +125,18 @@ const DeploymentList = () => {
     );
   };
 
+  const validatePorterYAML = () => {
+    return api.validatePorterYAML(
+      "<token>",
+      {},
+      {
+        project_id: currentProject.id,
+        cluster_id: currentCluster.id,
+        environment_id: Number(environment_id),
+      }
+    );
+  };
+
   useEffect(() => {
     const status_filter = getQueryParam("status_filter");
 
@@ -139,28 +158,47 @@ const DeploymentList = () => {
     let isSubscribed = true;
     setIsLoading(true);
 
-    Promise.allSettled([getPRDeploymentList(), getEnvironment()])
-      .then(([getDeploymentsResponse, getEnvironmentResponse]) => {
-        const deploymentList =
-          getDeploymentsResponse.status === "fulfilled"
-            ? getDeploymentsResponse.value.data
-            : {};
-        const environmentList =
-          getEnvironmentResponse.status === "fulfilled"
-            ? getEnvironmentResponse.value.data
-            : {};
-
-        if (!isSubscribed) {
-          return;
-        }
-
-        setDeploymentList(deploymentList.deployments || HARD_CODED_DEPLOYMENTS);
-        setPullRequests(deploymentList.pull_requests || []);
+    Promise.allSettled([
+      validatePorterYAML(),
+      getPRDeploymentList(),
+      getEnvironment(),
+    ])
+      .then(
+        ([
+          validatePorterYAMLResponse,
+          getDeploymentsResponse,
+          getEnvironmentResponse,
+        ]) => {
+          const deploymentList =
+            getDeploymentsResponse.status === "fulfilled"
+              ? getDeploymentsResponse.value.data
+              : {};
+          const environmentList =
+            getEnvironmentResponse.status === "fulfilled"
+              ? getEnvironmentResponse.value.data
+              : {};
+          const porterYAMLErrors =
+            validatePorterYAMLResponse.status === "fulfilled"
+              ? validatePorterYAMLResponse.value.data
+              : [];
+
+          if (!isSubscribed) {
+            return;
+          }
+
+          setPorterYAMLErrors(porterYAMLErrors.errors);
+          setDeploymentList(
+            deploymentList.deployments || HARD_CODED_DEPLOYMENTS
+          );
+          setPullRequests(deploymentList.pull_requests || []);
 
-        setNewCommentsDisabled(environmentList.new_comments_disabled || false);
+          setNewCommentsDisabled(
+            environmentList.new_comments_disabled || false
+          );
 
-        setIsLoading(false);
-      })
+          setIsLoading(false);
+        }
+      )
       .catch(() => {
         setDeploymentList(HARD_CODED_DEPLOYMENTS);
       });
@@ -307,6 +345,17 @@ const DeploymentList = () => {
 
   return (
     <>
+      {expandedPorterYAMLErrors && (
+        <Modal
+          onRequestClose={() => setExpandedPorterYAMLErrors(null)}
+          height="auto"
+        >
+          <Message>
+            <img src={document} />
+            {expandedPorterYAMLErrors}
+          </Message>
+        </Modal>
+      )}
       <BreadcrumbRow>
         <Breadcrumb to="/preview-environments">
           <ArrowIcon src={pullRequestIcon} />
@@ -334,6 +383,24 @@ const DeploymentList = () => {
         disableLineBreak
         capitalize={false}
       />
+      {porterYAMLErrors.length > 0 ? (
+        <Banner type="error">
+          Your porter.yaml file has errors. Please fix them before deploying.
+          <LinkButton
+            onClick={() => {
+              let yamlErrors = "";
+
+              porterYAMLErrors.forEach((err) => {
+                yamlErrors += "- " + err + "\n";
+              });
+
+              setExpandedPorterYAMLErrors(yamlErrors);
+            }}
+          >
+            View details
+          </LinkButton>
+        </Banner>
+      ) : null}
       {/* <Flex>
         <ActionsWrapper>
           <StyledStatusSelector>
@@ -469,6 +536,27 @@ const StyledLink = styled(DynamicLink)`
   }
 `;
 
+const LinkButton = styled.a`
+  text-decoration: underline;
+  margin-left: 7px;
+  cursor: pointer;
+`;
+
+const Message = styled.div`
+  padding: 20px;
+  background: #26292e;
+  border-radius: 5px;
+  line-height: 1.5em;
+  border: 1px solid #aaaabb33;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  > img {
+    width: 13px;
+    margin-right: 20px;
+  }
+`;
+
 const BreadcrumbRow = styled.div`
   width: 100%;
   display: flex;

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

@@ -241,6 +241,20 @@ const toggleNewCommentForEnvironment = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/environments/${environment_id}/toggle_new_comment`;
 });
 
+const validatePorterYAML = baseApi<
+  {
+    branch?: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    environment_id: number;
+  }
+>("GET", (pathParams) => {
+  let { project_id, cluster_id, environment_id } = pathParams;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/environments/${environment_id}/validate_porter_yaml`;
+});
+
 const createGCPIntegration = baseApi<
   {
     gcp_key_data: string;
@@ -2204,6 +2218,7 @@ export default {
   listEnvironments,
   getEnvironment,
   toggleNewCommentForEnvironment,
+  validatePorterYAML,
   createGCPIntegration,
   createInvite,
   createNamespace,

+ 4 - 1
go.mod

@@ -39,7 +39,7 @@ require (
 	github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6
 	github.com/opencontainers/image-spec v1.0.3-0.20220114050600-8b9d41f48198
 	github.com/pkg/errors v0.9.1
-	github.com/porter-dev/switchboard v0.0.0-20220628112428-7665a0121e4f
+	github.com/porter-dev/switchboard v0.0.0-20221019155755-67ff2bf04935
 	github.com/rs/zerolog v1.26.0
 	github.com/sendgrid/sendgrid-go v3.8.0+incompatible
 	github.com/spf13/cobra v1.5.0
@@ -119,7 +119,10 @@ require (
 	github.com/open-policy-agent/opa v0.44.0 // indirect
 	github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4 // indirect
 	github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect
+	github.com/santhosh-tekuri/jsonschema v1.2.4 // indirect
+	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1 // indirect
 	github.com/tchap/go-patricia/v2 v2.3.1 // indirect
+	github.com/tfkhsr/jsonschema v0.0.0-20180218143334-273afdd5a88c // indirect
 	github.com/xanzy/go-gitlab v0.68.0 // indirect
 	github.com/yashtewari/glob-intersection v0.1.0 // indirect
 	go.uber.org/goleak v1.1.12 // indirect

+ 8 - 0
go.sum

@@ -1729,6 +1729,8 @@ github.com/porter-dev/switchboard v0.0.0-20220416181342-416fc450addb h1:WNKCA31I
 github.com/porter-dev/switchboard v0.0.0-20220416181342-416fc450addb/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/porter-dev/switchboard v0.0.0-20220628112428-7665a0121e4f h1:REYJSDm2R3pM4mq88AlSBPIPhGiKFwiehe+GKZIc7Hc=
 github.com/porter-dev/switchboard v0.0.0-20220628112428-7665a0121e4f/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
+github.com/porter-dev/switchboard v0.0.0-20221019155755-67ff2bf04935 h1:hfb3nt3AJXIBbevu6ARTg9SdOkMP6WLbKBiG5hT5rcc=
+github.com/porter-dev/switchboard v0.0.0-20221019155755-67ff2bf04935/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
 github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
 github.com/poy/onpar v0.0.0-20190519213022-ee068f8ea4d1/go.mod h1:nSbFQvMj97ZyhFRSJYtut+msi4sOY6zJDGCdSc+/rZU=
@@ -1844,6 +1846,10 @@ github.com/safchain/ethtool v0.0.0-20210803160452-9aa261dae9b1/go.mod h1:Z0q5wiB
 github.com/sagikazarmark/crypt v0.1.0/go.mod h1:B/mN0msZuINBtQ1zZLEQcegFJJf9vnYIR88KRMEuODE=
 github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig=
 github.com/sanposhiho/wastedassign/v2 v2.0.6/go.mod h1:KyZ0MWTwxxBmfwn33zh3k1dmsbF2ud9pAAGfoLfjhtI=
+github.com/santhosh-tekuri/jsonschema v1.2.4 h1:hNhW8e7t+H1vgY+1QeEQpveR6D4+OwKPXCfD2aieJis=
+github.com/santhosh-tekuri/jsonschema v1.2.4/go.mod h1:TEAUOeZSmIxTTuHatJzrvARHiuO9LYd+cIxzgEHCQI4=
+github.com/santhosh-tekuri/jsonschema/v5 v5.0.1 h1:HNLA3HtUIROrQwG1cuu5EYuqk3UEoJ61Dr/9xkd6sok=
+github.com/santhosh-tekuri/jsonschema/v5 v5.0.1/go.mod h1:FKdcjfQW6rpZSnxxUvEA5H/cDPdvJ/SZJQLWWXWGrZ0=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g=
 github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
@@ -1982,6 +1988,8 @@ github.com/tdakkota/asciicheck v0.0.0-20200416200610-e657995f937b/go.mod h1:yHp0
 github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0=
 github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY=
 github.com/tetafro/godot v1.4.11/go.mod h1:LR3CJpxDVGlYOWn3ZZg1PgNZdTUvzsZWu8xaEohUpn8=
+github.com/tfkhsr/jsonschema v0.0.0-20180218143334-273afdd5a88c h1:FiJHojQ8AwCcltJnytC3Xkj37gW2WTzUzGl3AEYL+5U=
+github.com/tfkhsr/jsonschema v0.0.0-20180218143334-273afdd5a88c/go.mod h1:zhGMpmE6P0Eml0MgFIc5TljSWlr/hbNSmig8KiVEodo=
 github.com/timakin/bodyclose v0.0.0-20200424151742-cb6215831a94/go.mod h1:Qimiffbc6q9tBWlVV6x0P9sat/ao1xEkREYPPj9hphk=
 github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0=
 github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk=

+ 70 - 0
internal/integrations/preview/dep_resolver.go

@@ -0,0 +1,70 @@
+package preview
+
+import (
+	"fmt"
+
+	"github.com/porter-dev/switchboard/pkg/types"
+)
+
+type dependencyResolver struct {
+	resources  []*types.Resource
+	graph      map[string][]string
+	resolved   map[string]bool
+	unresolved map[string]bool
+}
+
+func newDependencyResolver(resources []*types.Resource) *dependencyResolver {
+	return &dependencyResolver{
+		resources:  resources,
+		graph:      make(map[string][]string),
+		resolved:   make(map[string]bool),
+		unresolved: make(map[string]bool),
+	}
+}
+
+func (r *dependencyResolver) Resolve() error {
+	if len(r.resources) > 0 {
+		// construct dependency graph
+		for _, resource := range r.resources {
+			// check for duplicate resource
+			if _, ok := r.graph[resource.Name]; ok {
+				return fmt.Errorf("duplicate resource detected: '%s'", resource.Name)
+			}
+
+			r.graph[resource.Name] = append(r.graph[resource.Name], resource.DependsOn...)
+		}
+
+		err := r.depResolve(r.resources[0].Name)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (r *dependencyResolver) depResolve(name string) error {
+	r.unresolved[name] = true
+
+	for _, dep := range r.graph[name] {
+		if _, ok := r.graph[dep]; !ok {
+			return fmt.Errorf("no such resource as: '%s'", dep)
+		}
+
+		if _, ok := r.resolved[dep]; !ok {
+			if _, ok = r.unresolved[dep]; ok {
+				return fmt.Errorf("circular depedency detected: '%s' -> '%s'", name, dep)
+			}
+			err := r.depResolve(dep)
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	r.resolved[name] = true
+	delete(r.unresolved, name)
+
+	return nil
+}

+ 305 - 0
internal/integrations/preview/driver_validators.go

@@ -0,0 +1,305 @@
+package preview
+
+import (
+	"fmt"
+
+	"github.com/mitchellh/mapstructure"
+	"github.com/porter-dev/switchboard/pkg/types"
+	"k8s.io/apimachinery/pkg/util/validation"
+)
+
+func commonValidator(resource *types.Resource) (*Source, *Target, error) {
+	source := &Source{}
+
+	err := mapstructure.Decode(resource.Source, source)
+
+	if err != nil {
+		return nil, nil, fmt.Errorf("for resource '%s': error parsing source: %w", resource.Name, err)
+	}
+
+	target := &Target{}
+
+	err = mapstructure.Decode(resource.Target, target)
+
+	if err != nil {
+		return nil, nil, fmt.Errorf("for resource '%s': error parsing target: %w", resource.Name, err)
+	}
+
+	return source, target, nil
+}
+
+func deployDriverValidator(resource *types.Resource) error {
+	source, _, err := commonValidator(resource)
+
+	if err != nil {
+		return err
+	}
+
+	if source.Name == "" {
+		return fmt.Errorf("for resource '%s': source name cannot be empty", resource.Name)
+	}
+
+	if source.Repo == "" {
+		source.Repo = "https://charts.getporter.dev"
+	}
+
+	if source.Repo == "https://charts.getporter.dev" {
+		appConfig := &ApplicationConfig{}
+
+		err = mapstructure.Decode(resource.Config, appConfig)
+
+		if err != nil {
+			return fmt.Errorf("for resource '%s': error parsing config: %w", resource.Name, err)
+		}
+
+		if appConfig.Build.Method == "" {
+			return fmt.Errorf("for resource '%s': build method cannot be empty", resource.Name)
+		} else if appConfig.Build.Method != "docker" &&
+			appConfig.Build.Method != "pack" &&
+			appConfig.Build.Method != "registry" {
+			return fmt.Errorf("for resource '%s': build method must be one of 'docker', 'pack', or 'registry'", resource.Name)
+		}
+
+		if appConfig.Build.Method == "docker" && appConfig.Build.Dockerfile == "" {
+			return fmt.Errorf("for resource '%s': dockerfile cannot be empty when using the 'docker' build method",
+				resource.Name)
+		} else if appConfig.Build.Method == "registry" && appConfig.Build.Image == "" {
+			return fmt.Errorf("for resource '%s': image cannot be empty when using the 'registry' build method",
+				resource.Name)
+		}
+
+		for _, eg := range appConfig.EnvGroups {
+			if errStrs := validation.IsDNS1123Label(eg.Name); len(errStrs) > 0 {
+				str := fmt.Sprintf("for resource '%s': invalid characters found in env group '%s' name:",
+					resource.Name, eg.Name)
+				for _, errStr := range errStrs {
+					str += fmt.Sprintf("\n  * %s", errStr)
+				}
+
+				return fmt.Errorf("%s", str)
+			}
+		}
+
+		if len(appConfig.Values) > 0 {
+			if source.Name == "web" {
+				err := validateWebChartValues(appConfig.Values)
+
+				if err != nil {
+					return fmt.Errorf("for resource '%s': error validating values for web deployment: %w",
+						resource.Name, err)
+				}
+			} else if source.Name == "worker" {
+				err := validateWorkerChartValues(appConfig.Values)
+
+				if err != nil {
+					return fmt.Errorf("for resource '%s': error validating values for worker deployment: %w",
+						resource.Name, err)
+				}
+			} else if source.Name == "job" {
+				err := validateJobChartValues(appConfig.Values)
+
+				if err != nil {
+					return fmt.Errorf("for resource '%s': error validating values for job deployment: %w",
+						resource.Name, err)
+				}
+			}
+		}
+	}
+
+	return nil
+}
+
+func buildImageDriverValidator(resource *types.Resource) error {
+	_, target, err := commonValidator(resource)
+
+	if err != nil {
+		return err
+	}
+
+	if target.AppName == "" {
+		return fmt.Errorf("for resource '%s': target app_name is missing", resource.Name)
+	} else {
+		errStrs := validation.IsDNS1123Label(target.AppName)
+
+		if len(errStrs) > 0 {
+			str := fmt.Sprintf("for resource '%s': invalid characters found in app_name:", resource.Name)
+			for _, errStr := range errStrs {
+				str += fmt.Sprintf("\n  * %s", errStr)
+			}
+
+			return fmt.Errorf("%s", str)
+		}
+	}
+
+	driverConfig := &BuildDriverConfig{}
+
+	err = mapstructure.Decode(resource.Config, driverConfig)
+
+	if err != nil {
+		return fmt.Errorf("for resource '%s': error parsing config: %w", resource.Name, err)
+	}
+
+	if driverConfig.Build.Method == "" {
+		return fmt.Errorf("for resource '%s': build method cannot be empty", resource.Name)
+	} else if driverConfig.Build.Method != "docker" &&
+		driverConfig.Build.Method != "pack" &&
+		driverConfig.Build.Method != "registry" {
+		return fmt.Errorf("for resource '%s': build method must be one of 'docker', 'pack', or 'registry'", resource.Name)
+	}
+
+	if driverConfig.Build.Method == "docker" && driverConfig.Build.Dockerfile == "" {
+		return fmt.Errorf("for resource '%s': dockerfile cannot be empty when using the 'docker' build method",
+			resource.Name)
+	} else if driverConfig.Build.Method == "registry" && driverConfig.Build.Image == "" {
+		return fmt.Errorf("for resource '%s': image cannot be empty when using the 'registry' build method",
+			resource.Name)
+	}
+
+	for _, eg := range driverConfig.EnvGroups {
+		if errStrs := validation.IsDNS1123Label(eg.Name); len(errStrs) > 0 {
+			str := fmt.Sprintf("for resource '%s': invalid characters found in env group '%s' name:",
+				resource.Name, eg.Name)
+			for _, errStr := range errStrs {
+				str += fmt.Sprintf("\n  * %s", errStr)
+			}
+
+			return fmt.Errorf("%s", str)
+		}
+	}
+
+	return nil
+}
+
+func pushImageDriverValidator(resource *types.Resource) error {
+	_, target, err := commonValidator(resource)
+
+	if err != nil {
+		return err
+	}
+
+	if target.AppName == "" {
+		return fmt.Errorf("for resource '%s': target app_name is missing", resource.Name)
+	} else {
+		errStrs := validation.IsDNS1123Label(target.AppName)
+
+		if len(errStrs) > 0 {
+			str := fmt.Sprintf("for resource '%s': invalid characters found in app_name:", resource.Name)
+			for _, errStr := range errStrs {
+				str += fmt.Sprintf("\n  * %s", errStr)
+			}
+
+			return fmt.Errorf("%s", str)
+		}
+	}
+
+	driverConfig := &PushDriverConfig{}
+
+	err = mapstructure.Decode(resource.Config, driverConfig)
+
+	if err != nil {
+		return fmt.Errorf("for resource '%s': error parsing config: %w", resource.Name, err)
+	}
+
+	if driverConfig.Push.Image == "" {
+		return fmt.Errorf("for resource '%s': image cannot be empty", resource.Name)
+	}
+
+	return nil
+}
+
+func updateConfigDriverValidator(resource *types.Resource) error {
+	_, target, err := commonValidator(resource)
+
+	if err != nil {
+		return err
+	}
+
+	if target.AppName == "" {
+		return fmt.Errorf("for resource '%s': target app_name is missing", resource.Name)
+	} else {
+		errStrs := validation.IsDNS1123Label(target.AppName)
+
+		if len(errStrs) > 0 {
+			str := fmt.Sprintf("for resource '%s': invalid characters found in app_name:", resource.Name)
+			for _, errStr := range errStrs {
+				str += fmt.Sprintf("\n  * %s", errStr)
+			}
+
+			return fmt.Errorf("%s", str)
+		}
+	}
+
+	driverConfig := &UpdateConfigDriverConfig{}
+
+	err = mapstructure.Decode(resource.Config, driverConfig)
+
+	if err != nil {
+		return fmt.Errorf("for resource '%s': error parsing config: %w", resource.Name, err)
+	}
+
+	if driverConfig.UpdateConfig.Image == "" {
+		return fmt.Errorf("for resource '%s': image cannot be empty", resource.Name)
+	}
+
+	for _, eg := range driverConfig.EnvGroups {
+		if errStrs := validation.IsDNS1123Label(eg.Name); len(errStrs) > 0 {
+			str := fmt.Sprintf("for resource '%s': invalid characters found in env group '%s' name:",
+				resource.Name, eg.Name)
+			for _, errStr := range errStrs {
+				str += fmt.Sprintf("\n  * %s", errStr)
+			}
+
+			return fmt.Errorf("%s", str)
+		}
+	}
+
+	return nil
+}
+
+func randomStringDriverValidator(resource *types.Resource) error {
+	driverConfig := &RandomStringDriverConfig{}
+
+	err := mapstructure.Decode(resource.Config, driverConfig)
+
+	if err != nil {
+		return fmt.Errorf("for resource '%s': error parsing config: %w", resource.Name, err)
+	}
+
+	return nil
+}
+
+func envGroupDriverValidator(resource *types.Resource) error {
+	target := &Target{}
+
+	err := mapstructure.Decode(resource.Target, target)
+
+	if err != nil {
+		return fmt.Errorf("for resource '%s': error parsing target: %w", resource.Name, err)
+	}
+
+	config := &EnvGroupDriverConfig{}
+
+	err = mapstructure.Decode(resource.Config, config)
+
+	if err != nil {
+		return fmt.Errorf("for resource '%s': error parsing config: %w", resource.Name, err)
+	}
+
+	for _, eg := range config.EnvGroups {
+		if errStrs := validation.IsDNS1123Label(eg.Name); len(errStrs) > 0 {
+			str := fmt.Sprintf("for resource '%s': invalid characters found in env group '%s' name:",
+				resource.Name, eg.Name)
+			for _, errStr := range errStrs {
+				str += fmt.Sprintf("\n  * %s", errStr)
+			}
+
+			return fmt.Errorf("%s", str)
+		}
+	}
+
+	return nil
+}
+
+func osEnvDriverValidator(resource *types.Resource) error {
+	return nil
+}

+ 201 - 0
internal/integrations/preview/embed/deploy_driver.schema.json.unused

@@ -0,0 +1,201 @@
+{
+  "$schema": "http://json-schema.org/schema#",
+  "title": "schema for the default deploy driver",
+  "type": "object",
+  "properties": {
+    "name": {
+      "type": "string",
+      "description": "resource name",
+      "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
+      "maxLength": 50
+    },
+    "driver": {
+      "type": "string",
+      "description": "resource driver",
+      "enum": ["deploy", ""]
+    },
+    "depends_on": {
+      "type": "array",
+      "description": "list of resource names this resource depends on",
+      "minItems": 1,
+      "items": {
+        "type": "string",
+        "description": "dependency resource name",
+        "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
+        "maxLength": 50
+      }
+    },
+    "source": {
+      "type": "object",
+      "description": "resource source",
+      "properties": {
+        "name": {
+          "type": "string",
+          "description": "source Helm chart name"
+        },
+        "version": {
+          "type": "string",
+          "description": "source Helm chart version"
+        },
+        "repo": {
+          "type": "string",
+          "description": "source Helm chart repo URL",
+          "default": "https://charts.getporter.dev"
+        }
+      },
+      "required": ["name"]
+    },
+    "target": {
+      "type": "object",
+      "description": "resource target",
+      "properties": {
+        "project": {
+          "type": "integer",
+          "description": "target Porter project ID"
+        },
+        "cluster": {
+          "type": "integer",
+          "description": "target Porter cluster ID"
+        },
+        "namespace": {
+          "type": "string",
+          "description": "target namespace"
+        }
+      }
+    },
+    "config": {
+      "type": "object",
+      "description": "resource configuration",
+      "additionalProperties": true
+    },
+    "if": {
+      "properties": {
+        "source": {
+          "properties": { "repo": { "const": "https://charts.getporter.dev" } }
+        }
+      }
+    },
+    "then": {
+      "properties": {
+        "config": {
+          "properties": {
+            "waitForJob": {
+              "type": "boolean",
+              "description": "wait for job to complete"
+            },
+            "onlyCreate": {
+              "type": "boolean",
+              "description": "only create the resource"
+            },
+            "build": {
+              "type": "object",
+              "description": "build configuration",
+              "properties": {
+                "use_cache": {
+                  "type": "boolean",
+                  "description": "use Porter build cache"
+                },
+                "method": {
+                  "type": "string",
+                  "description": "build method",
+                  "default": "docker",
+                  "enum": ["docker", "pack", "registry"]
+                },
+                "context": {
+                  "type": "string",
+                  "description": "build context"
+                },
+                "dockerfile": {
+                  "type": "string",
+                  "description": "Dockerfile path"
+                },
+                "image": {
+                  "type": "string",
+                  "description": "image name"
+                },
+                "builder": {
+                  "type": "string",
+                  "description": "buildpacks builder image"
+                },
+                "buildpacks": {
+                  "type": "array",
+                  "description": "list of buildpacks",
+                  "minItems": 1,
+                  "items": {
+                    "type": "string",
+                    "description": "buildpack"
+                  }
+                },
+                "env": {
+                  "type": "object",
+                  "description": "build-time environment variables",
+                  "additionalProperties": { "type": "string" }
+                }
+              },
+              "allOf": [
+                {
+                  "if": {
+                    "properties": {
+                      "method": { "const": "docker" }
+                    }
+                  },
+                  "then": {
+                    "dependentRequired": {
+                      "method": ["dockerfile"]
+                    }
+                  }
+                },
+                {
+                  "if": {
+                    "properties": {
+                      "method": { "const": "registry" }
+                    }
+                  },
+                  "then": {
+                    "dependentRequired": {
+                      "method": ["image"]
+                    }
+                  }
+                }
+              ]
+            },
+            "env_groups": {
+              "type": "array",
+              "description": "list of environment groups to use in the deployment",
+              "minItems": 1,
+              "items": {
+                "type": "object",
+                "description": "environment group",
+                "properties": {
+                  "name": {
+                    "type": "string",
+                    "description": "environment group name"
+                  },
+                  "version": {
+                    "type": "integer",
+                    "minimum": 0,
+                    "default": 0,
+                    "description": "environment group version"
+                  },
+                  "namespace": {
+                    "type": "string",
+                    "description": "environment group namespace"
+                  }
+                },
+                "required": ["name"]
+              }
+            },
+            "values": {
+              "type": "object",
+              "description": "Helm values to use for the deployment",
+              "additionalProperties": true
+            }
+          },
+          "required": ["build"]
+        }
+      },
+      "required": ["config"]
+    }
+  },
+  "required": ["name", "source"]
+}

+ 73 - 0
internal/integrations/preview/embed/job.values.schema.json

@@ -0,0 +1,73 @@
+{
+  "$schema": "http://json-schema.org/schema#",
+  "type": "object",
+  "properties": {
+    "replicaCount": {
+      "type": "integer",
+      "minimum": 1,
+      "default": 1
+    },
+    "container": {
+      "type": "object",
+      "properties": {
+        "port": {
+          "type": "integer",
+          "default": 80
+        },
+        "command": {
+          "type": "string"
+        },
+        "env": {
+          "type": "object",
+          "properties": {
+            "normal": {
+              "type": "object",
+              "additionalProperties": {
+                "type": "string"
+              }
+            }
+          }
+        }
+      }
+    },
+    "schedule": {
+      "type": "object",
+      "properties": {
+        "enabled": {
+          "type": "boolean",
+          "default": false
+        },
+        "value": {
+          "type": "string",
+          "default": "*/5 * * * *"
+        },
+        "successfulHistory": {
+          "type": "integer",
+          "default": 20
+        },
+        "failedHistory": {
+          "type": "integer",
+          "default": 20
+        }
+      }
+    },
+    "resources": {
+      "type": "object",
+      "properties": {
+        "requests": {
+          "type": "object",
+          "properties": {
+            "cpu": {
+              "type": "string",
+              "pattern": "^\\d+(m){0,1}$"
+            },
+            "memory": {
+              "type": "string",
+              "pattern": "^\\d+(Ki|Mi|Gi)$"
+            }
+          }
+        }
+      }
+    }
+  }
+}

+ 90 - 0
internal/integrations/preview/embed/porteryaml.schema.json.unused

@@ -0,0 +1,90 @@
+{
+  "$schema": "http://json-schema.org/schema#",
+  "type": "object",
+  "properties": {
+    "version": {
+      "type": "string",
+      "description": "porter.yaml version",
+      "pattern": "^v[1-9][0-9]*$"
+    },
+    "resources": {
+      "type": "array",
+      "description": "list of resources",
+      "minItems": 1,
+      "items": {
+        "type": "object",
+        "properties": {
+          "name": {
+            "type": "string",
+            "description": "resource name",
+            "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
+            "maxLength": 50
+          },
+          "driver": {
+            "type": "string",
+            "description": "resource driver"
+          },
+          "depends_on": {
+            "type": "array",
+            "description": "list of resource names this resource depends on",
+            "minItems": 1,
+            "items": {
+              "type": "string",
+              "description": "dependency resource name",
+              "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
+              "maxLength": 50
+            }
+          },
+          "source": {
+            "type": "object",
+            "description": "resource source",
+            "properties": {
+              "name": {
+                "type": "string",
+                "description": "source Helm chart name"
+              },
+              "version": {
+                "type": "string",
+                "description": "source Helm chart version"
+              },
+              "repo": {
+                "type": "string",
+                "description": "source Helm chart repo URL"
+              }
+            }
+          },
+          "target": {
+            "type": "object",
+            "description": "resource target",
+            "properties": {
+              "project": {
+                "type": "integer",
+                "description": "target Porter project ID"
+              },
+              "cluster": {
+                "type": "integer",
+                "description": "target Porter cluster ID"
+              },
+              "namespace": {
+                "type": "string",
+                "description": "target namespace"
+              },
+              "app_name": {
+                "type": "string",
+                "description": "target app name",
+                "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$",
+                "maxLength": 50
+              }
+            }
+          },
+          "config": {
+            "type": "object",
+            "description": "resource config"
+          }
+        },
+        "required": ["name"]
+      }
+    }
+  },
+  "required": ["version", "resources"]
+}

+ 289 - 0
internal/integrations/preview/embed/web.values.schema.json

@@ -0,0 +1,289 @@
+{
+  "$schema": "http://json-schema.org/schema#",
+  "type": "object",
+  "properties": {
+    "replicaCount": {
+      "type": "integer",
+      "minimum": 1,
+      "default": 1
+    },
+    "ingress": {
+      "type": "object",
+      "properties": {
+        "enabled": {
+          "type": "boolean",
+          "default": true
+        },
+        "hosts": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          }
+        },
+        "porter_hosts": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          }
+        },
+        "provider": {
+          "type": "string"
+        },
+        "custom_domain": {
+          "type": "boolean",
+          "default": false
+        },
+        "custom_paths": {
+          "type": "string"
+        },
+        "rewriteCustomPathsEnabled": {
+          "type": "boolean",
+          "default": true
+        },
+        "annotations": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          }
+        },
+        "wildcard": {
+          "type": "boolean",
+          "default": false
+        },
+        "tls": {
+          "type": "boolean",
+          "default": true
+        },
+        "useDefaultIngressTLSSecret": {
+          "type": "boolean",
+          "default": false
+        }
+      }
+    },
+    "container": {
+      "type": "object",
+      "properties": {
+        "port": {
+          "type": "integer",
+          "default": 80
+        },
+        "command": {
+          "type": "string"
+        },
+        "args": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          }
+        },
+        "env": {
+          "type": "object",
+          "properties": {
+            "normal": {
+              "type": "object",
+              "additionalProperties": {
+                "type": "string"
+              }
+            }
+          }
+        }
+      }
+    },
+    "resources": {
+      "type": "object",
+      "properties": {
+        "requests": {
+          "type": "object",
+          "properties": {
+            "cpu": {
+              "type": "string",
+              "pattern": "^\\d+(m){0,1}$"
+            },
+            "memory": {
+              "type": "string",
+              "pattern": "^\\d+(Ki|Mi|Gi)$"
+            }
+          }
+        }
+      }
+    },
+    "autoscaling": {
+      "type": "object",
+      "properties": {
+        "enabled": {
+          "type": "boolean",
+          "default": false
+        },
+        "minReplicas": {
+          "type": "integer",
+          "default": 1
+        },
+        "maxReplicas": {
+          "type": "integer",
+          "default": 10
+        },
+        "targetCPUUtilizationPercentage": {
+          "type": "integer",
+          "default": 50
+        },
+        "targetMemoryUtilizationPercentage": {
+          "type": "integer",
+          "default": 50
+        }
+      }
+    },
+    "health": {
+      "type": "object",
+      "properties": {
+        "livenessProbe": {
+          "type": "object",
+          "properties": {
+            "enabled": {
+              "type": "boolean",
+              "default": false
+            },
+            "path": {
+              "type": "string",
+              "default": "/livez"
+            },
+            "scheme": {
+              "type": "string",
+              "default": "HTTP"
+            },
+            "initialDelaySeconds": {
+              "type": "integer",
+              "default": 0
+            },
+            "periodSeconds": {
+              "type": "integer",
+              "default": 5
+            },
+            "timeoutSeconds": {
+              "type": "integer",
+              "default": 1
+            },
+            "successThreshold": {
+              "type": "integer",
+              "default": 1
+            },
+            "failureThreshold": {
+              "type": "integer",
+              "default": 3
+            },
+            "auth": {
+              "type": "object",
+              "properties": {
+                "enabled": {
+                  "type": "boolean",
+                  "default": false
+                },
+                "username": {
+                  "type": "string"
+                },
+                "password": {
+                  "type": "string"
+                }
+              }
+            }
+          }
+        },
+        "readinessProbe": {
+          "type": "object",
+          "properties": {
+            "enabled": {
+              "type": "boolean",
+              "default": false
+            },
+            "path": {
+              "type": "string",
+              "default": "/readyz"
+            },
+            "scheme": {
+              "type": "string",
+              "default": "HTTP"
+            },
+            "initialDelaySeconds": {
+              "type": "integer",
+              "default": 0
+            },
+            "periodSeconds": {
+              "type": "integer",
+              "default": 5
+            },
+            "timeoutSeconds": {
+              "type": "integer",
+              "default": 1
+            },
+            "successThreshold": {
+              "type": "integer",
+              "default": 1
+            },
+            "failureThreshold": {
+              "type": "integer",
+              "default": 3
+            },
+            "auth": {
+              "type": "object",
+              "properties": {
+                "enabled": {
+                  "type": "boolean",
+                  "default": false
+                },
+                "username": {
+                  "type": "string"
+                },
+                "password": {
+                  "type": "string"
+                }
+              }
+            }
+          }
+        },
+        "startupProbe": {
+          "type": "object",
+          "properties": {
+            "enabled": {
+              "type": "boolean",
+              "default": false
+            },
+            "path": {
+              "type": "string",
+              "default": "/startupz"
+            },
+            "scheme": {
+              "type": "string",
+              "default": "HTTP"
+            },
+            "failureThreshold": {
+              "type": "integer",
+              "default": 3
+            },
+            "periodSeconds": {
+              "type": "integer",
+              "default": 5
+            },
+            "timeoutSeconds": {
+              "type": "integer",
+              "default": 1
+            },
+            "auth": {
+              "type": "object",
+              "properties": {
+                "enabled": {
+                  "type": "boolean",
+                  "default": false
+                },
+                "username": {
+                  "type": "string"
+                },
+                "password": {
+                  "type": "string"
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}

+ 136 - 0
internal/integrations/preview/embed/worker.values.schema.json

@@ -0,0 +1,136 @@
+{
+  "$schema": "http://json-schema.org/schema#",
+  "type": "object",
+  "properties": {
+    "replicaCount": {
+      "type": "integer",
+      "minimum": 1,
+      "default": 1
+    },
+    "container": {
+      "type": "object",
+      "properties": {
+        "port": {
+          "type": "integer",
+          "default": 80
+        },
+        "command": {
+          "type": "string"
+        },
+        "env": {
+          "type": "object",
+          "properties": {
+            "normal": {
+              "type": "object",
+              "additionalProperties": {
+                "type": "string"
+              }
+            }
+          }
+        }
+      }
+    },
+    "resources": {
+      "type": "object",
+      "properties": {
+        "requests": {
+          "type": "object",
+          "properties": {
+            "cpu": {
+              "type": "string",
+              "pattern": "^\\d+(m){0,1}$"
+            },
+            "memory": {
+              "type": "string",
+              "pattern": "^\\d+(Ki|Mi|Gi)$"
+            }
+          }
+        }
+      }
+    },
+    "autoscaling": {
+      "type": "object",
+      "properties": {
+        "enabled": {
+          "type": "boolean",
+          "default": false
+        },
+        "minReplicas": {
+          "type": "integer",
+          "default": 1
+        },
+        "maxReplicas": {
+          "type": "integer",
+          "default": 10
+        },
+        "targetCPUUtilizationPercentage": {
+          "type": "integer",
+          "default": 50
+        },
+        "targetMemoryUtilizationPercentage": {
+          "type": "integer",
+          "default": 50
+        }
+      }
+    },
+    "health": {
+      "type": "object",
+      "properties": {
+        "enabled": {
+          "type": "boolean",
+          "default": false
+        },
+        "command": {
+          "type": "string",
+          "default": "ls -l"
+        },
+        "periodSeconds": {
+          "type": "integer",
+          "default": 5
+        },
+        "failureThreshold": {
+          "type": "integer",
+          "default": 3
+        },
+        "readinessProbe": {
+          "type": "object",
+          "properties": {
+            "enabled": {
+              "type": "boolean",
+              "default": false
+            },
+            "command": {
+              "type": "string",
+              "default": "ls -l"
+            },
+            "periodSeconds": {
+              "type": "integer",
+              "default": 5
+            }
+          }
+        },
+        "startupProbe": {
+          "type": "object",
+          "properties": {
+            "enabled": {
+              "type": "boolean",
+              "default": false
+            },
+            "command": {
+              "type": "string",
+              "default": "ls -l"
+            },
+            "failureThreshold": {
+              "type": "integer",
+              "default": 3
+            },
+            "periodSeconds": {
+              "type": "integer",
+              "default": 5
+            }
+          }
+        }
+      }
+    }
+  }
+}

+ 92 - 0
internal/integrations/preview/schema_validate.go

@@ -0,0 +1,92 @@
+package preview
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"github.com/santhosh-tekuri/jsonschema/v5"
+)
+
+func validateWebChartValues(values map[string]interface{}) error {
+	webValuesSchema, err := schemas.ReadFile("embed/web.values.schema.json")
+
+	if err != nil {
+		return fmt.Errorf("error reading web chart values schema: %w", err)
+	}
+
+	scm, err := jsonschema.CompileString("web.values.schema.json", string(webValuesSchema))
+
+	if err != nil {
+		return fmt.Errorf("error compiling web chart values schema: %w", err)
+	}
+
+	jsonBytes, err := json.Marshal(values)
+
+	if err != nil {
+		return fmt.Errorf("error marshalling values to JSON: %w", err)
+	}
+
+	var v interface{}
+
+	if err := json.Unmarshal(jsonBytes, &v); err != nil {
+		return fmt.Errorf("error unmarshalling values JSON to interface: %w", err)
+	}
+
+	return scm.Validate(v)
+}
+
+func validateWorkerChartValues(values map[string]interface{}) error {
+	workerValuesSchema, err := schemas.ReadFile("embed/worker.values.schema.json")
+
+	if err != nil {
+		return fmt.Errorf("error reading worker chart values schema: %w", err)
+	}
+
+	scm, err := jsonschema.CompileString("worker.values.schema.json", string(workerValuesSchema))
+
+	if err != nil {
+		return fmt.Errorf("error compiling worker chart values schema: %w", err)
+	}
+
+	jsonBytes, err := json.Marshal(values)
+
+	if err != nil {
+		return fmt.Errorf("error marshalling values to JSON: %w", err)
+	}
+
+	var v interface{}
+
+	if err := json.Unmarshal(jsonBytes, &v); err != nil {
+		return fmt.Errorf("error unmarshalling values JSON to interface: %w", err)
+	}
+
+	return scm.Validate(v)
+}
+
+func validateJobChartValues(values map[string]interface{}) error {
+	jobValuesSchema, err := schemas.ReadFile("embed/job.values.schema.json")
+
+	if err != nil {
+		return fmt.Errorf("error reading job chart values schema: %w", err)
+	}
+
+	scm, err := jsonschema.CompileString("job.values.schema.json", string(jobValuesSchema))
+
+	if err != nil {
+		return fmt.Errorf("error compiling job chart values schema: %w", err)
+	}
+
+	jsonBytes, err := json.Marshal(values)
+
+	if err != nil {
+		return fmt.Errorf("error marshalling values to JSON: %w", err)
+	}
+
+	var v interface{}
+
+	if err := json.Unmarshal(jsonBytes, &v); err != nil {
+		return fmt.Errorf("error unmarshalling values JSON to interface: %w", err)
+	}
+
+	return scm.Validate(v)
+}

+ 91 - 0
internal/integrations/preview/utils.go

@@ -0,0 +1,91 @@
+package preview
+
+import "github.com/porter-dev/porter/api/types"
+
+type Source struct {
+	Name          string
+	Repo          string
+	Version       string
+	IsApplication bool
+	SourceValues  map[string]interface{}
+}
+
+type Target struct {
+	AppName   string
+	Project   uint
+	Cluster   uint
+	Namespace string
+}
+
+type RandomStringDriverConfig struct {
+	Length uint
+	Lower  bool
+}
+
+type EnvGroupDriverConfig struct {
+	EnvGroups []*types.EnvGroup `mapstructure:"env_groups"`
+}
+
+type UpdateConfigDriverConfig struct {
+	WaitForJob bool
+
+	// If set to true, this does not run an update, it only creates the initial application and job,
+	// skipping subsequent updates
+	OnlyCreate bool
+
+	UpdateConfig struct {
+		Image string
+		Tag   string
+	} `mapstructure:"update_config"`
+
+	EnvGroups []types.EnvGroupMeta `mapstructure:"env_groups"`
+
+	Values map[string]interface{}
+}
+
+type PushDriverConfig struct {
+	Push struct {
+		UsePackCache bool `mapstructure:"use_pack_cache"`
+		Image        string
+	}
+}
+
+type BuildDriverConfig struct {
+	Build struct {
+		UsePackCache bool `mapstructure:"use_pack_cache"`
+		Method       string
+		Context      string
+		Dockerfile   string
+		Builder      string
+		Buildpacks   []string
+		Image        string
+		Env          map[string]string
+	}
+
+	EnvGroups []types.EnvGroupMeta `mapstructure:"env_groups"`
+
+	Values map[string]interface{}
+}
+
+type ApplicationConfig struct {
+	WaitForJob bool
+
+	// If set to true, this does not run an update, it only creates the initial application and job,
+	// skipping subsequent updates
+	OnlyCreate bool
+
+	Build struct {
+		UseCache   bool `mapstructure:"use_cache"`
+		Method     string
+		Context    string
+		Dockerfile string
+		Image      string
+		Builder    string
+		Buildpacks []string
+		Env        map[string]string
+	}
+
+	EnvGroups []types.EnvGroupMeta `mapstructure:"env_groups"`
+
+	Values map[string]interface{}
+}

+ 79 - 0
internal/integrations/preview/validate.go

@@ -0,0 +1,79 @@
+package preview
+
+import (
+	"embed"
+	"errors"
+	"fmt"
+
+	"github.com/porter-dev/switchboard/pkg/parser"
+	"github.com/porter-dev/switchboard/pkg/types"
+	"k8s.io/apimachinery/pkg/util/validation"
+)
+
+//go:embed embed/*.schema.json
+var schemas embed.FS
+
+var (
+	ErrNoPorterYAMLFile    = errors.New("porter.yaml does not exist in the root of this repository")
+	ErrEmptyPorterYAMLFile = errors.New("porter.yaml is empty")
+
+	ErrUnsupportedDriver = errors.New("no such driver")
+)
+
+type driverBasedResourceValidator func(*types.Resource) error
+
+var driverValidators = make(map[string]driverBasedResourceValidator)
+
+func init() {
+	driverValidators[""] = deployDriverValidator
+	driverValidators["deploy"] = deployDriverValidator
+	driverValidators["build-image"] = buildImageDriverValidator
+	driverValidators["push-image"] = pushImageDriverValidator
+	driverValidators["update-config"] = updateConfigDriverValidator
+	driverValidators["random-string"] = randomStringDriverValidator
+	driverValidators["env-group"] = envGroupDriverValidator
+	driverValidators["os-env"] = osEnvDriverValidator
+}
+
+func Validate(contents string) []error {
+	var errors []error
+
+	resGroup, err := parser.ParseRawBytes([]byte(contents))
+
+	if err != nil {
+		errors = append(errors, fmt.Errorf("error parsing porter.yaml: %w", err))
+		return errors
+	}
+
+	depResolver := newDependencyResolver(resGroup.Resources)
+
+	err = depResolver.Resolve()
+
+	if err != nil {
+		errors = append(errors, fmt.Errorf("error resolving dependencies: %w", err))
+		return errors
+	}
+
+	for _, res := range resGroup.Resources {
+		if len(res.Name) == 0 {
+			errors = append(errors, fmt.Errorf("resource has no name"))
+		} else if errStrs := validation.IsDNS1123Label(res.Name); len(errStrs) > 0 {
+			str := fmt.Sprintf("for resource '%s': invalid characters found in name:", res.Name)
+			for _, errStr := range errStrs {
+				str += fmt.Sprintf("\n  * %s", errStr)
+			}
+
+			errors = append(errors, fmt.Errorf("%s", str))
+		}
+
+		if validator, ok := driverValidators[res.Driver]; ok {
+			if err := validator(res); err != nil {
+				errors = append(errors, err)
+			}
+		} else {
+			errors = append(errors, fmt.Errorf("for resource '%s': %w: %s", res.Name, ErrUnsupportedDriver, res.Driver))
+		}
+	}
+
+	return errors
+}