Feroze Mohideen пре 3 година
родитељ
комит
862f27f8b9

+ 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

@@ -30,7 +30,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
+}

+ 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,