Explorar o código

Merge branch 'dev' of https://github.com/porter-dev/porter into dev

Alexander Belanger %!s(int64=3) %!d(string=hai) anos
pai
achega
e51963f637
Modificáronse 52 ficheiros con 923 adicións e 167 borrados
  1. 128 0
      api/server/handlers/environment/validate_porter_yaml.go
  2. 12 0
      api/server/handlers/release/update_image_batch.go
  3. 30 1
      api/server/router/cluster.go
  4. 8 0
      api/types/environment.go
  5. 8 30
      cli/cmd/apply.go
  6. 6 22
      cli/cmd/preview/build_image_driver.go
  7. 5 8
      cli/cmd/preview/env_group_driver.go
  8. 5 11
      cli/cmd/preview/push_image_driver.go
  9. 4 8
      cli/cmd/preview/random_string_driver.go
  10. 6 22
      cli/cmd/preview/update_config_driver.go
  11. 5 19
      cli/cmd/preview/utils.go
  12. BIN=BIN
      dashboard/src/assets/azure.png
  13. 0 1
      dashboard/src/components/Button.tsx
  14. 0 2
      dashboard/src/components/MultiSaveButton.tsx
  15. 0 2
      dashboard/src/components/ProvisionerStatus.tsx
  16. 0 2
      dashboard/src/components/SaveButton.tsx
  17. 0 1
      dashboard/src/components/repo-selector/ContentsList.tsx
  18. 0 1
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  19. 1 1
      dashboard/src/main/home/cluster-dashboard/SortSelector.tsx
  20. 0 2
      dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx
  21. 0 1
      dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx
  22. 0 1
      dashboard/src/main/home/cluster-dashboard/dashboard/events/EventsTab.tsx
  23. 0 1
      dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTab.tsx
  24. 0 2
      dashboard/src/main/home/cluster-dashboard/databases/DatabasesList.tsx
  25. 0 1
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx
  26. 0 2
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  27. 0 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  28. 0 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx
  29. 0 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/incidents/EventsTab.tsx
  30. 0 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/ButtonEnablePREnvironments.tsx
  31. 0 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/RecreateWorkflowFilesModal.tsx
  32. 0 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentCard.tsx
  33. 0 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentsList.tsx
  34. 1 2
      dashboard/src/main/home/cluster-dashboard/stacks/Dashboard.tsx
  35. 0 2
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/Settings.tsx
  36. 0 1
      dashboard/src/main/home/cluster-dashboard/stacks/components/styles.ts
  37. 0 1
      dashboard/src/main/home/integrations/IntegrationCategories.tsx
  38. 0 1
      dashboard/src/main/home/integrations/IntegrationList.tsx
  39. 0 1
      dashboard/src/main/home/launch/expanded-template/TemplateInfo.tsx
  40. 0 1
      dashboard/src/main/home/modals/RedirectToOnboardingModal.tsx
  41. 0 1
      dashboard/src/main/home/modals/UsageWarningModal.tsx
  42. 0 1
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/_DORegistryForm.tsx
  43. 0 1
      dashboard/src/main/home/onboarding/steps/ConnectSource.tsx
  44. 0 2
      dashboard/src/main/home/project-settings/APITokensSection.tsx
  45. 0 2
      dashboard/src/main/home/project-settings/InviteList.tsx
  46. 0 1
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  47. 296 0
      dashboard/src/main/home/provisioner/AzureFormSection.tsx
  48. 36 1
      dashboard/src/main/home/provisioner/ProvisionerSettings.tsx
  49. 68 0
      internal/integrations/preview/dep_resolver.go
  50. 150 0
      internal/integrations/preview/driver_validators.go
  51. 91 0
      internal/integrations/preview/utils.go
  52. 63 0
      internal/integrations/preview/validate.go

+ 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)
+}

+ 12 - 0
api/server/handlers/release/update_image_batch.go

@@ -81,6 +81,12 @@ func (c *UpdateImageBatchHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 			rel, err := helmAgent.GetRelease(releases[index].Name, 0, false)
 
 			if err != nil {
+				// if this is a release not found error, just return - the release has likely been deleted from the underlying
+				// cluster but has not been deleted from the Porter database yet
+				if strings.Contains(err.Error(), "release: not found") {
+					return
+				}
+
 				mu.Lock()
 				errors = append(errors, fmt.Sprintf("Error for %s, index %d: %s", releases[index].Name, index, err.Error()))
 				mu.Unlock()
@@ -105,6 +111,12 @@ func (c *UpdateImageBatchHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 				_, err = helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf)
 
 				if err != nil {
+					// if this is a release not found error, just return - the release has likely been deleted from the underlying
+					// cluster in the time since we've read the release, but has not been deleted from the Porter database yet
+					if strings.Contains(err.Error(), "release: not found") {
+						return
+					}
+
 					mu.Lock()
 					errors = append(errors, fmt.Sprintf("Error for %s, index %d: %s", releases[index].Name, index, err.Error()))
 					mu.Unlock()

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

@@ -346,7 +346,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,
@@ -375,6 +375,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

@@ -138,3 +138,11 @@ type UpdateEnvironmentSettingsRequest struct {
 	DisableNewComments bool     `json:"disable_new_comments"`
 	GitRepoBranches    []string `json:"git_repo_branches"`
 }
+
+type ValidatePorterYAMLRequest struct {
+	Branch string `schema:"branch"`
+}
+
+type ValidatePorterYAMLResponse struct {
+	Errors []string `json:"errors"`
+}

+ 8 - 30
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"
@@ -170,32 +171,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
@@ -447,7 +425,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 +511,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 +599,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 +610,7 @@ func (d *DeployDriver) getApplicationConfig(resource *models.Resource) (*Applica
 		return nil, err
 	}
 
-	appConf := &ApplicationConfig{}
+	appConf := &previewInt.ApplicationConfig{}
 
 	err = mapstructure.Decode(populatedConf, appConf)
 
@@ -993,7 +971,7 @@ func (t *CloneEnvGroupHook) PreApply() error {
 			continue
 		}
 
-		appConf := &ApplicationConfig{}
+		appConf := &previewInt.ApplicationConfig{}
 
 		err := mapstructure.Decode(res.Config, &appConf)
 		if err != nil {

+ 6 - 22
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{}
 }
@@ -335,7 +319,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 +330,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 - 11
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{}
 }
@@ -157,7 +151,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 +162,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 - 22
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{}
 }
@@ -225,7 +209,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 +220,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 != "" {

BIN=BIN
dashboard/src/assets/azure.png


+ 0 - 1
dashboard/src/components/Button.tsx

@@ -37,7 +37,6 @@ const ButtonWrapper = styled.div`
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
 

+ 0 - 2
dashboard/src/components/MultiSaveButton.tsx

@@ -258,8 +258,6 @@ const Button = styled.button<ButtonProps>`
   border: 0;
   border-radius: ${(props) => (props.rounded ? "100px" : "5px 0 0 5px")};
   background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
-  box-shadow: ${(props) =>
-    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
   cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
   user-select: none;
   :focus {

+ 0 - 2
dashboard/src/components/ProvisionerStatus.tsx

@@ -951,8 +951,6 @@ const Button = styled.button`
   border: 0;
   border-radius: 5px;
   background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
-  box-shadow: ${(props) =>
-    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
   cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
   user-select: none;
   :focus {

+ 0 - 2
dashboard/src/components/SaveButton.tsx

@@ -195,8 +195,6 @@ const Button = styled.button<{
   border: 0;
   border-radius: ${(props) => (props.rounded ? "100px" : "5px")};
   background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
-  box-shadow: ${(props) =>
-    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
   cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
   user-select: none;
   :focus {

+ 0 - 1
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -689,7 +689,6 @@ const UseButton = styled.div`
   font-weight: 500;
   padding: 10px 15px;
   border-radius: 100px;
-  box-shadow: 0 2px 5px 0 #00000030;
   cursor: pointer;
   :hover {
     filter: brightness(120%);

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -419,7 +419,6 @@ const Button = styled.div`
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
 

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/SortSelector.tsx

@@ -53,7 +53,7 @@ export default class SortSelector extends Component<PropsType, StateType> {
           options={this.getSortOptions()}
           name="Sort"
           icon={sort}
-          dropdownAlignRight={true}
+          dropdownAlignRight={false}
           noMargin
         />
       </StyledSortSelector>

+ 0 - 2
dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx

@@ -241,8 +241,6 @@ const Button = styled.button`
   border: 0;
   border-radius: 5px;
   background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
-  box-shadow: ${(props) =>
-    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
   cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
   user-select: none;
   :focus {

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/NamespaceList.tsx

@@ -258,7 +258,6 @@ const Button = styled.div`
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
 

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/events/EventsTab.tsx

@@ -162,7 +162,6 @@ const InstallPorterAgentButton = styled.button`
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
   background: ${(props: { disabled?: boolean }) =>

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/incidents/IncidentsTab.tsx

@@ -153,7 +153,6 @@ const InstallPorterAgentButton = styled.button`
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
   background: ${(props: { disabled?: boolean }) =>

+ 0 - 2
dashboard/src/main/home/cluster-dashboard/databases/DatabasesList.tsx

@@ -316,7 +316,6 @@ const Button = styled(Link)`
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
 
@@ -354,7 +353,6 @@ const ConnectButton = styled.button<{}>`
   border: 0;
   border-radius: 5px;
   background: #5561c0;
-  box-shadow: 0 2px 5px 0 #00000030;
   cursor: pointer;
   user-select: none;
   :focus {

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx

@@ -192,7 +192,6 @@ const Button = styled.div`
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
 

+ 0 - 2
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -871,8 +871,6 @@ const Button = styled.button`
   border: 0;
   border-radius: 5px;
   background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
-  box-shadow: ${(props) =>
-    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
   cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
   user-select: none;
   :focus {

+ 0 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -355,8 +355,6 @@ const Button = styled.button`
   border: 0;
   border-radius: 5px;
   background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
-  box-shadow: ${(props) =>
-    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
   cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
   user-select: none;
   :focus {

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx

@@ -150,7 +150,6 @@ const InstallPorterAgentButton = styled.button`
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
   background: ${(props: { disabled?: boolean }) =>

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/incidents/EventsTab.tsx

@@ -155,7 +155,6 @@ const InstallPorterAgentButton = styled.button`
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
   background: ${(props: { disabled?: boolean }) =>

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/preview-environments/components/ButtonEnablePREnvironments.tsx

@@ -134,7 +134,6 @@ const Button = styled(DynamicLink)`
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
 

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/preview-environments/components/RecreateWorkflowFilesModal.tsx

@@ -62,7 +62,6 @@ const Button = styled.button`
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
   cursor: pointer;
   border: none;
   :not(:last-child) {

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentCard.tsx

@@ -243,7 +243,6 @@ const Button = styled.button`
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
   cursor: pointer;
   border: none;
   :not(:last-child) {

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/preview-environments/environments/EnvironmentsList.tsx

@@ -180,7 +180,6 @@ const Button = styled(DynamicLink)`
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
 

+ 1 - 2
dashboard/src/main/home/cluster-dashboard/stacks/Dashboard.tsx

@@ -50,7 +50,7 @@ const Dashboard = () => {
           <RadioFilter
             selected={currentSort}
             noMargin
-            dropdownAlignRight={true}
+            dropdownAlignRight={false}
             setSelected={(sortType: any) => setCurrentSort(sortType as any)}
             options={[
               {
@@ -105,7 +105,6 @@ const Button = styled(DynamicLink)`
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
 

+ 0 - 2
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/Settings.tsx

@@ -128,8 +128,6 @@ const Button = styled.button`
   border: 0;
   border-radius: 5px;
   background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
-  box-shadow: ${(props) =>
-    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
   cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
   user-select: none;
   :focus {

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/stacks/components/styles.ts

@@ -157,7 +157,6 @@ export const Action = {
     overflow: hidden;
     white-space: nowrap;
     text-overflow: ellipsis;
-    box-shadow: 0 5px 8px 0px #00000010;
     cursor: ${(props: { disabled?: boolean }) =>
       props.disabled ? "not-allowed" : "pointer"};
 

+ 0 - 1
dashboard/src/main/home/integrations/IntegrationCategories.tsx

@@ -240,7 +240,6 @@ const Button = styled.div`
   padding-right: 12px;
   border-radius: 5px;
   cursor: pointer;
-  box-shadow: 0 5px 8px 0px #00000010;
   display: flex;
   flex-direction: row;
   align-items: center;

+ 0 - 1
dashboard/src/main/home/integrations/IntegrationList.tsx

@@ -302,7 +302,6 @@ const Button = styled.div`
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
 

+ 0 - 1
dashboard/src/main/home/launch/expanded-template/TemplateInfo.tsx

@@ -283,7 +283,6 @@ const Button = styled.div`
   border-radius: 3px;
   cursor: ${(props: { isDisabled: boolean }) =>
     !props.isDisabled ? "pointer" : "default"};
-  box-shadow: 0 5px 8px 0px #00000010;
   display: flex;
   flex-direction: row;
   align-items: center;

+ 0 - 1
dashboard/src/main/home/modals/RedirectToOnboardingModal.tsx

@@ -46,7 +46,6 @@ const ContinueButton = styled.a`
   width: 160px;
   border-radius: 5px;
   background: #616feecc;
-  box-shadow: 0 2px 5px 0 #00000030;
   cursor: pointer;
   user-select: none;
   :focus {

+ 0 - 1
dashboard/src/main/home/modals/UsageWarningModal.tsx

@@ -167,7 +167,6 @@ const Button = styled.button`
   padding: 10px 15px;
   border-radius: 3px;
   cursor: "pointer";
-  box-shadow: 0 5px 8px 0px #00000010;
   display: flex;
   flex-direction: row;
   align-items: center;

+ 0 - 1
dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/_DORegistryForm.tsx

@@ -290,7 +290,6 @@ const ConnectDigitalOceanButton = styled.a`
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
 

+ 0 - 1
dashboard/src/main/home/onboarding/steps/ConnectSource.tsx

@@ -283,7 +283,6 @@ const ConnectToGithubButton = styled.a`
   margin-top: 25px;
   margin-bottom: 25px;
   text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
 

+ 0 - 2
dashboard/src/main/home/project-settings/APITokensSection.tsx

@@ -231,8 +231,6 @@ const InviteButton = styled.div<{ disabled: boolean }>`
   border: 0;
   border-radius: 5px;
   background: ${(props) => (!props.disabled ? "#616FEEcc" : "#aaaabb")};
-  box-shadow: ${(props) =>
-    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
   cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
   user-select: none;
   :focus {

+ 0 - 2
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -684,8 +684,6 @@ const InviteButton = styled.div<{ disabled: boolean }>`
   border: 0;
   border-radius: 5px;
   background: ${(props) => (!props.disabled ? "#616FEEcc" : "#aaaabb")};
-  box-shadow: ${(props) =>
-    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
   cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
   user-select: none;
   :focus {

+ 0 - 1
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -236,7 +236,6 @@ const DeleteButton = styled.div`
   margin-left: 0;
   justify-content: center;
   border-radius: 5px;
-  box-shadow: 0 2px 5px 0 #00000030;
   cursor: pointer;
   user-select: none;
   :focus {

+ 296 - 0
dashboard/src/main/home/provisioner/AzureFormSection.tsx

@@ -0,0 +1,296 @@
+import React, { Component, useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+
+import close from "assets/close.png";
+import { isAlphanumeric } from "shared/common";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { InfraType } from "shared/types";
+
+import InputRow from "components/form-components/InputRow";
+import CheckboxRow from "components/form-components/CheckboxRow";
+import SelectRow from "components/form-components/SelectRow";
+import Helper from "components/form-components/Helper";
+import Heading from "components/form-components/Heading";
+import SaveButton from "components/SaveButton";
+import CheckboxList from "components/form-components/CheckboxList";
+
+type PropsType = {
+  setSelectedProvisioner: (x: string | null) => void;
+  handleError: () => void;
+  projectName: string;
+  highlightCosts?: boolean;
+  infras: InfraType[];
+  trackOnSave: () => void;
+};
+
+const provisionOptions = [
+  { value: "docr", label: "DigitalOcean Container Registry" },
+  { value: "doks", label: "DigitalOcean Kubernetes Service" },
+];
+
+const tierOptions = [
+  { value: "basic", label: "Basic" },
+  { value: "professional", label: "Professional" },
+];
+
+const regionOptions = [
+  { value: "ams3", label: "Amsterdam 3" },
+  { value: "blr1", label: "Bangalore 1" },
+  { value: "fra1", label: "Frankfurt 1" },
+  { value: "lon1", label: "London 1" },
+  { value: "nyc1", label: "New York 1" },
+  { value: "nyc3", label: "New York 3" },
+  { value: "sfo2", label: "San Francisco 2" },
+  { value: "sfo3", label: "San Francisco 3" },
+  { value: "sgp1", label: "Singapore 1" },
+  { value: "tor1", label: "Toronto 1" },
+];
+
+const AzureFormSectionFC: React.FC<PropsType> = (props) => {
+  const [selectedInfras, setSelectedInfras] = useState([...provisionOptions]);
+  const [applicationId, setApplicationId] = useState("");
+  const [azureServicePrincipal, setAzureServicePrincipal] = useState("");
+  const [tenantId, setTenantId] = useState("");
+  const [subscriptionId, setSubscriptionId] = useState("");
+  const [clusterName, setClusterName] = useState("");
+  const [clusterNameSet, setClusterNameSet] = useState(false);
+  const [provisionConfirmed, setProvisionConfirmed] = useState(false);
+
+  const context = useContext(Context);
+
+  // This is added only for tracking purposes
+  // With this prop we will track down if the user has had an intent of filling the formulary
+  const [isFormDirty, setIsFormDirty] = useState(false);
+
+  useEffect(() => {
+    if (!isFormDirty) {
+      return;
+    }
+
+    window.analytics?.track("provision_form-dirty", {
+      provider: "do",
+    });
+  }, [isFormDirty]);
+
+  useEffect(() => {
+    if (props.infras) {
+      // From the dashboard, only uncheck and disable if "creating" or "created"
+      let filtered = selectedInfras;
+      props.infras.forEach((infra: InfraType, i: number) => {
+        let { kind, status } = infra;
+        if (status === "creating" || status === "created") {
+          filtered = filtered.filter((item: any) => {
+            return item.value !== kind;
+          });
+        }
+      });
+      setSelectedInfras(filtered);
+    }
+  }, [props.infras]);
+
+  useEffect(() => {
+    setClusterNameIfNotSet();
+  }, [props.projectName]);
+
+  const setClusterNameIfNotSet = () => {
+    let projectName = props.projectName || context.currentProject?.name;
+
+    if (!clusterNameSet && !clusterName.includes(`${projectName}-cluster`)) {
+      setClusterName(
+        `${projectName}-cluster-${Math.random().toString(36).substring(2, 8)}`
+      );
+    }
+  };
+
+  const checkFormDisabled = () => {
+    if (!provisionConfirmed) {
+      return true;
+    }
+
+    let { projectName } = props;
+    if (projectName || projectName === "") {
+      return (
+        !isAlphanumeric(projectName) ||
+        selectedInfras.length === 0 ||
+        !clusterName
+      );
+    } else {
+      return selectedInfras.length === 0 || !clusterName;
+    }
+  };
+
+  const getButtonStatus = () => {
+    if (props.projectName) {
+      if (!isAlphanumeric(props.projectName)) {
+        return "Project name contains illegal characters";
+      }
+    }
+    if (!provisionConfirmed || props.projectName === "" || !clusterName) {
+      return "Required fields missing";
+    }
+  };
+
+  return (
+    <StyledAWSFormSection>
+      <FormSection>
+        <CloseButton onClick={() => props.setSelectedProvisioner(null)}>
+          <CloseButtonImg src={close} />
+        </CloseButton>
+        <Heading isAtTop={true}>Azure credentials</Heading>
+        <InputRow
+          type="text"
+          value={applicationId}
+          setValue={(x: string) => setApplicationId(x)}
+          label="⚙️ Azure application (client) ID"
+          placeholder="ex: 123456780-abcd-1234-abcd-12345678"
+          width="100%"
+          isRequired={true}
+        />
+        <InputRow
+          type="password"
+          value={azureServicePrincipal}
+          setValue={(x: string) => setAzureServicePrincipal(x)}
+          label="🔒 Azure service principal"
+          placeholder="○ ○ ○ ○ ○ ○ ○ ○ ○"
+          width="100%"
+          isRequired={true}
+        />
+        <InputRow
+          type="text"
+          value={tenantId}
+          setValue={(x: string) => setTenantId(x)}
+          label="👤 Azure tenant ID"
+          placeholder="ex: 123456780-abcd-1234-abcd-12345678"
+          width="100%"
+          isRequired={true}
+        />
+        <InputRow
+          type="text"
+          value={subscriptionId}
+          setValue={(x: string) => setSubscriptionId(x)}
+          label="💳 Azure subscription ID"
+          placeholder="ex: 123456780-abcd-1234-abcd-12345678"
+          width="100%"
+          isRequired={true}
+        />
+      </FormSection>
+      {props.children ? props.children : <Padding />}
+      <SaveButton
+        text="Submit"
+        onClick={() => {}}
+        makeFlush={true}
+        helper="Note: Provisioning can take up to 15 minutes"
+      />
+    </StyledAWSFormSection>
+  );
+};
+
+export default AzureFormSectionFC;
+
+const Highlight = styled.a`
+  color: #8590ff;
+  cursor: pointer;
+  text-decoration: none;
+  margin-left: 5px;
+`;
+
+const Padding = styled.div`
+  height: 15px;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 2px;
+`;
+
+const StyledAWSFormSection = styled.div`
+  position: relative;
+  padding-bottom: 35px;
+`;
+
+const FormSection = styled.div`
+  background: #ffffff11;
+  margin-top: 25px;
+  background: #26282f;
+  border-radius: 5px;
+  margin-bottom: 25px;
+  padding: 25px;
+  padding-bottom: 16px;
+  font-size: 13px;
+  animation: fadeIn 0.3s 0s;
+  position: relative;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const GuideButton = styled.a`
+  display: flex;
+  align-items: center;
+  margin-left: 20px;
+  color: #aaaabb;
+  font-size: 13px;
+  margin-bottom: -1px;
+  border: 1px solid #aaaabb;
+  padding: 5px 10px;
+  padding-left: 6px;
+  border-radius: 5px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+    color: #ffffff;
+    border: 1px solid #ffffff;
+
+    > i {
+      color: #ffffff;
+    }
+  }
+
+  > i {
+    color: #aaaabb;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const CostHighlight = styled.span<{ highlight: boolean }>`
+  background-color: ${(props) => props.highlight && "yellow"};
+`;
+
+const StyledInfoTooltip = styled.div`
+  display: inline-block;
+  position: relative;
+  margin-right: 2px;
+  > i {
+    display: flex;
+    align-items: center;
+    position: absolute;
+    top: -10px;
+    font-size: 10px;
+    color: #858faaaa;
+    cursor: pointer;
+    :hover {
+      color: #aaaabb;
+    }
+  }
+`;

+ 36 - 1
dashboard/src/main/home/provisioner/ProvisionerSettings.tsx

@@ -15,10 +15,12 @@ import Helper from "components/form-components/Helper";
 import AWSFormSection from "./AWSFormSection";
 import GCPFormSection from "./GCPFormSection";
 import DOFormSection from "./DOFormSection";
+import AzureFormSection from "./AzureFormSection";
 import SaveButton from "components/SaveButton";
 import ExistingClusterSection from "./ExistingClusterSection";
 import { useHistory, useLocation } from "react-router";
 import { pushFiltered } from "shared/routing";
+import azure from "assets/azure.png";
 
 type Props = {
   isInNewProject?: boolean;
@@ -163,6 +165,21 @@ const ProvisionerSettings: React.FC<Props> = ({
       );
     }
 
+    if (selectedProvider === "azure") {
+      return (
+        <AzureFormSection
+          handleError={handleError}
+          projectName={projectName}
+          infras={infras}
+          highlightCosts={highlightCosts}
+          setSelectedProvisioner={(x: string | null) => {
+            handleSelectProvider(x);
+          }}
+          trackOnSave={() => trackOnSave(selectedProvider)}
+        />
+      );
+    }
+
     if (selectedProvider === "do") {
       return (
         <DOFormSection
@@ -267,10 +284,28 @@ const ProvisionerSettings: React.FC<Props> = ({
                   <InfoTooltip text={""} />
                   */}
                 </CostSection>
-                <BlockDescription>Hosted in your own cloud.</BlockDescription>
+                <BlockDescription>Hosted in your own cloud</BlockDescription>
               </Block>
             );
           })}
+          {
+            window.location.href.includes("dashboard.staging.getporter.dev") && (
+              <Block
+                key={3}
+                disabled={isUsageExceeded}
+                onClick={() => {
+                  if (!isUsageExceeded) {
+                    handleSelectProvider("azure");
+                    setHighlightCosts(false);
+                  }
+                }}
+              >
+                <Icon src={azure} />
+                <BlockTitle>Azure</BlockTitle>
+                <BlockDescription>Hosted in your own cloud</BlockDescription>
+              </Block>
+            )
+          }
         </BlockList>
       ) : (
         <>{renderSelectedProvider()}</>

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

@@ -0,0 +1,68 @@
+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 {
+	// 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
+}

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

@@ -0,0 +1,150 @@
+package preview
+
+import (
+	"fmt"
+
+	"github.com/mitchellh/mapstructure"
+	"github.com/porter-dev/switchboard/pkg/types"
+)
+
+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.Repo == "" || 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)
+		}
+	}
+
+	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)
+	}
+
+	driverConfig := &BuildDriverConfig{}
+
+	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 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)
+	}
+
+	driverConfig := &PushDriverConfig{}
+
+	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 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)
+	}
+
+	driverConfig := &UpdateConfigDriverConfig{}
+
+	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 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)
+	}
+
+	return nil
+}
+
+func osEnvDriverValidator(resource *types.Resource) error {
+	return nil
+}

+ 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{}
+}

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

@@ -0,0 +1,63 @@
+package preview
+
+import (
+	"errors"
+	"fmt"
+
+	"github.com/porter-dev/switchboard/pkg/parser"
+	"github.com/porter-dev/switchboard/pkg/types"
+)
+
+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 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
+}