Просмотр исходного кода

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

Justin Rhee 3 лет назад
Родитель
Сommit
98405bf4b4

+ 1 - 1
api/client/api.go

@@ -128,7 +128,7 @@ func (c *Client) postRequest(relPath string, data interface{}, response interfac
 	for i := 0; i < int(retryCount); i++ {
 		strData, err := json.Marshal(data)
 		if err != nil {
-			return nil
+			return err
 		}
 
 		req, err := http.NewRequest(

+ 41 - 0
api/client/stack.go

@@ -0,0 +1,41 @@
+package client
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/porter-dev/porter/api/types"
+)
+
+// CreateStack creates the stack
+func (c *Client) CreateStack(
+	ctx context.Context,
+	projectID, clusterID uint,
+	req *types.CreateStackReleaseRequest,
+) error {
+	return c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/stacks",
+			projectID, clusterID,
+		),
+		req,
+		nil,
+	)
+}
+
+// UpdateStack updates the stack
+func (c *Client) UpdateStack(
+	ctx context.Context,
+	projectID, clusterID uint,
+	stackName string,
+	req *types.CreateStackReleaseRequest,
+) error {
+	return c.patchRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/stacks/%s",
+			projectID, clusterID, stackName,
+		),
+		req,
+		nil,
+	)
+}

+ 96 - 0
api/server/handlers/stacks/create.go

@@ -0,0 +1,96 @@
+package stacks
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type CreateStackHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewCreateStackHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateStackHandler {
+	return &CreateStackHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *CreateStackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.CreateStackReleaseRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error decoding request")))
+		return
+	}
+	stackName := request.StackName
+	namespace := fmt.Sprintf("porter-stack-%s", stackName)
+
+	helmAgent, err := c.GetHelmAgent(r, cluster, namespace)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting helm agent: %w", err)))
+		return
+	}
+
+	k8sAgent, err := c.GetAgent(r, cluster, namespace)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting k8s agent: %w", err)))
+		return
+	}
+
+	// create the namespace if it does not exist already
+	_, err = k8sAgent.CreateNamespace(namespace, nil)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error creating namespace: %w", err)))
+		return
+	}
+
+	chart, err := createChartFromDependencies(request.Dependencies)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error creating chart: %w", err)))
+		return
+	}
+
+	registries, err := c.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error listing registries: %w", err)))
+		return
+	}
+
+	conf := &helm.InstallChartConfig{
+		Chart:      chart,
+		Name:       stackName,
+		Namespace:  namespace,
+		Values:     request.Values,
+		Cluster:    cluster,
+		Repo:       c.Repo(),
+		Registries: registries,
+	}
+
+	_, err = helmAgent.InstallChart(conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("error installing a new chart: %s", err.Error()),
+			http.StatusBadRequest,
+		))
+
+		return
+	}
+	w.WriteHeader(http.StatusCreated)
+}

+ 122 - 0
api/server/handlers/stacks/update.go

@@ -0,0 +1,122 @@
+package stacks
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/stefanmcshane/helm/pkg/chart"
+)
+
+type UpdateStackHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewUpdateStackHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateStackHandler {
+	return &UpdateStackHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *UpdateStackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.CreateStackReleaseRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error decoding request")))
+		return
+	}
+
+	stackName := request.StackName
+	namespace := fmt.Sprintf("porter-stack-%s", stackName)
+	helmAgent, err := c.GetHelmAgent(r, cluster, namespace)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error getting helm agent: %w", err)))
+		return
+	}
+
+	chart, err := createChartFromDependencies(request.Dependencies)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error creating chart: %w", err)))
+		return
+	}
+
+	registries, err := c.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error listing registries: %w", err)))
+		return
+	}
+
+	conf := &helm.InstallChartConfig{
+		Chart:      chart,
+		Name:       stackName,
+		Namespace:  namespace,
+		Values:     request.Values,
+		Cluster:    cluster,
+		Repo:       c.Repo(),
+		Registries: registries,
+	}
+
+	_, err = helmAgent.UpgradeInstallChart(conf, c.Config().DOConf, c.Config().ServerConf.DisablePullSecretsInjection)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("error updating a chart: %s", err.Error()),
+			http.StatusBadRequest,
+		))
+
+		return
+	}
+	w.WriteHeader(http.StatusCreated)
+}
+
+func createChartFromDependencies(deps []types.Dependency) (*chart.Chart, error) {
+	metadata := &chart.Metadata{
+		Name:        "umbrella",
+		Description: "Web application that is exposed to external traffic.",
+		Version:     "0.96.0",
+		APIVersion:  "v2",
+		Home:        "https://getporter.dev/",
+		Icon:        "https://user-images.githubusercontent.com/65516095/111255214-07d3da80-85ed-11eb-99e2-fddcbdb99bdb.png",
+		Keywords: []string{
+			"porter",
+			"application",
+			"service",
+			"umbrella",
+		},
+		Type:         "application",
+		Dependencies: createChartDependencies(deps),
+	}
+
+	// create a new chart object with the metadata
+	c := &chart.Chart{
+		Metadata: metadata,
+	}
+	return c, nil
+}
+
+func createChartDependencies(deps []types.Dependency) []*chart.Dependency {
+	var chartDependencies []*chart.Dependency
+	for _, d := range deps {
+		chartDependencies = append(chartDependencies, &chart.Dependency{
+			Name:       d.Name,
+			Alias:      d.Alias,
+			Version:    d.Version,
+			Repository: d.Repository,
+		})
+	}
+	return chartDependencies
+}

+ 2 - 1
api/server/router/router.go

@@ -31,7 +31,8 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 	releaseRegisterer := NewReleaseScopedRegisterer()
 	namespaceRegisterer := NewNamespaceScopedRegisterer(releaseRegisterer)
 	clusterIntegrationRegisterer := NewClusterIntegrationScopedRegisterer()
-	clusterRegisterer := NewClusterScopedRegisterer(namespaceRegisterer, clusterIntegrationRegisterer)
+	stackRegisterer := NewStackScopedRegisterer()
+	clusterRegisterer := NewClusterScopedRegisterer(namespaceRegisterer, clusterIntegrationRegisterer, stackRegisterer)
 	infraRegisterer := NewInfraScopedRegisterer()
 	gitInstallationRegisterer := NewGitInstallationScopedRegisterer()
 	registryRegisterer := NewRegistryScopedRegisterer()

+ 114 - 0
api/server/router/stack.go

@@ -0,0 +1,114 @@
+package router
+
+import (
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/handlers/stacks"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
+	"github.com/porter-dev/porter/api/types"
+)
+
+func NewStackScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
+		GetRoutes: GetStackScopedRoutes,
+		Children:  children,
+	}
+}
+
+func GetStackScopedRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+	children ...*router.Registerer,
+) []*router.Route {
+	routes, projPath := getStackRoutes(r, config, basePath, factory)
+
+	if len(children) > 0 {
+		r.Route(projPath.RelativePath, func(r chi.Router) {
+			for _, child := range children {
+				childRoutes := child.GetRoutes(r, config, basePath, factory, child.Children...)
+
+				routes = append(routes, childRoutes...)
+			}
+		})
+	}
+
+	return routes
+}
+
+func getStackRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+) ([]*router.Route, *types.Path) {
+	relPath := "/stacks"
+
+	newPath := &types.Path{
+		Parent:       basePath,
+		RelativePath: relPath,
+	}
+
+	var routes []*router.Route
+
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks -> stacks.NewCreateStackHandler
+	createEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	createHandler := stacks.NewCreateStackHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: createEndpoint,
+		Handler:  createHandler,
+		Router:   r,
+	})
+
+	// PATCH /api/projects/{project_id}/clusters/{cluster_id}/stacks/{stack} -> stacks.NewUpdateStackHandler
+	updateEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPatch,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	updateHandler := stacks.NewUpdateStackHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: updateEndpoint,
+		Handler:  updateHandler,
+		Router:   r,
+	})
+	return routes, newPath
+}

+ 15 - 0
api/types/release.go

@@ -218,3 +218,18 @@ type UpdateGitActionConfigRequest struct {
 type UpdateCanonicalNameRequest struct {
 	CanonicalName string `json:"canonical_name"`
 }
+
+type CreateStackReleaseRequest struct {
+	// The Helm values for this release
+	Values map[string]interface{} `json:"values"`
+	// Used to construct the Chart.yaml
+	Dependencies []Dependency `json:"dependencies form:"required"`
+	StackName    string       `json:"stack_name" form:"required,dns1123"`
+}
+
+type Dependency struct {
+	Name       string `json:"name" form:"required"`
+	Alias      string `json:"alias" form:"required"`
+	Version    string `json:"version" form:"required"`
+	Repository string `json:"repository" form:"required"`
+}

+ 27 - 1
cli/cmd/apply.go

@@ -21,6 +21,7 @@ import (
 	"github.com/porter-dev/porter/cli/cmd/deploy/wait"
 	"github.com/porter-dev/porter/cli/cmd/preview"
 	previewV2Beta1 "github.com/porter-dev/porter/cli/cmd/preview/v2beta1"
+	stack "github.com/porter-dev/porter/cli/cmd/stack"
 	previewInt "github.com/porter-dev/porter/internal/integrations/preview"
 	"github.com/porter-dev/porter/internal/templater/utils"
 	"github.com/porter-dev/switchboard/pkg/drivers"
@@ -122,6 +123,7 @@ func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string
 	}
 
 	var resGroup *switchboardTypes.ResourceGroup
+	worker := switchboardWorker.NewWorker()
 
 	if previewVersion.Version == "v2beta1" {
 		ns := os.Getenv("PORTER_NAMESPACE")
@@ -149,6 +151,31 @@ func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string
 		if err != nil {
 			return fmt.Errorf("error parsing porter.yaml: %w", err)
 		}
+	} else if previewVersion.Version == "v1stack" {
+		stackName := os.Getenv("PORTER_STACK_NAME")
+		if stackName == "" {
+			return fmt.Errorf("environment variable PORTER_STACK_NAME must be set")
+		}
+
+		resGroup, err = stack.CreateV1BuildResources(client, fileBytes)
+		if err != nil {
+			return fmt.Errorf("error parsing porter.yaml for build resources: %w", err)
+		}
+
+		appResGroup, err := stack.CreateV1ApplicationResources(client, fileBytes)
+		if err != nil {
+			return fmt.Errorf("error parsing porter.yaml for application resources: %w", err)
+		}
+
+		deployStackHook := &stack.DeployStackHook{
+			Client:               client,
+			StackName:            stackName,
+			ProjectID:            cliConf.Project,
+			ClusterID:            cliConf.Cluster,
+			AppResourceGroup:     appResGroup,
+			BuildImageDriverName: stack.GetBuildImageDriverName(),
+		}
+		worker.RegisterHook("deploy-stack", deployStackHook)
 	} else {
 		return fmt.Errorf("unknown porter.yaml version: %s", previewVersion.Version)
 	}
@@ -158,7 +185,6 @@ func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string
 		return fmt.Errorf("error getting working directory: %w", err)
 	}
 
-	worker := switchboardWorker.NewWorker()
 	worker.RegisterDriver("deploy", NewDeployDriver)
 	worker.RegisterDriver("build-image", preview.NewBuildDriver)
 	worker.RegisterDriver("push-image", preview.NewPushDriver)

+ 16 - 0
cli/cmd/config/config.go

@@ -315,3 +315,19 @@ func (c *CLIConfig) SetKubeconfig(kubeconfig string) error {
 
 	return nil
 }
+
+func ValidateCLIEnvironment() error {
+	if GetCLIConfig().Token == "" {
+		return fmt.Errorf("no auth token present, please run 'porter auth login' to authenticate")
+	}
+
+	if GetCLIConfig().Project == 0 {
+		return fmt.Errorf("no project selected, please run 'porter config set-project' to select a project")
+	}
+
+	if GetCLIConfig().Cluster == 0 {
+		return fmt.Errorf("no cluster selected, please run 'porter config set-cluster' to select a cluster")
+	}
+
+	return nil
+}

+ 1 - 1
cli/cmd/preview/build_image_driver.go

@@ -70,7 +70,7 @@ func (d *BuildDriver) Apply(resource *models.Resource) (*models.Resource, error)
 	if tag == "" {
 		commit, err := git.LastCommit()
 		if err != nil {
-			return nil, err
+			return nil, fmt.Errorf("could not get last commit to be used as the image tag: %s", err.Error())
 		}
 
 		tag = commit.Sha[:7]

+ 1 - 21
cli/cmd/preview/v2beta1/apply.go

@@ -47,7 +47,7 @@ func NewApplier(client *api.Client, raw []byte, namespace string) (*PreviewAppli
 	// 	return nil, err
 	// }
 
-	err = validateCLIEnvironment(namespace)
+	err = config.ValidateCLIEnvironment()
 
 	if err != nil {
 		errMsg := composePreviewMessage("porter CLI is not configured correctly", Error)
@@ -62,26 +62,6 @@ func NewApplier(client *api.Client, raw []byte, namespace string) (*PreviewAppli
 	}, nil
 }
 
-func validateCLIEnvironment(namespace string) error {
-	if config.GetCLIConfig().Token == "" {
-		return fmt.Errorf("no auth token present, please run 'porter auth login' to authenticate")
-	}
-
-	if config.GetCLIConfig().Project == 0 {
-		return fmt.Errorf("no project selected, please run 'porter config set-project' to select a project")
-	}
-
-	if config.GetCLIConfig().Cluster == 0 {
-		return fmt.Errorf("no cluster selected, please run 'porter config set-cluster' to select a cluster")
-	}
-
-	// if namespace == "" {
-	// 	printInfoMessage("no namespace provided, falling back to namespace 'default'")
-	// }
-
-	return nil
-}
-
 func (a *PreviewApplier) Apply() error {
 	// for v2beta1, check if the namespace exists in the current project-cluster pair
 	//

+ 74 - 0
cli/cmd/stack/app.go

@@ -0,0 +1,74 @@
+package stack
+
+import (
+	"fmt"
+
+	"github.com/mitchellh/mapstructure"
+	"github.com/porter-dev/porter/cli/cmd/deploy"
+	"github.com/porter-dev/porter/internal/integrations/preview"
+	"github.com/porter-dev/porter/internal/templater/utils"
+	"github.com/porter-dev/switchboard/pkg/types"
+)
+
+func (a *App) GetType() string {
+	return *a.Type
+}
+
+func (a *App) GetDefaultValues() map[string]interface{} {
+	var defaultValues map[string]interface{}
+	if *a.Type == "web" {
+		defaultValues = map[string]interface{}{
+			"ingress": map[string]interface{}{
+				"enabled": false,
+			},
+			"container": map[string]interface{}{
+				"command": *a.Run,
+				"env":     map[string]interface{}{},
+			},
+		}
+	} else {
+		defaultValues = map[string]interface{}{
+			"container": map[string]interface{}{
+				"command": *a.Run,
+				"env":     map[string]interface{}{},
+			},
+		}
+	}
+	return defaultValues
+}
+
+func (a *App) getV1Resource(name string, b *Build, env map[string]string) (*types.Resource, error) {
+	config := &preview.ApplicationConfig{}
+
+	if a.Config == nil {
+		a.Config = make(map[string]interface{})
+	}
+	config.Build.Method = "registry"
+	config.Build.Image = fmt.Sprintf("{ .%s.image }", b.GetName())
+	config.Build.Env = CopyEnv(env)
+
+	defaultValues := a.GetDefaultValues()
+	containerDefaultValues, err := deploy.GetNestedMap(defaultValues, "container", "env")
+	if err != nil {
+		return nil, err
+	}
+	containerDefaultValues["normal"] = CopyEnv(env)
+	config.Values = utils.CoalesceValues(defaultValues, a.Config)
+
+	rawConfig := make(map[string]any)
+
+	err = mapstructure.Decode(config, &rawConfig)
+	if err != nil {
+		return nil, err
+	}
+
+	return &types.Resource{
+		Name:      name,
+		DependsOn: []string{"get-env", b.GetName()},
+		Source: map[string]any{
+			"name": a.GetType(),
+		},
+		Config: rawConfig,
+		Driver: "",
+	}, nil
+}

+ 94 - 0
cli/cmd/stack/apply.go

@@ -0,0 +1,94 @@
+package stack
+
+import (
+	"fmt"
+
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/cli/cmd/config"
+	"github.com/porter-dev/switchboard/pkg/types"
+	"gopkg.in/yaml.v2"
+)
+
+type StackConf struct {
+	apiClient *api.Client
+	rawBytes  []byte
+	parsed    *PorterStackYAML
+}
+
+func CreateV1BuildResources(client *api.Client, raw []byte) (*types.ResourceGroup, error) {
+	stackConf, err := createStackConf(client, raw)
+	if err != nil {
+		return nil, err
+	}
+
+	v1File := &types.ResourceGroup{
+		Version: "v1",
+		Resources: []*types.Resource{
+			{
+				Name:   "get-env",
+				Driver: "os-env",
+			},
+		},
+	}
+	if stackConf.parsed.Build != nil {
+		bi, err := stackConf.parsed.Build.getV1BuildImage(stackConf.parsed.Env)
+		if err != nil {
+			return nil, err
+		}
+
+		pi, err := stackConf.parsed.Build.getV1PushImage()
+		if err != nil {
+			return nil, err
+		}
+
+		v1File.Resources = append(v1File.Resources, bi, pi)
+	}
+
+	return v1File, nil
+}
+
+func createStackConf(client *api.Client, raw []byte) (*StackConf, error) {
+	parsed := &PorterStackYAML{}
+
+	err := yaml.Unmarshal(raw, parsed)
+	if err != nil {
+		errMsg := composePreviewMessage("error parsing porter.yaml", Error)
+		return nil, fmt.Errorf("%s: %w", errMsg, err)
+	}
+
+	err = config.ValidateCLIEnvironment()
+	if err != nil {
+		errMsg := composePreviewMessage("porter CLI is not configured correctly", Error)
+		return nil, fmt.Errorf("%s: %w", errMsg, err)
+	}
+
+	return &StackConf{
+		apiClient: client,
+		rawBytes:  raw,
+		parsed:    parsed,
+	}, nil
+}
+
+func CreateV1ApplicationResources(client *api.Client, raw []byte) (*types.ResourceGroup, error) {
+	stackConf, err := createStackConf(client, raw)
+	if err != nil {
+		return nil, err
+	}
+
+	v1File := &types.ResourceGroup{}
+
+	for name, app := range stackConf.parsed.Apps {
+		if app == nil {
+			continue
+		}
+
+		ai, err := app.getV1Resource(name, stackConf.parsed.Build, stackConf.parsed.Env)
+		if err != nil {
+			return nil, err
+		}
+
+		v1File.Resources = append(v1File.Resources, ai)
+	}
+
+	return v1File, nil
+}

+ 152 - 0
cli/cmd/stack/build.go

@@ -0,0 +1,152 @@
+package stack
+
+import (
+	"fmt"
+
+	"github.com/mitchellh/mapstructure"
+	"github.com/porter-dev/porter/internal/integrations/preview"
+	"github.com/porter-dev/switchboard/pkg/types"
+)
+
+func (b *Build) GetName() string {
+	if b == nil {
+		return ""
+	}
+
+	return getBuildImageName()
+}
+
+func (b *Build) GetContext() string {
+	if b == nil || b.Context == nil || *b.Context == "" {
+		return "."
+	}
+
+	return *b.Context
+}
+
+func (b *Build) GetMethod() string {
+	if b == nil || b.Method == nil {
+		return ""
+	}
+
+	return *b.Method
+}
+
+func (b *Build) GetBuilder() string {
+	if b == nil || b.Builder == nil {
+		return ""
+	}
+
+	return *b.Builder
+}
+
+func (b *Build) GetBuildpacks() []string {
+	if b == nil || b.Buildpacks == nil {
+		return []string{}
+	}
+
+	var bp []string
+
+	for _, b := range b.Buildpacks {
+		if b == nil {
+			continue
+		}
+
+		bp = append(bp, *b)
+	}
+
+	return bp
+}
+
+func (b *Build) GetDockerfile() string {
+	if b == nil || b.Dockerfile == nil {
+		return ""
+	}
+
+	return *b.Dockerfile
+}
+
+func (b *Build) GetImage() string {
+	if b == nil || b.Image == nil {
+		return ""
+	}
+
+	return *b.Image
+}
+
+func (b *Build) getV1BuildImage(env map[string]string) (*types.Resource, error) {
+	config := &preview.BuildDriverConfig{}
+
+	if b.GetMethod() == "pack" {
+		config.Build.Method = "pack"
+		config.Build.Builder = b.GetBuilder()
+		config.Build.Buildpacks = b.GetBuildpacks()
+	} else if b.GetMethod() == "docker" {
+		config.Build.Method = "docker"
+		config.Build.Dockerfile = b.GetDockerfile()
+	} else if b.GetMethod() == "registry" {
+		config.Build.Method = "registry"
+		config.Build.Image = b.GetImage()
+	} else {
+		return nil, fmt.Errorf("invalid build method: %s", b.GetMethod())
+	}
+
+	config.Build.Context = b.GetContext()
+	config.Build.Env = CopyEnv(env)
+
+	rawConfig := make(map[string]any)
+
+	err := mapstructure.Decode(config, &rawConfig)
+	if err != nil {
+		return nil, err
+	}
+
+	return &types.Resource{
+		Name:   fmt.Sprintf("%s-build-image", b.GetName()),
+		Driver: "build-image",
+		Source: map[string]any{
+			"name": "web",
+		},
+		Target: map[string]any{
+			"app_name": b.GetName(),
+		},
+		DependsOn: []string{
+			"get-env",
+		},
+		Config: rawConfig,
+	}, nil
+}
+
+func getBuildImageName() string {
+	return "base-image"
+}
+
+func GetBuildImageDriverName() string {
+	return fmt.Sprintf("%s-build-image", getBuildImageName())
+}
+
+func (b *Build) getV1PushImage() (*types.Resource, error) {
+	config := &preview.PushDriverConfig{}
+
+	config.Push.Image = fmt.Sprintf("{ .%s.image }", GetBuildImageDriverName())
+
+	rawConfig := make(map[string]any)
+
+	err := mapstructure.Decode(config, &rawConfig)
+	if err != nil {
+		return nil, err
+	}
+
+	return &types.Resource{
+		Name:   b.GetName(),
+		Driver: "push-image",
+		DependsOn: []string{
+			"get-env",
+			GetBuildImageDriverName(),
+		},
+		Target: map[string]any{
+			"app_name": b.GetName(),
+		},
+		Config: rawConfig,
+	}, nil
+}

+ 17 - 0
cli/cmd/stack/env.go

@@ -0,0 +1,17 @@
+package stack
+
+func CopyEnv(env map[string]string) map[string]string {
+	envCopy := make(map[string]string)
+	if env == nil {
+		return envCopy
+	}
+
+	for k, v := range env {
+		if k == "" || v == "" {
+			continue
+		}
+		envCopy[k] = v
+	}
+
+	return envCopy
+}

+ 196 - 0
cli/cmd/stack/hooks.go

@@ -0,0 +1,196 @@
+package stack
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/fatih/color"
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/cli/cmd/config"
+	switchboardTypes "github.com/porter-dev/switchboard/pkg/types"
+)
+
+type DeployStackHook struct {
+	Client               *api.Client
+	StackName            string
+	ProjectID, ClusterID uint
+	AppResourceGroup     *switchboardTypes.ResourceGroup
+	BuildImageDriverName string
+}
+
+type StackConfig struct {
+	Values       map[string]interface{}
+	Dependencies []types.Dependency
+}
+
+func (t *DeployStackHook) PreApply() error {
+	return nil
+}
+
+func (t *DeployStackHook) DataQueries() map[string]interface{} {
+	res := map[string]interface{}{
+		"image": fmt.Sprintf("{$.%s.image}", t.BuildImageDriverName),
+	}
+	return res
+}
+
+// deploy the stack
+func (t *DeployStackHook) PostApply(driverOutput map[string]interface{}) error {
+	client := config.GetAPIClient()
+	namespace := fmt.Sprintf("porter-stack-%s", t.StackName)
+
+	_, err := client.GetRelease(
+		context.Background(),
+		t.ProjectID,
+		t.ClusterID,
+		namespace,
+		t.StackName,
+	)
+
+	shouldCreate := err != nil
+
+	if err != nil {
+		color.New(color.FgYellow).Printf("Could not read release for stack %s (%s): attempting creation\n", t.StackName, err.Error())
+	} else {
+		color.New(color.FgGreen).Printf("Found release for stack %s: attempting update\n", t.StackName)
+	}
+
+	return t.applyStack(t.AppResourceGroup, client, shouldCreate, driverOutput)
+}
+
+func (t *DeployStackHook) applyStack(applications *switchboardTypes.ResourceGroup, client *api.Client, shouldCreate bool, driverOutput map[string]interface{}) error {
+	if applications == nil {
+		return fmt.Errorf("no applications found")
+	}
+
+	err := insertImageInfoIntoApps(applications, driverOutput)
+	if err != nil {
+		return fmt.Errorf("unable to insert image info into apps: %w", err)
+	}
+
+	values, err := buildStackValues(applications)
+	if err != nil {
+		return err
+	}
+
+	deps, err := buildStackDependencies(applications, client, t.ProjectID)
+	if err != nil {
+		return err
+	}
+
+	stackConf := StackConfig{
+		Values:       values,
+		Dependencies: deps,
+	}
+
+	if shouldCreate {
+		err := t.createStack(client, stackConf)
+		if err != nil {
+			return fmt.Errorf("error creating stack %s: %w", t.StackName, err)
+		}
+	} else {
+		err := t.updateStack(client, stackConf)
+		if err != nil {
+			return fmt.Errorf("error updating stack %s: %w", t.StackName, err)
+		}
+	}
+
+	return nil
+}
+
+func insertImageInfoIntoApps(applications *switchboardTypes.ResourceGroup, driverOutput map[string]interface{}) error {
+	image, ok := driverOutput["image"].(string)
+	if !ok || image == "" {
+		return fmt.Errorf("unable to find image in driver output")
+	}
+
+	// split image into image-path:tag format
+	imageSpl := strings.Split(image, ":")
+
+	if len(imageSpl) != 2 {
+		return fmt.Errorf("invalid image format: must be image-path:tag format")
+	}
+
+	for _, resource := range applications.Resources {
+		if resource.Config == nil {
+			resource.Config = make(map[string]interface{})
+		}
+		values, ok := resource.Config["Values"].(map[string]interface{})
+		if !ok {
+			values = make(map[string]interface{})
+			resource.Config["Values"] = values
+		}
+		image, ok := values["image"].(map[string]interface{})
+		if !ok {
+			image = make(map[string]interface{})
+			values["image"] = image
+		}
+		image["repository"] = imageSpl[0]
+		image["tag"] = imageSpl[1]
+	}
+
+	return nil
+}
+
+func (t *DeployStackHook) createStack(client *api.Client, stackConf StackConfig) error {
+	err := client.CreateStack(
+		context.Background(),
+		t.ProjectID,
+		t.ClusterID,
+		&types.CreateStackReleaseRequest{
+			StackName:    t.StackName,
+			Values:       convertMap(stackConf.Values).(map[string]interface{}),
+			Dependencies: stackConf.Dependencies,
+		},
+	)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (t *DeployStackHook) updateStack(client *api.Client, stackConf StackConfig) error {
+	err := client.UpdateStack(
+		context.Background(),
+		t.ProjectID,
+		t.ClusterID,
+		t.StackName,
+		&types.CreateStackReleaseRequest{
+			StackName:    t.StackName,
+			Values:       convertMap(stackConf.Values).(map[string]interface{}),
+			Dependencies: stackConf.Dependencies,
+		},
+	)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// this is necessary to marshal the resulting object during the request
+func convertMap(m interface{}) interface{} {
+	switch m := m.(type) {
+	case map[string]interface{}:
+		for k, v := range m {
+			m[k] = convertMap(v)
+		}
+	case map[interface{}]interface{}:
+		result := map[string]interface{}{}
+		for k, v := range m {
+			result[k.(string)] = convertMap(v)
+		}
+		return result
+	case []interface{}:
+		for i, v := range m {
+			m[i] = convertMap(v)
+		}
+	}
+	return m
+}
+
+func (t *DeployStackHook) OnConsolidatedErrors(map[string]error) {}
+func (t *DeployStackHook) OnError(error)                         {}

+ 24 - 0
cli/cmd/stack/types.go

@@ -0,0 +1,24 @@
+package stack
+
+type PorterStackYAML struct {
+	Version *string           `yaml:"version"`
+	Build   *Build            `yaml:"build"`
+	Env     map[string]string `yaml:"env"`
+	Apps    map[string]*App   `yaml:"apps"`
+	Release *string           `yaml:"release"`
+}
+
+type Build struct {
+	Context    *string   `yaml:"context" validate:"dir"`
+	Method     *string   `yaml:"method" validate:"required,oneof=pack docker registry"`
+	Builder    *string   `yaml:"builder" validate:"required_if=Method pack"`
+	Buildpacks []*string `yaml:"buildpacks"`
+	Dockerfile *string   `yaml:"dockerfile" validate:"required_if=Method docker"`
+	Image      *string   `yaml:"image" validate:"required_if=Method registry"`
+}
+
+type App struct {
+	Run    *string                `yaml:"run" validate:"required"`
+	Config map[string]interface{} `yaml:"config"`
+	Type   *string                `yaml:"type" validate:"required, oneof=web worker job"`
+}

+ 97 - 0
cli/cmd/stack/utils.go

@@ -0,0 +1,97 @@
+package stack
+
+import (
+	"context"
+	"fmt"
+
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/api/types"
+	switchboardTypes "github.com/porter-dev/switchboard/pkg/types"
+)
+
+type MessageLevel string
+
+const (
+	Warning MessageLevel = "WARN"
+	Error   MessageLevel = "ERR"
+	Success MessageLevel = "OK"
+	Info    MessageLevel = "INFO"
+)
+
+func composePreviewMessage(msg string, level MessageLevel) string {
+	return fmt.Sprintf("[porter.yaml stack][%s] -- %s", level, msg)
+}
+
+func buildStackValues(apps *switchboardTypes.ResourceGroup) (map[string]interface{}, error) {
+	values := make(map[string]interface{})
+
+	for _, app := range apps.Resources {
+		if app.Config == nil {
+			continue
+		}
+
+		if helm_values, ok := app.Config["Values"]; ok {
+			values[app.Name] = helm_values
+		}
+	}
+
+	return values, nil
+}
+
+func buildStackDependencies(apps *switchboardTypes.ResourceGroup, client *api.Client, projectID uint) ([]types.Dependency, error) {
+	deps := make([]types.Dependency, 0)
+
+	for _, app := range apps.Resources {
+		source, ok := app.Source["name"]
+		if !ok {
+			return nil, fmt.Errorf("app %s does not have a source", app.Name)
+		}
+		chartName, ok := source.(string)
+		if !ok {
+			return nil, fmt.Errorf("unable to parse source name for app %s", app.Name)
+		}
+		selectedRepo := "https://charts.getporter.dev"
+		selectedVersion, err := getLatestTemplateVersion(chartName, client, projectID)
+		if err != nil {
+			return nil, err
+		}
+		deps = append(deps, types.Dependency{
+			Name:       chartName,
+			Alias:      app.Name,
+			Version:    selectedVersion,
+			Repository: selectedRepo,
+		})
+	}
+
+	return deps, nil
+}
+
+// getLatestTemplateVersion retrieves the latest template version for a specific
+// Porter template from the chart repository.
+func getLatestTemplateVersion(templateName string, client *api.Client, projectID uint) (string, error) {
+	resp, err := client.ListTemplates(
+		context.Background(),
+		projectID,
+		&types.ListTemplatesRequest{},
+	)
+	if err != nil {
+		return "", err
+	}
+
+	templates := *resp
+
+	var version string
+	// find the matching template name
+	for _, template := range templates {
+		if templateName == template.Name {
+			version = template.Versions[0]
+			break
+		}
+	}
+
+	if version == "" {
+		return "", fmt.Errorf("matching template version not found")
+	}
+
+	return version, nil
+}

+ 5 - 8
dashboard/src/components/porter/ExpandableSection.tsx

@@ -25,13 +25,10 @@ const ExpandableSection: React.FC<Props> = ({
   collapseText,
   maxHeight,
 }) => {
-  const [isExpanded, setIsExpanded] = useState(false);
-  useEffect(() => {
-    setIsExpanded(isInitiallyExpanded);
-  }, [isInitiallyExpanded]);
+  const [isExpanded, setIsExpanded] = useState(isInitiallyExpanded ?? false);
 
   return (
-    <StyledExpandableSection 
+    <StyledExpandableSection
       isExpanded={isExpanded}
       background={background}
       noWrapper={noWrapper}
@@ -44,7 +41,7 @@ const ExpandableSection: React.FC<Props> = ({
           </ExpandButton>
         </Container>
       ) : (
-        <HeaderRow 
+        <HeaderRow
           isExpanded={isExpanded}
           onClick={() => setIsExpanded(!isExpanded)}
           color={color}
@@ -71,7 +68,7 @@ const ExpandButton = styled.div`
   font-size: 13px;
 `;
 
-const HeaderRow = styled.div<{ 
+const HeaderRow = styled.div<{
   isExpanded: boolean;
   color?: string;
 }>`
@@ -97,7 +94,7 @@ const HeaderRow = styled.div<{
   }
 `;
 
-const StyledExpandableSection = styled.div<{ 
+const StyledExpandableSection = styled.div<{
   isExpanded: boolean;
   background?: string;
   noWrapper?: boolean;

+ 153 - 0
dashboard/src/components/repo-selector/ActionConfBranchSelector.tsx

@@ -0,0 +1,153 @@
+import React from "react";
+import styled from "styled-components";
+
+import { ActionConfigType } from "shared/types";
+
+import RepoList from "./RepoList";
+import BranchList from "./BranchList";
+import ContentsList from "./ContentsList";
+import ActionDetails from "./ActionDetails";
+import InputRow from "../form-components/InputRow";
+
+type Props = {
+  actionConfig: ActionConfigType | null;
+  branch: string;
+  setActionConfig: (x: ActionConfigType) => void;
+  setBranch: (x: string) => void;
+  reset: any;
+  dockerfilePath: string;
+  procfilePath: string;
+  procfileProcess: string;
+  setDockerfilePath: (x: string) => void;
+  setProcfileProcess: (x: string) => void;
+  setProcfilePath: (x: string) => void;
+  folderPath: string;
+  setFolderPath: (x: string) => void;
+  setSelectedRegistry: (x: any) => void;
+  selectedRegistry: any;
+  setBuildConfig: (x: any) => void;
+};
+
+const defaultActionConfig: ActionConfigType = {
+  git_repo: "",
+  image_repo_uri: "",
+  git_branch: "",
+  git_repo_id: 0,
+  kind: "github",
+};
+
+const ActionConfEditorStack: React.FC<Props> = (props) => {
+  const { actionConfig, setBranch, setActionConfig, branch } = props;
+
+  if (!actionConfig.git_repo) {
+    return (
+      <ExpandedWrapperAlt>
+        <RepoList
+          actionConfig={actionConfig}
+          setActionConfig={(x: ActionConfigType) => setActionConfig(x)}
+          readOnly={false}
+        />
+      </ExpandedWrapperAlt>
+    );
+  } else if (!branch) {
+    return (
+      <>
+        <ExpandedWrapperAlt>
+          <BranchList
+            actionConfig={actionConfig}
+            setBranch={(branch: string) => setBranch(branch)}
+          />
+        </ExpandedWrapperAlt>
+        <Br />
+      </>
+    );
+  }
+  return (
+    <>
+      <InputRow
+        disabled={true}
+        label="Branch"
+        type="text"
+        width="100%"
+        value={props?.branch}
+      />
+      <BackButton
+        width="145px"
+        onClick={() => {
+          setBranch("");
+          props.setDockerfilePath("");
+        }}
+      >
+        <i className="material-icons">keyboard_backspace</i>
+        Select branch
+      </BackButton>
+    </>
+  );
+};
+
+export default ActionConfEditorStack;
+
+const Br = styled.div`
+  width: 100%;
+  height: 8px;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const HeaderButton = styled.div`
+  margin-bottom: 5px;
+  padding: 5px 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: 500;
+  margin-right: 10px;
+`;
+
+const RepoHeader = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const ExpandedWrapper = styled.div`
+  margin-top: 10px;
+  width: 100%;
+  border-radius: 3px;
+  border: 1px solid #ffffff44;
+  max-height: 275px;
+`;
+
+const ExpandedWrapperAlt = styled(ExpandedWrapper)`
+  border: 0;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 22px;
+  cursor: pointer;
+  font-size: 13px;
+  height: 35px;
+  padding: 5px 13px;
+  margin-bottom: -7px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;

+ 143 - 0
dashboard/src/components/repo-selector/ActionConfEditorStack.tsx

@@ -0,0 +1,143 @@
+import React from "react";
+import styled from "styled-components";
+
+import { ActionConfigType } from "shared/types";
+
+import RepoList from "./RepoList";
+import BranchList from "./BranchList";
+import ContentsList from "./ContentsList";
+import ActionDetails from "./ActionDetails";
+import InputRow from "../form-components/InputRow";
+
+type Props = {
+  actionConfig: ActionConfigType | null;
+  branch: string;
+  setActionConfig: (x: ActionConfigType) => void;
+  setBranch: (x: string) => void;
+  reset: any;
+  dockerfilePath: string;
+  procfilePath: string;
+  procfileProcess: string;
+  setDockerfilePath: (x: string) => void;
+  setProcfileProcess: (x: string) => void;
+  setProcfilePath: (x: string) => void;
+  folderPath: string;
+  setFolderPath: (x: string) => void;
+  setSelectedRegistry: (x: any) => void;
+  selectedRegistry: any;
+  setBuildConfig: (x: any) => void;
+};
+
+const defaultActionConfig: ActionConfigType = {
+  git_repo: "",
+  image_repo_uri: "",
+  git_branch: "",
+  git_repo_id: 0,
+  kind: "github",
+};
+
+const ActionConfEditorStack: React.FC<Props> = (props) => {
+  const { actionConfig, setBranch, setActionConfig, branch } = props;
+
+  if (!actionConfig.git_repo) {
+    return (
+      <ExpandedWrapperAlt>
+        <RepoList
+          actionConfig={actionConfig}
+          setActionConfig={(x: ActionConfigType) => setActionConfig(x)}
+          readOnly={false}
+        />
+      </ExpandedWrapperAlt>
+    );
+  } else {
+    return (
+      <>
+        <InputRow
+          disabled={true}
+          label="Git repository"
+          type="text"
+          width="100%"
+          value={actionConfig?.git_repo}
+        />
+        <BackButton
+          width="135px"
+          onClick={() => {
+            setActionConfig({ ...defaultActionConfig });
+            setBranch("");
+            props.setDockerfilePath("");
+          }}
+        >
+          <i className="material-icons">keyboard_backspace</i>
+          Select repo
+        </BackButton>
+      </>
+    );
+  }
+};
+
+export default ActionConfEditorStack;
+
+const Br = styled.div`
+  width: 100%;
+  height: 8px;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const HeaderButton = styled.div`
+  margin-bottom: 5px;
+  padding: 5px 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: 500;
+  margin-right: 10px;
+`;
+
+const RepoHeader = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const ExpandedWrapper = styled.div`
+  margin-top: 10px;
+  width: 100%;
+  border-radius: 3px;
+  border: 1px solid #ffffff44;
+  max-height: 275px;
+`;
+
+const ExpandedWrapperAlt = styled(ExpandedWrapper)`
+  border: 0;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 22px;
+  cursor: pointer;
+  font-size: 13px;
+  height: 35px;
+  padding: 5px 13px;
+  margin-bottom: -7px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;

+ 5 - 2
dashboard/src/main/home/app-dashboard/new-app-flow/GithubActionModal.tsx

@@ -23,7 +23,7 @@ const GithubActionModal: React.FC<GithubActionModalProps> = ({
             </Text>
             <Spacer height="15px" />
             <Text color="helper">
-                In order to automatically update your services every time new code is pushed to your GitHub branch, you will need the following file in your Github repository:
+                In order to automatically update your services every time new code is pushed to your GitHub branch, the following file must exist in your Github repository:
             </Text>
             <Spacer y={1} />
             <ExpandableSection
@@ -33,6 +33,7 @@ const GithubActionModal: React.FC<GithubActionModalProps> = ({
                 Header={
                     <ModalHeader>./github/workflows/porter_deploy.yml</ModalHeader>
                 }
+                isInitiallyExpanded={true}
                 ExpandedSection={
                     <>
                         <Spacer height="15px" />
@@ -52,7 +53,7 @@ const GithubActionModal: React.FC<GithubActionModalProps> = ({
             />
             <Spacer y={1} />
             <Text color="helper">
-                Porter can open a PR for you to approve and merge this file into your repository, or you can add it yourself.
+                Porter can open a PR for you to approve and merge this file into your repository, or you can add it yourself. If you allow Porter to open a PR, you will be redirected to the PR in a new tab after hitting Complete below.
             </Text>
             <Spacer y={1} />
             <Select
@@ -61,9 +62,11 @@ const GithubActionModal: React.FC<GithubActionModalProps> = ({
                     { label: "I will copy the file into my repository myself", value: "I will copy the file into my repository myself" },
                 ]}
                 onChange={(x: any) => console.log(x)}
+                width="100%"
             />
             <Button
                 onClick={closeModal}
+                width={"100%"}
             >
                 Complete
             </Button>

+ 169 - 114
dashboard/src/main/home/app-dashboard/new-app-flow/NewAppFlow.tsx

@@ -22,15 +22,24 @@ import { generateSlug } from "random-word-slugs";
 import { RouteComponentProps, withRouter } from "react-router";
 import Error from "components/porter/Error";
 import SourceSelector, { SourceType } from "./SourceSelector";
-import SourceSettings from "./SourceSettings"
+import SourceSettings from "./SourceSettings";
 import Services from "./Services";
-import EnvGroupArray, { KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
+import EnvGroupArray, {
+  KeyValueType,
+} from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
 import Select from "components/porter/Select";
 import GithubActionModal from "./GithubActionModal";
+import { ActionConfigType, FullActionConfigType } from "shared/types";
 
-type Props = RouteComponentProps & {
-};
+type Props = RouteComponentProps & {};
 
+const defaultActionConfig: ActionConfigType = {
+  git_repo: "",
+  image_repo_uri: "",
+  git_branch: "",
+  git_repo_id: 0,
+  kind: "github",
+};
 
 interface FormState {
   applicationName: string;
@@ -55,128 +64,177 @@ const Validators: {
   envVariables: (value: KeyValueType[]) => true,
 };
 
+const NewAppFlow: React.FC<Props> = ({ ...props }) => {
+  const [templateName, setTemplateName] = useState("");
 
-
-const NewAppFlow: React.FC<Props> = ({
-  ...props
-}) => {
+  const [imageUrl, setImageUrl] = useState("");
+  const [imageTag, setImageTag] = useState("");
   const { currentCluster, currentProject } = useContext(Context);
   const [isLoading, setIsLoading] = useState<boolean>(true);
   const [currentStep, setCurrentStep] = useState<number>(0);
   const [formState, setFormState] = useState<FormState>(INITIAL_STATE);
+  const [actionConfig, setActionConfig] = useState<ActionConfigType>({
+    ...defaultActionConfig,
+  });
+  const [procfileProcess, setProcfileProcess] = useState("");
+  const [branch, setBranch] = useState("");
+  const [repoType, setRepoType] = useState("");
+  const [dockerfilePath, setDockerfilePath] = useState(null);
+  const [procfilePath, setProcfilePath] = useState(null);
+  const [folderPath, setFolderPath] = useState(null);
+  const [selectedRegistry, setSelectedRegistry] = useState(null);
+  const [shouldCreateWorkflow, setShouldCreateWorkflow] = useState(true);
+  const [buildConfig, setBuildConfig] = useState();
+  const getFullActionConfig = (): FullActionConfigType => {
+    let imageRepoURI = `${selectedRegistry?.url}/${templateName}`;
+    return {
+      kind: "github",
+      git_repo: actionConfig.git_repo,
+      git_branch: branch,
+      registry_id: selectedRegistry?.id,
+      dockerfile_path: dockerfilePath,
+      folder_path: folderPath,
+      image_repo_uri: imageRepoURI,
+      git_repo_id: actionConfig.git_repo_id,
+      should_create_workflow: shouldCreateWorkflow,
+    };
+  };
   const [showGHAModal, setShowGHAModal] = useState<boolean>(false);
 
   return (
     <CenterWrapper>
       <Div>
-      <StyledConfigureTemplate>
-        <Back to="/apps" />
-        <DashboardHeader
-          prefix={
-            <Icon
-              src={web}
-            />
-          }
-          title="Deploy a new application"
-          capitalize={false}
-          disableLineBreak
-        />
-        <DarkMatter />
-        <VerticalSteps
-          currentStep={currentStep}
-          steps={[
-            <>
-              <Text size={16}>Application name</Text>
-              <Spacer y={0.5} />
-              <Text color="helper">
-                Lowercase letters, numbers, and "-" only.
-              </Text>
-              <Spacer y={0.5} />
-              <Input
-                placeholder="ex: academic-sophon"
-                value={formState.applicationName}
-                width="300px"
-                setValue={(e) => {
-                  setFormState({ ...formState, applicationName: e })
-                  if (Validators.applicationName(e)) {
-                    setCurrentStep(Math.max(currentStep, 1));
-                  }
-                }}
-              />
-            </>,
-            <>
-              <Text size={16}>Deployment method</Text>
-              <Spacer y={0.5} />
-              <Text color="helper">
-                Deploy from a Git repository or a Docker registry.
-                <a
-                  href="https://docs.porter.run/deploying-applications/overview"
-                  target="_blank"
-                >
-                  &nbsp;Learn more.
-                </a>
-              </Text>
-              <Spacer y={0.5} />
-              <SourceSelector
-                selectedSourceType={formState.selectedSourceType}
-                setSourceType={(type) => {
-                  setFormState({ ...formState, selectedSourceType: type })
-                  if (Validators.selectedSourceType(type)) {
-                    setCurrentStep(Math.max(currentStep, 2));
-                  }
-                }}
-              />
-              <SourceSettings source={formState.selectedSourceType} />
-            </>,
-            <>
-              <Text size={16}>Services</Text>
-              <Spacer y={1} />
-              <Services
-                setServices={
-                  (services: any[]) => {
-                    setFormState({ ...formState, serviceList: services })
+        <StyledConfigureTemplate>
+          <Back to="/apps" />
+          <DashboardHeader
+            prefix={<Icon src={web} />}
+            title="Deploy a new application"
+            capitalize={false}
+            disableLineBreak
+          />
+          <DarkMatter />
+          <VerticalSteps
+            currentStep={currentStep}
+            steps={[
+              <>
+                <Text size={16}>Application name</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  Lowercase letters, numbers, and "-" only.
+                </Text>
+                <Spacer y={0.5}></Spacer> 
+                <Input
+                  placeholder="ex: academic-sophon"
+                  value={formState.applicationName}
+                  width="300px"
+                  setValue={(e) => {
+                    setFormState({ ...formState, applicationName: e });
+                    if (Validators.applicationName(e)) {
+                      setCurrentStep(Math.max(currentStep, 1));
+                    }
+                  }}
+                />
+              </>,
+              <>
+                <Text size={16}>Deployment method</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  Deploy from a Git repository or a Docker registry.
+                  <a
+                    href="https://docs.porter.run/deploying-applications/overview"
+                    target="_blank"
+                  >
+                    &nbsp;Learn more.
+                  </a>
+                </Text>
+                <Spacer y={0.5} />
+                <SourceSelector
+                  selectedSourceType={formState.selectedSourceType}
+                  setSourceType={(type) => {
+                    setFormState({ ...formState, selectedSourceType: type });
+                    if (Validators.selectedSourceType(type)) {
+                      setCurrentStep(Math.max(currentStep, 2));
+                    }
+                  }}
+                />
+                <SourceSettings
+                  source={formState.selectedSourceType}
+                  templateName={templateName}
+                  setTemplateName={setTemplateName}
+                  imageUrl={imageUrl}
+                  setImageUrl={setImageUrl}
+                  imageTag={imageTag}
+                  setImageTag={setImageTag}
+                  actionConfig={actionConfig}
+                  setActionConfig={setActionConfig}
+                  branch={branch}
+                  setBranch={setBranch}
+                  procfileProcess={procfileProcess}
+                  setProcfileProcess={setProcfileProcess}
+                  repoType={repoType}
+                  setRepoType={setRepoType}
+                  dockerfilePath={dockerfilePath}
+                  setDockerfilePath={setDockerfilePath}
+                  folderPath={folderPath}
+                  setFolderPath={setFolderPath}
+                  procfilePath={procfilePath}
+                  setProcfilePath={setProcfilePath}
+                  selectedRegistry={selectedRegistry}
+                  setSelectedRegistry={setSelectedRegistry}
+                  setBuildConfig={setBuildConfig}
+                />
+              </>,
+              <>
+                <Text size={16}>Services</Text>
+                <Spacer y={1} />
+                <Services
+                  setServices={(services: any[]) => {
+                    setFormState({ ...formState, serviceList: services });
                     if (Validators.serviceList(services)) {
                       setCurrentStep(Math.max(currentStep, 4));
                     }
                   }}
-                services={formState.serviceList}
-              />
-            </>,
-            <>
-              <Text size={16}>Environment variables</Text>
-              <Spacer y={0.5} />
-              <Text color="helper">
-                Specify environment variables shared among all services.
-              </Text>
-              <EnvGroupArray
-                values={formState.envVariables}
-                setValues={(x: any) => setFormState({ ...formState, envVariables: x })}
-                fileUpload={true}
-              />
-            </>,
-            <>
-              <Text size={16}>Release command (optional)</Text>
-              <Spacer y={0.5} />
-              <Text color="helper">
-                If specified, this command will be run before every deployment.
-              </Text>
-              <Spacer y={0.5} />
-              <Input
-                placeholder="yarn ./scripts/run-migrations.js"
-                value={""}
-                width="300px"
-                setValue={(e) => { }}
-              />
-            </>
-          ]}
-        />
-        <Spacer y={1} />
-        <Button onClick={() => setShowGHAModal(true)}>
-          DEPLYOY
-        </Button>
-      </StyledConfigureTemplate>
+                  services={formState.serviceList}
+                />
+              </>,
+              <>
+                <Text size={16}>Environment variables</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  Specify environment variables shared among all services.
+                </Text>
+                <EnvGroupArray
+                  values={formState.envVariables}
+                  setValues={(x: any) =>
+                    setFormState({ ...formState, envVariables: x })
+                  }
+                  fileUpload={true}
+                />
+              </>,
+              <>
+                <Text size={16}>Release command (optional)</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  If specified, this command will be run before every
+                  deployment.
+                </Text>
+                <Spacer y={0.5} />
+                <Input
+                  placeholder="yarn ./scripts/run-migrations.js"
+                  value={""}
+                  width="300px"
+                  setValue={(e) => {}}
+                />
+              </>,
+            ]}
+          />
+          <Spacer y={1} />
+          <Button onClick={() => setShowGHAModal(true)}>DEPLYOY</Button>
+        </StyledConfigureTemplate>
       </Div>
-      {showGHAModal && <GithubActionModal closeModal={() => setShowGHAModal(false)} />}
+      {showGHAModal && (
+        <GithubActionModal closeModal={() => setShowGHAModal(false)} />
+      )}
     </CenterWrapper>
   );
 };
@@ -221,6 +279,3 @@ const Icon = styled.img`
 const StyledConfigureTemplate = styled.div`
   height: 100%;
 `;
-
-
-

+ 430 - 95
dashboard/src/main/home/app-dashboard/new-app-flow/SourceSettings.tsx

@@ -1,118 +1,267 @@
 import AnimateHeight from "react-animate-height";
-import React from "react";
+import React, { Component } from "react";
 import Text from "components/porter/Text";
 import Spacer from "components/porter/Spacer";
 import Input from "components/porter/Input";
 import AdvancedBuildSettings from "./AdvancedBuildSettings";
 import styled from "styled-components";
 import { SourceType } from "./SourceSelector";
+import ActionConfEditorStack from "components/repo-selector/ActionConfEditorStack";
+import { ActionConfigType } from "shared/types";
+import { RouteComponentProps } from "react-router";
+import { Context } from "shared/Context";
+import ActionConfBranchSelector from "components/repo-selector/ActionConfBranchSelector";
 
-interface SourceSettingsProps {
-    source: SourceType | undefined;
-}
+type PropsType = RouteComponentProps & {
+  source: SourceType | undefined;
+  templateName: string;
+  setTemplateName: (x: string) => void;
+  setValuesToOverride: (x: any) => void;
+  setPage: (x: string) => void;
+  sourceType: string;
+  setSourceType: (x: string) => void;
 
-const SourceSettings: React.FC<SourceSettingsProps> = ({
-    source
-}) => {
-    const renderGithubSettings = () => {
-        return (
-            <>
-                <Text size={16}>Build settings</Text>
-                <Spacer y={0.5} />
-                <Text color="helper">
-                    Select your Github repository.
-                </Text>
-                <Spacer y={0.5} />
-                <Input
-                    placeholder="ex: academic-sophon"
-                    value=""
-                    width="100%"
-                    setValue={(e) => { }}
-                />
-                <Spacer y={0.5} />
-                <Text color="helper">
-                    Select your branch.
-                </Text>
-                <Spacer y={0.5} />
-                <Input
-                    placeholder="ex: academic-sophon"
-                    value=""
-                    width="100%"
-                    setValue={(e) => { }}
-                />
-                <Spacer y={0.5} />
-                <Text color="helper">
-                    Specify your application root path.
-                </Text>
-                <Spacer y={0.5} />
-                <Input
-                    placeholder="ex: ./"
-                    value=""
-                    width="100%"
-                    setValue={(e) => { }}
-                />
-                <Spacer y={0.5} />
-                <Text color="helper">
-                    Specify your porter.yaml path. <a>&nbsp;What is this?</a>
-                </Text>
-                <Spacer y={0.5} />
-                <Input
-                    placeholder="ex: ./porter.yaml"
-                    value=""
-                    width="100%"
-                    setValue={(e) => { }}
-                />
-                <Spacer y={1} />
-                <DetectedBuildMessage>
-                    <i className="material-icons">check</i>
-                    Detected Dockerfile at ./Dockerfile
-                </DetectedBuildMessage>
-                <Spacer y={1} />
-                <AdvancedBuildSettings />
-            </>
-        )
-    }
+  imageUrl: string;
+  setImageUrl: (x: string) => void;
+  imageTag: string;
+  setImageTag: (x: string) => void;
 
-    const renderDockerSettings = () => {
-        return (
-            <>
-                <Text size={16}>Registry settings</Text>
-                <Spacer y={0.5} />
-                <Text color="helper">
-                    Select your Github repository.
-                </Text>
-                <Spacer height="20px" />
-                <Input
+  hasSource?: string;
+
+  actionConfig: ActionConfigType;
+  setActionConfig: (
+    x: ActionConfigType | ((prevState: ActionConfigType) => ActionConfigType)
+  ) => void;
+  procfileProcess: string;
+  setProcfileProcess: (x: string) => void;
+  branch: string;
+  setBranch: (x: string) => void;
+  repoType: string;
+  setRepoType: (x: string) => void;
+  dockerfilePath: string | null;
+  setDockerfilePath: (x: string) => void;
+  procfilePath: string | null;
+  setProcfilePath: (x: string) => void;
+  folderPath: string | null;
+  setFolderPath: (x: string) => void;
+  selectedRegistry: any;
+  setSelectedRegistry: (x: string) => void;
+  setBuildConfig: (x: any) => void;
+};
+const defaultActionConfig: ActionConfigType = {
+  git_repo: "",
+  image_repo_uri: "",
+  git_branch: "",
+  git_repo_id: 0,
+  kind: "github",
+};
+type StateType = {};
+class SourceSettings extends Component<PropsType, StateType> {
+  renderGithubSettings = () => {
+    return (
+      <>
+        <Text size={16}>Build settings</Text>
+        <Spacer y={0.5} />
+        <Text color="helper">Select your Github repository.</Text>
+        <Spacer y={0.5} />
+        {/* <Input
                     placeholder="ex: academic-sophon"
                     value=""
                     width="100%"
                     setValue={(e) => { }}
-                />
-                <Spacer y={0.5} />
-                <Text color="helper">
-                    Select your branch.
-                </Text>
-            </>
-        )
-    }
+                /> */}
+        {
+          <>
+            {" "}
+            {/* <CloseButton
+        onClick={() => {
+          setSourceType("");
+          setDockerfilePath("");
+          setFolderPath("");
+          setProcfilePath("");
+          setProcfileProcess("");
+        }}
+      >
+        <i className="material-icons">close</i>
+      </CloseButton> */}
+            <Subtitle>
+              Provide a repo folder to use as source.
+              {/* <Highlight
+                onClick={() => this.context.setCurrentModal("AccountSettingsModal", {})}
+              >
+                Manage Git repos
+              </Highlight> */}
+              <Required>*</Required>
+              <ActionConfEditorStack
+                actionConfig={this.props.actionConfig}
+                branch={this.props.branch}
+                setActionConfig={(actionConfig: ActionConfigType) => {
+                  this.props.setActionConfig(
+                    (currentActionConfig: ActionConfigType) => ({
+                      ...currentActionConfig,
+                      ...actionConfig,
+                    })
+                  );
+                  this.props.setImageUrl(actionConfig.image_repo_uri);
+                  /*
+            setParentState({ actionConfig }, () =>
+              setParentState({ imageUrl: actionConfig.image_repo_uri })
+            )
+            */
+                }}
+                procfileProcess={this.props.procfileProcess}
+                setProcfileProcess={(procfileProcess: string) => {
+                  this.props.setProcfileProcess(procfileProcess);
+                  this.props.setValuesToOverride((v: any) => ({
+                    ...v,
+                    "container.command": procfileProcess || "",
+                    showStartCommand: !procfileProcess,
+                  }));
+                }}
+                setBranch={this.props.setBranch}
+                setDockerfilePath={this.props.setDockerfilePath}
+                setProcfilePath={this.props.setProcfilePath}
+                procfilePath={this.props.procfilePath}
+                dockerfilePath={this.props.dockerfilePath}
+                folderPath={this.props.folderPath}
+                setFolderPath={this.props.setFolderPath}
+                reset={() => {
+                  this.props.setActionConfig({ ...defaultActionConfig });
+                  this.props.setBranch("");
+                  this.props.setDockerfilePath(null);
+                  this.props.setFolderPath(null);
+                }}
+                setSelectedRegistry={this.props.setSelectedRegistry}
+                selectedRegistry={this.props.selectedRegistry}
+                setBuildConfig={this.props.setBuildConfig}
+              />
+            </Subtitle>
+            <DarkMatter antiHeight="-4px" />
+            <br />
+          </>
+        }
+        <Spacer y={0.5} />
+        {this.props.actionConfig.git_repo ? (
+          <>
+            <Text color="helper">Select your branch.</Text>
+            <ActionConfBranchSelector
+              actionConfig={this.props.actionConfig}
+              branch={this.props.branch}
+              setActionConfig={(actionConfig: ActionConfigType) => {
+                this.props.setActionConfig(
+                  (currentActionConfig: ActionConfigType) => ({
+                    ...currentActionConfig,
+                    ...actionConfig,
+                  })
+                );
+                this.props.setImageUrl(actionConfig.image_repo_uri);
+                /*
+      setParentState({ actionConfig }, () =>
+        setParentState({ imageUrl: actionConfig.image_repo_uri })
+      )
+      */
+              }}
+              procfileProcess={this.props.procfileProcess}
+              setProcfileProcess={(procfileProcess: string) => {
+                this.props.setProcfileProcess(procfileProcess);
+                this.props.setValuesToOverride((v: any) => ({
+                  ...v,
+                  "container.command": procfileProcess || "",
+                  showStartCommand: !procfileProcess,
+                }));
+              }}
+              setBranch={this.props.setBranch}
+              setDockerfilePath={this.props.setDockerfilePath}
+              setProcfilePath={this.props.setProcfilePath}
+              procfilePath={this.props.procfilePath}
+              dockerfilePath={this.props.dockerfilePath}
+              folderPath={this.props.folderPath}
+              setFolderPath={this.props.setFolderPath}
+              reset={() => {
+                this.props.setActionConfig({ ...defaultActionConfig });
+                this.props.setBranch("");
+                this.props.setDockerfilePath(null);
+                this.props.setFolderPath(null);
+              }}
+              setSelectedRegistry={this.props.setSelectedRegistry}
+              selectedRegistry={this.props.selectedRegistry}
+              setBuildConfig={this.props.setBuildConfig}
+            />
+          </>
+        ) : (
+          <></>
+        )}
+        <Spacer y={0.5} />
+        <Spacer y={0.5} />
+        <Text color="helper">Specify your application root path.</Text>
+        <Spacer y={0.5} />
+        <Input
+          placeholder="ex: ./"
+          value=""
+          width="100%"
+          setValue={(e) => {}}
+        />
+        <Spacer y={0.5} />
+        <Text color="helper">
+          Specify your porter.yaml path. <a>&nbsp;What is this?</a>
+        </Text>
+        <Spacer y={0.5} />
+        <Input
+          placeholder="ex: ./porter.yaml"
+          value=""
+          width="100%"
+          setValue={(e) => {}}
+        />
+        <Spacer y={1} />
+        <DetectedBuildMessage>
+          <i className="material-icons">check</i>
+          Detected Dockerfile at ./Dockerfile
+        </DetectedBuildMessage>
+        <Spacer y={1} />
+        <AdvancedBuildSettings />
+      </>
+    );
+  };
+
+  renderDockerSettings = () => {
+    return (
+      <>
+        <Text size={16}>Registry settings</Text>
+        <Spacer y={0.5} />
+        <Text color="helper">Select your Github repository.</Text>
+        <Spacer height="20px" />
+        <Input
+          placeholder="ex: academic-sophon"
+          value=""
+          width="100%"
+          setValue={(e) => {}}
+        />
+        <Spacer y={0.5} />
+        <Text color="helper">Select your branch.</Text>
+      </>
+    );
+  };
 
+  render() {
     return (
-        <SourceSettingsContainer source={source}>
-            <AnimateHeight
-                height={source ? 'auto' : 0}
-            >
-                <div >
-                    {source === "github" ? renderGithubSettings() : renderDockerSettings()}
-                </div>
-            </AnimateHeight >
-        </SourceSettingsContainer>
-    )
+      <SourceSettingsContainer source={this.props.source}>
+        <AnimateHeight height={this.props.source ? "auto" : 0}>
+          <div>
+            {this.props.source === "github"
+              ? this.renderGithubSettings()
+              : this.renderDockerSettings()}
+          </div>
+        </AnimateHeight>
+      </SourceSettingsContainer>
+    );
+  }
 }
 
 export default SourceSettings;
 
 const SourceSettingsContainer = styled.div`
-    margin-top: ${(props: { source: SourceType | undefined }) => props.source && "20px"};
+  margin-top: ${(props: { source: SourceType | undefined }) =>
+    props.source && "20px"};
 `;
 const DetectedBuildMessage = styled.div`
   color: #0f872b;
@@ -127,4 +276,190 @@ const DetectedBuildMessage = styled.div`
     border-radius: 20px;
     transform: none;
   }
-`;
+`;
+
+const Heading = styled.div<{ isAtTop?: boolean }>`
+  color: white;
+  font-weight: 500;
+  font-size: 16px;
+  margin-bottom: 5px;
+  margin-top: ${(props) => (props.isAtTop ? "10px" : "30px")};
+  display: flex;
+  align-items: center;
+`;
+
+const StyledSourcePage = styled.div`
+  position: relative;
+  margin-top: -5px;
+`;
+
+const Buffer = styled.div`
+  width: 100%;
+  height: 15px;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 5px;
+`;
+
+const DarkMatter = styled.div<{ antiHeight?: string }>`
+  width: 100%;
+  margin-top: ${(props) => props.antiHeight || "-15px"};
+`;
+
+const Subtitle = styled.div`
+  padding: 11px 0px 16px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  line-height: 1.6em;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1;
+  border-radius: 50%;
+  right: 12px;
+  top: 10px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+
+  > i {
+    font-size: 20px;
+    color: #aaaabb;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const BlockIcon = styled.img<{ bw?: boolean }>`
+  height: 38px;
+  padding: 2px;
+  margin-top: 30px;
+  margin-bottom: 15px;
+  filter: ${(props) => (props.bw ? "grayscale(1)" : "")};
+`;
+
+const BlockDescription = styled.div`
+  margin-bottom: 12px;
+  color: #ffffff66;
+  text-align: center;
+  font-weight: default;
+  font-size: 13px;
+  padding: 0px 25px;
+  height: 2.4em;
+  font-size: 12px;
+  display: -webkit-box;
+  overflow: hidden;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+`;
+
+const BlockTitle = styled.div`
+  margin-bottom: 12px;
+  width: 80%;
+  text-align: center;
+  font-size: 14px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const Block = styled.div<{ disabled?: boolean }>`
+  align-items: center;
+  user-select: none;
+  display: flex;
+  font-size: 13px;
+  overflow: hidden;
+  font-weight: 500;
+  padding: 3px 0px 12px;
+  flex-direction: column;
+  align-item: center;
+  justify-content: space-between;
+  height: 170px;
+  cursor: ${(props) => (props.disabled ? "" : "pointer")};
+  color: #ffffff;
+  position: relative;
+
+  border-radius: 5px;
+  background: ${(props) => props.theme.clickable.bg};
+  border: 1px solid #494b4f;
+  :hover {
+  }
+  :hover {
+    border: ${(props) => (props.disabled ? "" : "1px solid #7a7b80")};
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const BlockList = styled.div`
+  overflow: visible;
+  margin-top: 6px;
+  margin-bottom: 27px;
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+`;
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+  display: inline-block;
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: -15px;
+  margin-bottom: -6px;
+`;
+
+const Warning = styled.span`
+  color: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
+    props.highlight ? "#f5cb42" : ""};
+  margin-left: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
+    props.makeFlush ? "" : "5px"};
+`;
+
+const Highlight = styled.a`
+  color: #8590ff;
+  text-decoration: none;
+  margin-left: 5px;
+  cursor: pointer;
+  display: inline;
+`;
+
+const StyledSourceBox = styled.div`
+  width: 100%;
+  color: #ffffff;
+  padding: 14px 35px 20px;
+  position: relative;
+  font-size: 13px;
+  margin-top: 6px;
+  margin-bottom: 25px;
+  border-radius: 5px;
+  background: ${(props) => props.theme.fg};
+  border: 1px solid #494b4f;
+`;

+ 63 - 3
internal/helm/agent.go

@@ -431,15 +431,75 @@ func (a *Agent) InstallChart(
 	}
 
 	if req := conf.Chart.Metadata.Dependencies; req != nil {
-		if err := action.CheckDependencies(conf.Chart, req); err != nil {
-			// TODO: Handle dependency updates.
-			return nil, err
+		for _, dep := range req {
+			depChart, err := loader.LoadChartPublic(dep.Repository, dep.Name, dep.Version)
+			if err != nil {
+				return nil, fmt.Errorf("error retrieving chart dependency %s/%s-%s: %s", dep.Repository, dep.Name, dep.Version, err.Error())
+			}
+
+			conf.Chart.AddDependency(depChart)
 		}
 	}
 
 	return cmd.Run(conf.Chart, conf.Values)
 }
 
+// UpgradeInstallChart installs a new chart if it doesn't exist, otherwise it upgrades it
+func (a *Agent) UpgradeInstallChart(
+	conf *InstallChartConfig,
+	doAuth *oauth2.Config,
+	disablePullSecretsInjection bool,
+) (*release.Release, error) {
+	defer func() {
+		if r := recover(); r != nil {
+			fmt.Println("stacktrace from panic: \n" + string(debug.Stack()))
+		}
+	}()
+
+	cmd := action.NewUpgrade(a.ActionConfig)
+	cmd.Install = true
+
+	if cmd.Version == "" && cmd.Devel {
+		cmd.Version = ">0.0.0-0"
+	}
+
+	cmd.Namespace = conf.Namespace
+	cmd.Timeout = 300 * time.Second
+
+	if err := checkIfInstallable(conf.Chart); err != nil {
+		return nil, err
+	}
+
+	var err error
+
+	cmd.PostRenderer, err = NewPorterPostrenderer(
+		conf.Cluster,
+		conf.Repo,
+		a.K8sAgent,
+		conf.Namespace,
+		conf.Registries,
+		doAuth,
+		disablePullSecretsInjection,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if req := conf.Chart.Metadata.Dependencies; req != nil {
+		for _, dep := range req {
+			depChart, err := loader.LoadChartPublic(dep.Repository, dep.Name, dep.Version)
+			if err != nil {
+				return nil, fmt.Errorf("error retrieving chart dependency %s/%s-%s: %s", dep.Repository, dep.Name, dep.Version, err.Error())
+			}
+
+			conf.Chart.AddDependency(depChart)
+		}
+	}
+
+	return cmd.Run(conf.Name, conf.Chart, conf.Values)
+}
+
 // UninstallChart uninstalls a chart
 func (a *Agent) UninstallChart(
 	name string,