Browse Source

Merge pull request #2137 from porter-dev/belanger/stacks

Add support for stacks endpoints on the API
abelanger5 3 years ago
parent
commit
2e96fd527d

+ 2 - 0
api/server/authz/policy.go

@@ -130,6 +130,8 @@ func getRequestActionForEndpoint(
 			resource.Name, reqErr = requestutils.GetURLParamString(r, types.URLParamNamespace)
 		case types.ReleaseScope:
 			resource.Name, reqErr = requestutils.GetURLParamString(r, types.URLParamReleaseName)
+		case types.StackScope:
+			resource.Name, reqErr = requestutils.GetURLParamString(r, types.URLParamStackID)
 		case types.InviteScope:
 			resource.UInt, reqErr = requestutils.GetURLParamUint(r, types.URLParamInviteID)
 		case types.GitlabIntegrationScope:

+ 63 - 0
api/server/authz/stack.go

@@ -0,0 +1,63 @@
+package authz
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type StackScopedFactory struct {
+	config *config.Config
+}
+
+func NewStackScopedFactory(
+	config *config.Config,
+) *StackScopedFactory {
+	return &StackScopedFactory{config}
+}
+
+func (p *StackScopedFactory) Middleware(next http.Handler) http.Handler {
+	return &StackScopedMiddleware{next, p.config}
+}
+
+type StackScopedMiddleware struct {
+	next   http.Handler
+	config *config.Config
+}
+
+func (p *StackScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// read the project to check scopes
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	// get the registry id from the URL param context
+	reqScopes, _ := r.Context().Value(types.RequestScopeCtxKey).(map[types.PermissionScope]*types.RequestAction)
+	stackID := reqScopes[types.StackScope].Resource.Name
+
+	stack, err := p.config.Repo.Stack().ReadStackByStringID(proj.ID, stackID)
+
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrForbidden(
+				fmt.Errorf("stack with id %s not found in project %d", stackID, proj.ID),
+			), true)
+		} else {
+			apierrors.HandleAPIError(p.config.Logger, p.config.Alerter, w, r, apierrors.NewErrInternal(err), true)
+		}
+
+		return
+	}
+
+	ctx := NewStackContext(r.Context(), stack)
+	r = r.Clone(ctx)
+	p.next.ServeHTTP(w, r)
+}
+
+func NewStackContext(ctx context.Context, stack *models.Stack) context.Context {
+	return context.WithValue(ctx, types.StackScope, stack)
+}

+ 26 - 9
api/server/handlers/release/create.go

@@ -138,7 +138,7 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		}
 	}
 
-	release, err := createReleaseFromHelmRelease(c.Config(), cluster.ProjectID, cluster.ID, helmRelease)
+	release, err := CreateAppReleaseFromHelmRelease(c.Config(), cluster.ProjectID, cluster.ID, 0, helmRelease)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -218,9 +218,9 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	w.WriteHeader(http.StatusCreated)
 }
 
-func createReleaseFromHelmRelease(
+func CreateAppReleaseFromHelmRelease(
 	config *config.Config,
-	projectID, clusterID uint,
+	projectID, clusterID, stackResourceID uint,
 	helmRelease *release.Release,
 ) (*models.Release, error) {
 	token, err := encryption.GenerateRandomBytes(16)
@@ -244,12 +244,29 @@ func createReleaseFromHelmRelease(
 	}
 
 	release := &models.Release{
-		ClusterID:    clusterID,
-		ProjectID:    projectID,
-		Namespace:    helmRelease.Namespace,
-		Name:         helmRelease.Name,
-		WebhookToken: token,
-		ImageRepoURI: repoStr,
+		ClusterID:       clusterID,
+		ProjectID:       projectID,
+		Namespace:       helmRelease.Namespace,
+		Name:            helmRelease.Name,
+		WebhookToken:    token,
+		ImageRepoURI:    repoStr,
+		StackResourceID: stackResourceID,
+	}
+
+	return config.Repo.Release().CreateRelease(release)
+}
+
+func CreateAddonReleaseFromHelmRelease(
+	config *config.Config,
+	projectID, clusterID, stackResourceID uint,
+	helmRelease *release.Release,
+) (*models.Release, error) {
+	release := &models.Release{
+		ClusterID:       clusterID,
+		ProjectID:       projectID,
+		Namespace:       helmRelease.Namespace,
+		Name:            helmRelease.Name,
+		StackResourceID: stackResourceID,
 	}
 
 	return config.Repo.Release().CreateRelease(release)

+ 1 - 1
api/server/handlers/release/create_webhook.go

@@ -29,7 +29,7 @@ func (c *CreateWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	helmRelease, _ := r.Context().Value(types.ReleaseScope).(*release.Release)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
-	release, err := createReleaseFromHelmRelease(c.Config(), cluster.ProjectID, cluster.ID, helmRelease)
+	release, err := CreateAppReleaseFromHelmRelease(c.Config(), cluster.ProjectID, cluster.ID, 0, helmRelease)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 16 - 0
api/server/handlers/release/upgrade.go

@@ -16,6 +16,7 @@ import (
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/integrations/slack"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
 	"helm.sh/helm/v3/pkg/release"
 )
 
@@ -229,4 +230,19 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 			}
 		}
 	}
+
+	c.WriteResult(w, r, nil)
+
+	err = postUpgrade(c.Config(), cluster.ProjectID, cluster.ID, helmRelease)
+
+	if err != nil {
+		c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}
+
+// postUpgrade runs any necessary scripting after the release has been upgraded.
+func postUpgrade(config *config.Config, projectID, clusterID uint, release *release.Release) error {
+	// update the relevant helm revision number if tied to a stack resource
+	return stacks.UpdateHelmRevision(config, projectID, clusterID, release)
 }

+ 9 - 0
api/server/handlers/release/upgrade_webhook.go

@@ -204,4 +204,13 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			rel.Chart.Metadata.Name,
 		),
 	}))
+
+	c.WriteResult(w, r, nil)
+
+	err = postUpgrade(c.Config(), cluster.ProjectID, cluster.ID, rel)
+
+	if err != nil {
+		c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+		return
+	}
 }

+ 250 - 0
api/server/handlers/stack/create.go

@@ -0,0 +1,250 @@
+package stack
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/handlers/release"
+	"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/encryption"
+	"github.com/porter-dev/porter/internal/models"
+
+	helmrelease "helm.sh/helm/v3/pkg/release"
+)
+
+type StackCreateHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackCreateHandler(
+	config *config.Config,
+	reader shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StackCreateHandler {
+	return &StackCreateHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, reader, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	namespace, _ := r.Context().Value(types.NamespaceScope).(string)
+
+	req := &types.CreateStackRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	// populate fields with defaults
+	for i, reqResource := range req.AppResources {
+		if reqResource.TemplateRepoURL == "" {
+			req.AppResources[i].TemplateRepoURL = p.Config().ServerConf.DefaultApplicationHelmRepoURL
+		}
+	}
+
+	uid, err := encryption.GenerateRandomBytes(16)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	sourceConfigs, err := getSourceConfigModels(req.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	resources, err := getResourceModels(req.AppResources, sourceConfigs, p.Config().ServerConf.DefaultApplicationHelmRepoURL)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// write stack to the database with creating status
+	stack := &models.Stack{
+		ProjectID: proj.ID,
+		ClusterID: cluster.ID,
+		Namespace: namespace,
+		Name:      req.Name,
+		UID:       uid,
+		Revisions: []models.StackRevision{
+			{
+				RevisionNumber: 1,
+				Status:         string(types.StackRevisionStatusDeploying),
+				SourceConfigs:  sourceConfigs,
+				Resources:      resources,
+			},
+		},
+	}
+
+	stack, err = p.Repo().Stack().CreateStack(stack)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// apply all app resources
+	registries, err := p.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	helmAgent, err := p.GetHelmAgent(r, cluster, "")
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	helmReleaseMap := make(map[string]*helmrelease.Release)
+
+	deployErrs := make([]string, 0)
+
+	for _, appResource := range req.AppResources {
+		rel, err := applyAppResource(&applyAppResourceOpts{
+			config:     p.Config(),
+			projectID:  proj.ID,
+			namespace:  namespace,
+			cluster:    cluster,
+			registries: registries,
+			helmAgent:  helmAgent,
+			request:    appResource,
+		})
+
+		if err != nil {
+			deployErrs = append(deployErrs, err.Error())
+		} else {
+			helmReleaseMap[fmt.Sprintf("%s/%s", namespace, appResource.Name)] = rel
+		}
+	}
+
+	// update stack revision status
+	revision := &stack.Revisions[0]
+
+	if len(deployErrs) > 0 {
+		revision.Status = string(types.StackRevisionStatusFailed)
+		revision.Reason = "DeployError"
+		revision.Message = strings.Join(deployErrs, " , ")
+	} else {
+		revision.Status = string(types.StackRevisionStatusDeployed)
+	}
+
+	revision, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	saveErrs := make([]string, 0)
+
+	for _, resource := range revision.Resources {
+		if rel, exists := helmReleaseMap[fmt.Sprintf("%s/%s", namespace, resource.Name)]; exists {
+			_, err = release.CreateAppReleaseFromHelmRelease(p.Config(), proj.ID, cluster.ID, resource.ID, rel)
+
+			if err != nil {
+				saveErrs = append(saveErrs, fmt.Sprintf("the resource %s/%s could not be saved right now", namespace, resource.Name))
+			}
+		}
+	}
+
+	if len(saveErrs) > 0 {
+		revision.Reason = "SaveError"
+		revision.Message = strings.Join(saveErrs, " , ")
+
+		revision, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	// read the stack again to get the latest revision info
+	stack, err = p.Repo().Stack().ReadStackByStringID(proj.ID, stack.UID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	w.WriteHeader(http.StatusCreated)
+	p.WriteResult(w, r, stack.ToStackType())
+}
+
+func getSourceConfigModels(sourceConfigs []*types.CreateStackSourceConfigRequest) ([]models.StackSourceConfig, error) {
+	res := make([]models.StackSourceConfig, 0)
+
+	// for now, only write source configs which are deployed as a docker image
+	// TODO: add parsing/writes for git-based sources
+	for _, sourceConfig := range sourceConfigs {
+		if sourceConfig.StackSourceConfigBuild == nil {
+			uid, err := encryption.GenerateRandomBytes(16)
+
+			if err != nil {
+				return nil, err
+			}
+
+			res = append(res, models.StackSourceConfig{
+				UID:          uid,
+				Name:         sourceConfig.Name,
+				ImageRepoURI: sourceConfig.ImageRepoURI,
+				ImageTag:     sourceConfig.ImageTag,
+			})
+		}
+	}
+
+	return res, nil
+}
+
+func getResourceModels(appResources []*types.CreateStackAppResourceRequest, sourceConfigs []models.StackSourceConfig, defaultRepoURL string) ([]models.StackResource, error) {
+	res := make([]models.StackResource, 0)
+
+	for _, appResource := range appResources {
+		uid, err := encryption.GenerateRandomBytes(16)
+
+		if err != nil {
+			return nil, err
+		}
+
+		var linkedSourceConfigUID string
+
+		for _, sourceConfig := range sourceConfigs {
+			if sourceConfig.Name == appResource.SourceConfigName {
+				linkedSourceConfigUID = sourceConfig.UID
+			}
+		}
+
+		if linkedSourceConfigUID == "" {
+			return nil, fmt.Errorf("source config %s does not exist in source config list", appResource.SourceConfigName)
+		}
+
+		res = append(res, models.StackResource{
+			Name:                 appResource.Name,
+			UID:                  uid,
+			StackSourceConfigUID: linkedSourceConfigUID,
+			TemplateRepoURL:      appResource.TemplateRepoURL,
+			TemplateName:         appResource.TemplateName,
+			TemplateVersion:      appResource.TemplateVersion,
+			HelmRevisionID:       1,
+		})
+	}
+
+	return res, nil
+}

+ 63 - 0
api/server/handlers/stack/delete.go

@@ -0,0 +1,63 @@
+package stack
+
+import (
+	"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/models"
+)
+
+type StackDeleteHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackDeleteHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *StackDeleteHandler {
+	return &StackDeleteHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	namespace, _ := r.Context().Value(types.NamespaceScope).(string)
+
+	revision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, stack.Revisions[0].ID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	helmAgent, err := p.GetHelmAgent(r, cluster, namespace)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// delete all resources in stack
+	for _, appResource := range revision.Resources {
+		deleteAppResource(&deleteAppResourceOpts{
+			helmAgent: helmAgent,
+			name:      appResource.Name,
+		})
+	}
+
+	stack, err = p.Repo().Stack().DeleteStack(stack)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 30 - 0
api/server/handlers/stack/get.go

@@ -0,0 +1,30 @@
+package stack
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type StackGetHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewStackGetHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *StackGetHandler {
+	return &StackGetHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *StackGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+
+	p.WriteResult(w, r, stack.ToStackType())
+}

+ 47 - 0
api/server/handlers/stack/get_revision.go

@@ -0,0 +1,47 @@
+package stack
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type StackGetRevisionHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewStackGetRevisionHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *StackGetRevisionHandler {
+	return &StackGetRevisionHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *StackGetRevisionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+
+	// read the revision number from the request
+	revNumber, reqErr := requestutils.GetURLParamUint(r, types.URLParamStackRevisionNumber)
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	revision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, revNumber)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	p.WriteResult(w, r, revision.ToStackRevisionType(stack.UID))
+}

+ 103 - 0
api/server/handlers/stack/helpers.go

@@ -0,0 +1,103 @@
+package stack
+
+import (
+	"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/helm/loader"
+	"github.com/porter-dev/porter/internal/models"
+	"helm.sh/helm/v3/pkg/release"
+)
+
+type applyAppResourceOpts struct {
+	config     *config.Config
+	projectID  uint
+	namespace  string
+	cluster    *models.Cluster
+	helmAgent  *helm.Agent
+	request    *types.CreateStackAppResourceRequest
+	registries []*models.Registry
+}
+
+func applyAppResource(opts *applyAppResourceOpts) (*release.Release, error) {
+	if opts.request.TemplateVersion == "latest" {
+		opts.request.TemplateVersion = ""
+	}
+
+	chart, err := loader.LoadChartPublic(opts.request.TemplateRepoURL, opts.request.TemplateName, opts.request.TemplateVersion)
+
+	if err != nil {
+		return nil, err
+	}
+
+	conf := &helm.InstallChartConfig{
+		Chart:      chart,
+		Name:       opts.request.Name,
+		Namespace:  opts.namespace,
+		Values:     opts.request.Values,
+		Cluster:    opts.cluster,
+		Repo:       opts.config.Repo,
+		Registries: opts.registries,
+	}
+
+	return opts.helmAgent.InstallChart(conf, opts.config.DOConf)
+}
+
+type rollbackAppResourceOpts struct {
+	helmAgent      *helm.Agent
+	helmRevisionID uint
+	name           string
+}
+
+func rollbackAppResource(opts *rollbackAppResourceOpts) error {
+	return opts.helmAgent.RollbackRelease(opts.name, int(opts.helmRevisionID))
+}
+
+type updateAppResourceTagOpts struct {
+	helmAgent  *helm.Agent
+	name, tag  string
+	config     *config.Config
+	projectID  uint
+	namespace  string
+	cluster    *models.Cluster
+	registries []*models.Registry
+}
+
+func updateAppResourceTag(opts *updateAppResourceTagOpts) error {
+	// read the current release to get the current values
+	rel, err := opts.helmAgent.GetRelease(opts.name, 0, true)
+
+	if err != nil {
+		return err
+	}
+
+	imagePre := rel.Config["image"]
+	image := imagePre.(map[string]interface{})
+	image["tag"] = opts.tag
+	rel.Config["image"] = image
+
+	conf := &helm.UpgradeReleaseConfig{
+		Name:       opts.name,
+		Cluster:    opts.cluster,
+		Repo:       opts.config.Repo,
+		Registries: opts.registries,
+		Values:     rel.Config,
+	}
+
+	_, err = opts.helmAgent.UpgradeReleaseByValues(conf, opts.config.DOConf)
+
+	return err
+}
+
+type deleteAppResourceOpts struct {
+	helmAgent *helm.Agent
+	name      string
+}
+
+func deleteAppResource(opts *deleteAppResourceOpts) error {
+	_, err := opts.helmAgent.UninstallChart(opts.name)
+
+	return err
+}
+
+// func setValuesWithSourceConfig(values map[string]interface{}, sourceConfig )

+ 46 - 0
api/server/handlers/stack/list.go

@@ -0,0 +1,46 @@
+package stack
+
+import (
+	"net/http"
+
+	"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/models"
+)
+
+type StackListHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewStackListHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *StackListHandler {
+	return &StackListHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *StackListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	namespace, _ := r.Context().Value(types.NamespaceScope).(string)
+
+	stacks, err := p.Repo().Stack().ListStacks(proj.ID, cluster.ID, namespace)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := make([]*types.Stack, 0)
+
+	for _, stack := range stacks {
+		res = append(res, stack.ToStackType())
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 138 - 0
api/server/handlers/stack/rollback.go

@@ -0,0 +1,138 @@
+package stack
+
+import (
+	"net/http"
+	"strings"
+
+	"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/models"
+	"github.com/porter-dev/porter/internal/stacks"
+	"gorm.io/gorm"
+)
+
+type StackRollbackHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackRollbackHandler(
+	config *config.Config,
+	reader shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StackRollbackHandler {
+	return &StackRollbackHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, reader, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackRollbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	helmAgent, err := p.GetHelmAgent(r, cluster, "")
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	// namespace, _ := r.Context().Value(types.NamespaceScope).(string)
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+
+	req := &types.StackRollbackRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	// read the target revision
+	revision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, req.TargetRevision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// read the latest revision
+	latestRevision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, stack.Revisions[0].RevisionNumber)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// clear out model data and create new revision
+	revision.Model = gorm.Model{}
+	revision.RevisionNumber = latestRevision.RevisionNumber + 1
+	revision.Status = string(types.StackRevisionStatusDeploying)
+
+	newSourceConfigs, err := stacks.CloneSourceConfigs(revision.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	appResources, err := stacks.CloneAppResources(revision.Resources, revision.SourceConfigs, newSourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	revision.SourceConfigs = newSourceConfigs
+	revision.Resources = appResources
+
+	revision, err = p.Repo().Stack().AppendNewRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// apply to cluster
+	rollbackErrors := make([]string, 0)
+
+	for _, resource := range revision.Resources {
+		err := rollbackAppResource(&rollbackAppResourceOpts{
+			helmAgent:      helmAgent,
+			helmRevisionID: resource.HelmRevisionID,
+			name:           resource.Name,
+		})
+
+		if err != nil {
+			rollbackErrors = append(rollbackErrors, err.Error())
+		}
+	}
+
+	if len(rollbackErrors) > 0 {
+		revision.Status = string(types.StackRevisionStatusFailed)
+		revision.Reason = "RollbackError"
+		revision.Message = strings.Join(rollbackErrors, " , ")
+	} else {
+		revision.Status = string(types.StackRevisionStatusDeployed)
+	}
+
+	revision, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// read the stack again to get the latest revision info
+	stack, err = p.Repo().Stack().ReadStackByStringID(proj.ID, stack.UID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	p.WriteResult(w, r, stack.ToStackType())
+}

+ 154 - 0
api/server/handlers/stack/update_source_put.go

@@ -0,0 +1,154 @@
+package stack
+
+import (
+	"net/http"
+	"strings"
+
+	"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/models"
+	"github.com/porter-dev/porter/internal/stacks"
+	"gorm.io/gorm"
+)
+
+type StackPutSourceConfigHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackPutSourceConfigHandler(
+	config *config.Config,
+	reader shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StackPutSourceConfigHandler {
+	return &StackPutSourceConfigHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, reader, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackPutSourceConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	namespace, _ := r.Context().Value(types.NamespaceScope).(string)
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+
+	helmAgent, err := p.GetHelmAgent(r, cluster, "")
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	req := &types.PutStackSourceConfigRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	// read the latest revision
+	revision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, stack.Revisions[0].RevisionNumber)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	sourceConfigs, err := getSourceConfigModels(req.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// clear out model data and create new revision
+	revision.Model = gorm.Model{}
+	revision.RevisionNumber++
+	revision.Status = string(types.StackRevisionStatusDeploying)
+	prevSourceConfigs := revision.SourceConfigs
+	revision.SourceConfigs = sourceConfigs
+	clonedAppResources, err := stacks.CloneAppResources(revision.Resources, prevSourceConfigs, revision.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	revision.Resources = clonedAppResources
+
+	revision, err = p.Repo().Stack().AppendNewRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// apply to cluster
+	registries, err := p.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	deployErrs := make([]string, 0)
+
+	for i, appResource := range clonedAppResources {
+		// get the corresponding source config tag
+		var imageTag string
+
+		for _, sourceConfig := range sourceConfigs {
+			if sourceConfig.UID == appResource.StackSourceConfigUID {
+				imageTag = sourceConfig.ImageTag
+			}
+		}
+
+		// TODO: case on if image tag is empty
+
+		err = updateAppResourceTag(&updateAppResourceTagOpts{
+			helmAgent:  helmAgent,
+			name:       appResource.Name,
+			tag:        imageTag,
+			config:     p.Config(),
+			projectID:  proj.ID,
+			namespace:  namespace,
+			cluster:    cluster,
+			registries: registries,
+		})
+
+		if err != nil {
+			deployErrs = append(deployErrs, err.Error())
+		}
+
+		clonedAppResources[i].HelmRevisionID++
+	}
+
+	if len(deployErrs) > 0 {
+		revision.Status = string(types.StackRevisionStatusFailed)
+		revision.Reason = "DeployError"
+		revision.Message = strings.Join(deployErrs, " , ")
+	} else {
+		revision.Status = string(types.StackRevisionStatusDeployed)
+	}
+
+	revision, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// read the stack again to get the latest revision info
+	stack, err = p.Repo().Stack().ReadStackByStringID(proj.ID, stack.UID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	p.WriteResult(w, r, stack.ToStackType())
+}

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

@@ -117,7 +117,8 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 
 		v1RegistryRegisterer := v1.NewV1RegistryScopedRegisterer()
 		v1ReleaseRegisterer := v1.NewV1ReleaseScopedRegisterer()
-		v1NamespaceRegisterer := v1.NewV1NamespaceScopedRegisterer(v1ReleaseRegisterer)
+		v1StackRegisterer := v1.NewV1StackScopedRegisterer()
+		v1NamespaceRegisterer := v1.NewV1NamespaceScopedRegisterer(v1ReleaseRegisterer, v1StackRegisterer)
 		v1ClusterRegisterer := v1.NewV1ClusterScopedRegisterer(v1NamespaceRegisterer)
 		v1ProjRegisterer := v1.NewV1ProjectScopedRegisterer(
 			v1ClusterRegisterer,
@@ -208,6 +209,10 @@ func registerRoutes(config *config.Config, routes []*router.Route) {
 	// after authorization. Each subsequent http.Handler can lookup the release in context.
 	releaseFactory := authz.NewReleaseScopedFactory(config)
 
+	// Create a new "stack-scoped" factory which will create a new stack-scoped request after
+	// authorization. Each subsequent http.Handler can lookup the stack in context.
+	stackFactory := authz.NewStackScopedFactory(config)
+
 	// Policy doc loader loads the policy documents for a specific project.
 	policyDocLoader := policy.NewBasicPolicyDocumentLoader(config.Repo.Project(), config.Repo.Policy())
 
@@ -255,6 +260,8 @@ func registerRoutes(config *config.Config, routes []*router.Route) {
 				atomicGroup.Use(operationFactory.Middleware)
 			case types.ReleaseScope:
 				atomicGroup.Use(releaseFactory.Middleware)
+			case types.StackScope:
+				atomicGroup.Use(stackFactory.Middleware)
 			case types.GitlabIntegrationScope:
 				atomicGroup.Use(gitlabIntFactory.Middleware)
 			}

+ 1 - 1
api/server/router/v1/namespace.go

@@ -8,7 +8,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 )
 
-// swagger:parameters getNamespace deleteNamespace createRelease
+// swagger:parameters getNamespace deleteNamespace createRelease createStack listStacks
 type namespacePathParams struct {
 	// The project id
 	// in: path

+ 490 - 0
api/server/router/v1/stack.go

@@ -0,0 +1,490 @@
+package v1
+
+import (
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/handlers/stack"
+	"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"
+)
+
+// swagger:parameters getStack deleteStack putStackSource rollbackStack
+type stackPathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+
+	// The cluster id
+	// in: path
+	// required: true
+	// minimum: 1
+	ClusterID uint `json:"cluster_id"`
+
+	// The namespace
+	// in: path
+	// required: true
+	Namespace string `json:"namespace"`
+
+	// The stack id
+	// in: path
+	// required: true
+	StackID string `json:"stack_id"`
+}
+
+// swagger:parameters getStackRevision
+type stackRevisionPathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+
+	// The cluster id
+	// in: path
+	// required: true
+	// minimum: 1
+	ClusterID uint `json:"cluster_id"`
+
+	// The namespace
+	// in: path
+	// required: true
+	Namespace string `json:"namespace"`
+
+	// The stack id
+	// in: path
+	// required: true
+	StackID string `json:"stack_id"`
+
+	// The stack revision number
+	// in: path
+	// required: true
+	// minimum: 1
+	StackRevisionNumber string `json:"stack_revision_number"`
+}
+
+func NewV1StackScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
+		GetRoutes: GetV1StackScopedRoutes,
+		Children:  children,
+	}
+}
+
+func GetV1StackScopedRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+	children ...*router.Registerer,
+) []*router.Route {
+	routes, projPath := getV1StackRoutes(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 getV1StackRoutes(
+	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/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks -> stack.NewStackCreateHandler
+	// swagger:operation POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks createStack
+	//
+	// Creates a new stack and triggers a deployment for all resources in the stack.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Create a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - in: body
+	//     name: CreateStackRequest
+	//     description: The stack to create
+	//     schema:
+	//       $ref: '#/definitions/CreateStackRequest'
+	// responses:
+	//   '201':
+	//     description: Successfully created the stack
+	//     schema:
+	//       $ref: '#/definitions/Stack'
+	//   '403':
+	//     description: Forbidden
+	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,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	createHandler := stack.NewStackCreateHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: createEndpoint,
+		Handler:  createHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks -> stack.NewStackListHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks listStacks
+	//
+	// Lists stacks in a namespace
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: List stacks
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	// responses:
+	//   '200':
+	//     description: Successfully listed stacks
+	//     schema:
+	//       $ref: '#/definitions/StackListResponse'
+	//   '403':
+	//     description: Forbidden
+	listEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	listHandler := stack.NewStackListHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listEndpoint,
+		Handler:  listHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id} -> stack.NewStackGetHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id} getStack
+	//
+	// Gets a stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Get a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	// responses:
+	//   '200':
+	//     description: Successfully got the stack
+	//     schema:
+	//       $ref: '#/definitions/Stack'
+	//   '403':
+	//     description: Forbidden
+	getEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	getHandler := stack.NewStackGetHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getEndpoint,
+		Handler:  getHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/{stack_revision_number} -> stack.NewStackGetRevisionHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/{stack_revision_number} getStackRevision
+	//
+	// Gets a stack revision
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Get a stack revision
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - name: stack_revision_number
+	// responses:
+	//   '200':
+	//     description: Successfully got the stack revision
+	//     schema:
+	//       $ref: '#/definitions/StackRevision'
+	//   '403':
+	//     description: Forbidden
+	getRevisionEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/{stack_revision_number}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	getRevisionHandler := stack.NewStackGetRevisionHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getRevisionEndpoint,
+		Handler:  getRevisionHandler,
+		Router:   r,
+	})
+
+	// PUT /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/source -> stack.NewStackPutSourceConfig
+	// swagger:operation PUT /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/source putStackSource
+	//
+	// Updates a stack's source configuration
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Update source configuration
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - in: body
+	//     name: PutStackSourceConfigRequest
+	//     description: The source configurations to update
+	//     schema:
+	//       $ref: '#/definitions/PutStackSourceConfigRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully updated the source configuration
+	//     schema:
+	//       $ref: '#/definitions/Stack'
+	//   '403':
+	//     description: Forbidden
+	putSourceEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPut,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/source",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	putSourceHandler := stack.NewStackPutSourceConfigHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: putSourceEndpoint,
+		Handler:  putSourceHandler,
+		Router:   r,
+	})
+
+	// POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/rollback -> stack.NewStackRollbackHandler
+	// swagger:operation POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/rollback rollbackStack
+	//
+	// Performs a rollback for a stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Rollback stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - in: body
+	//     name: StackRollbackRequest
+	//     description: The target revision to roll back to
+	//     schema:
+	//       $ref: '#/definitions/StackRollbackRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully rolled the stack back
+	//     schema:
+	//       $ref: '#/definitions/Stack'
+	//   '403':
+	//     description: Forbidden
+	rollbackEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/rollback",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	rollbackHandler := stack.NewStackRollbackHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: rollbackEndpoint,
+		Handler:  rollbackHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id} -> stack.NewStackDeleteHandler
+	// swagger:operation DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id} deleteStack
+	//
+	// Deletes a stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Delete a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	// responses:
+	//   '200':
+	//     description: Successfully deleted the stack
+	//   '403':
+	//     description: Forbidden
+	deleteEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	deleteHandler := stack.NewStackDeleteHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: deleteEndpoint,
+		Handler:  deleteHandler,
+		Router:   r,
+	})
+
+	return routes, newPath
+}

+ 2 - 0
api/types/policy.go

@@ -17,6 +17,7 @@ const (
 	NamespaceScope         PermissionScope = "namespace"
 	SettingsScope          PermissionScope = "settings"
 	ReleaseScope           PermissionScope = "release"
+	StackScope             PermissionScope = "stack"
 	GitlabIntegrationScope PermissionScope = "gitlab_integration"
 )
 
@@ -41,6 +42,7 @@ var ScopeHeirarchy = ScopeTree{
 	ProjectScope: {
 		ClusterScope: {
 			NamespaceScope: {
+				StackScope:   {},
 				ReleaseScope: {},
 			},
 		},

+ 1 - 0
api/types/project.go

@@ -8,6 +8,7 @@ type Project struct {
 	RDSDatabasesEnabled bool    `json:"enable_rds_databases"`
 	ManagedInfraEnabled bool    `json:"managed_infra_enabled"`
 	APITokensEnabled    bool    `json:"api_tokens_enabled"`
+	StacksEnabled       bool    `json:"stacks_enabled"`
 }
 
 type CreateProjectRequest struct {

+ 3 - 0
api/types/release.go

@@ -35,6 +35,9 @@ type PorterRelease struct {
 
 	// The list of tags for this release
 	Tags []string `json:"tags,omitempty"`
+
+	// Whether this release is tied to a stack or not
+	IsStack bool `json:"is_stack"`
 }
 
 // swagger:model

+ 1 - 0
api/types/request.go

@@ -43,6 +43,7 @@ const (
 	URLParamInviteID          URLParam = "invite_id"
 	URLParamNamespace         URLParam = "namespace"
 	URLParamReleaseName       URLParam = "name"
+	URLParamStackID           URLParam = "stack_id"
 	URLParamReleaseVersion    URLParam = "version"
 	URLParamWildcard          URLParam = "*"
 	URLParamIntegrationID     URLParam = "integration_id"

+ 263 - 0
api/types/stacks.go

@@ -0,0 +1,263 @@
+package types
+
+import "time"
+
+// swagger:model
+type CreateStackRequest struct {
+	// The display name of the stack
+	// required: true
+	Name string `json:"name" form:"required"`
+
+	// A list of app resources to create. An app resource is an application helm chart, such as a `web` or `worker` template.
+	// required: true
+	AppResources []*CreateStackAppResourceRequest `json:"app_resources,omitempty" form:"required,dive,required"`
+
+	// A list of configurations which can build an application. Each application resource must use at least one
+	// source config in order to build application from source. The source config can be specified as a Docker image
+	// registry or linked to a remote Git repository.
+	// required: true
+	SourceConfigs []*CreateStackSourceConfigRequest `json:"source_configs,omitempty" form:"required,dive,required"`
+}
+
+// swagger:model
+type PutStackSourceConfigRequest struct {
+	SourceConfigs []*CreateStackSourceConfigRequest `json:"source_configs,omitempty" form:"required,dive,required"`
+}
+
+const URLParamStackRevisionNumber URLParam = "stack_revision_number"
+
+// swagger:model
+type StackRollbackRequest struct {
+	TargetRevision uint `json:"target_revision"`
+}
+
+// swagger:model
+type PatchStackSourceConfigRequest struct {
+	SourceConfig *UpdateStackSourceConfigRequest `json:"source_config,omitempty" form:"required"`
+}
+
+type CreateStackAppResourceRequest struct {
+	// The URL of the Helm registry to pull the template from. If not set, this defaults to `https://charts.getporter.dev`.
+	TemplateRepoURL string `json:"template_repo_url"`
+
+	// The name of the template in the Helm registry, such as `web`
+	// required: true
+	TemplateName string `json:"template_name" form:"required"`
+
+	// The version of the template in the Helm registry, such as `v0.50.0`
+	// required: true
+	TemplateVersion string `json:"template_version" form:"required"`
+
+	// The values to pass in to the template.
+	Values map[string]interface{} `json:"values"`
+
+	// The name of the resource.
+	// required: true
+	Name string `json:"name" form:"required"`
+
+	// The name of the source config (must exist inside `source_configs`).
+	// required: true
+	SourceConfigName string `json:"source_config_name" form:"required"`
+}
+
+// swagger:model
+type Stack struct {
+	// The time that the stack was initially created
+	CreatedAt time.Time `json:"created_at"`
+
+	// The time that the stack was last updated
+	UpdatedAt time.Time `json:"updated_at"`
+
+	// The display name of the stack
+	Name string `json:"name"`
+
+	// A unique id for the stack
+	ID string `json:"id"`
+
+	// The latest revision for the stack
+	LatestRevision *StackRevision `json:"latest_revision,omitempty"`
+
+	// The list of revisions deployed for this stack
+	Revisions []StackRevisionMeta `json:"revisions,omitempty"`
+}
+
+// swagger:model
+type StackListResponse []Stack
+
+type StackResource struct {
+	// The time that this resource was initially created
+	CreatedAt time.Time `json:"created_at"`
+
+	// The time that this resource was last updated
+	UpdatedAt time.Time `json:"updated_at"`
+
+	// The stack ID that this resource belongs to
+	StackID string `json:"stack_id"`
+
+	// The numerical revision id that this resource belongs to
+	StackRevisionID uint `json:"stack_revision_id"`
+
+	// The name of the resource
+	Name string `json:"name"`
+
+	// The id for this resource
+	ID string `json:"id"`
+
+	// If this is an app resource, app-specific information for the resource
+	StackAppData *StackResourceAppData `json:"stack_app_data,omitempty"`
+
+	// The source configuration for this stack
+	StackSourceConfig *StackSourceConfig `json:"stack_source_config,omitempty"`
+}
+
+type StackResourceAppData struct {
+	// The URL of the Helm registry to pull the template from
+	TemplateRepoURL string `json:"template_repo_url"`
+
+	// The name of the template in the Helm registry, such as `web`
+	TemplateName string `json:"template_name"`
+
+	// The version of the template in the Helm registry, such as `v0.50.0`
+	TemplateVersion string `json:"template_version"`
+}
+
+type StackRevisionStatus string
+
+const (
+	StackRevisionStatusDeploying StackRevisionStatus = "deploying"
+	StackRevisionStatusFailed    StackRevisionStatus = "failed"
+	StackRevisionStatusDeployed  StackRevisionStatus = "deployed"
+)
+
+type StackRevisionMeta struct {
+	// The time that this revision was created
+	CreatedAt time.Time `json:"created_at"`
+
+	// The id of the revision
+	ID uint `json:"id"`
+
+	// The status of the revision
+	Status StackRevisionStatus `json:"status"`
+
+	// The stack ID that this source config belongs to
+	StackID string `json:"stack_id"`
+}
+
+type StackRevision struct {
+	*StackRevisionMeta
+
+	// The reason for any error or status change
+	Reason string `json:"reason"`
+
+	// The message associated with an error or status change
+	Message string `json:"message"`
+
+	// The list of resources deployed in this revision
+	Resources []StackResource `json:"resources"`
+
+	SourceConfigs []StackSourceConfig `json:"source_configs"`
+}
+
+type StackSourceConfig struct {
+	// The time that the source configuration was initially created
+	CreatedAt time.Time `json:"created_at"`
+
+	// The time that the source configuration was last updated
+	UpdatedAt time.Time `json:"updated_at"`
+
+	// The stack ID that this source config belongs to
+	StackID string `json:"stack_id"`
+
+	// The numerical revision id that this source config belongs to
+	StackRevisionID uint `json:"stack_revision_id"`
+
+	// The display name of the stack source
+	Name string `json:"name"`
+
+	// The unique id of the stack source config
+	ID string `json:"id"`
+
+	// The complete image repo uri used by the source
+	ImageRepoURI string `json:"image_repo_uri"`
+
+	// The current image tag used by the application
+	ImageTag string `json:"image_tag"`
+
+	// If this field is empty, the resource is deployed directly from the image repo uri
+	StackSourceConfigBuild *StackSourceConfigBuild `json:"build,omitempty"`
+}
+
+// swagger:model
+type CreateStackSourceConfigRequest struct {
+	// required: true
+	Name string `json:"name"`
+
+	// required: true
+	ImageRepoURI string `json:"image_repo_uri"`
+
+	// required: true
+	ImageTag string `json:"image_tag"`
+
+	// If this field is empty, the resource is deployed directly from the image repo uri
+	StackSourceConfigBuild *StackSourceConfigBuild `json:"build,omitempty"`
+}
+
+// swagger:model
+type UpdateStackSourceConfigRequest struct {
+	// required: true
+	Name string `json:"name"`
+
+	// required: true
+	ImageRepoURI string `json:"image_repo_uri"`
+
+	// required: true
+	ImageTag string `json:"image_tag"`
+}
+
+type StackSourceConfigBuild struct {
+	// The build method to use: can be `docker` (for dockerfiles), or `pack` (for buildpacks)
+	// required: true
+	Method string `json:"method" form:"required"`
+
+	// The path to the build context (the root folder of the application). For example, `.` or `./app`
+	// required: true
+	FolderPath string `json:"folder_path" form:"required"`
+
+	// The remote Git configuration to use. If not passed in, this application will not appear to be linked to a
+	// remote Git repository.
+	StackSourceConfigBuildGit *StackSourceConfigBuildGit `json:"git,omitempty"`
+
+	// The Dockerfile build configuration, if `method` is `docker`
+	StackSourceConfigBuildDockerfile *StackSourceConfigBuildDockerfile `json:"dockerfile,omitempty"`
+
+	// The buildpack configuration, if method is `pack`
+	StackSourceConfigBuildPack *StackSourceConfigBuildPack `json:"buildpack,omitempty"`
+}
+
+type StackSourceConfigBuildGit struct {
+	// The git integration kind: can be `github` or `gitlab`
+	GitIntegrationKind string `json:"git_integration_kind"`
+
+	// The integration id of the github or gitlab integration
+	GitIntegrationID uint `json:"git_integration_id"`
+
+	// The git repo in ${owner}/${repo} form
+	GitRepo string `json:"git_repo"`
+
+	// The git branch to use
+	GitBranch string `json:"git_branch"`
+}
+
+type StackSourceConfigBuildDockerfile struct {
+	// The path to the dockerfile from the root directory. Defaults to `./Dockerfile`.
+	DockerfilePath string `json:"dockerfile_path" form:"required"`
+}
+
+type StackSourceConfigBuildPack struct {
+	// The buildpack builder to use
+	// required: true
+	Builder string `json:"builder" form:"required"`
+
+	// A list of buildpacks to use
+	Buildpacks []string `json:"buildpacks"`
+}

+ 2 - 0
internal/models/project.go

@@ -61,6 +61,7 @@ type Project struct {
 	PreviewEnvsEnabled  bool
 	RDSDatabasesEnabled bool
 	ManagedInfraEnabled bool
+	StacksEnabled       bool
 	APITokensEnabled    bool
 }
 
@@ -79,6 +80,7 @@ func (p *Project) ToProjectType() *types.Project {
 		PreviewEnvsEnabled:  p.PreviewEnvsEnabled,
 		RDSDatabasesEnabled: p.RDSDatabasesEnabled,
 		ManagedInfraEnabled: p.ManagedInfraEnabled,
+		StacksEnabled:       p.StacksEnabled,
 		APITokensEnabled:    p.APITokensEnabled,
 	}
 }

+ 3 - 0
internal/models/release.go

@@ -17,6 +17,8 @@ type Release struct {
 	Name         string `json:"name"`
 	Namespace    string `json:"namespace"`
 
+	StackResourceID uint
+
 	// The complete image repository uri to pull from. This is also stored in GitActionConfig,
 	// but this should be used for the source of truth going forward.
 	ImageRepoURI string `json:"image_repo_uri,omitempty"`
@@ -33,6 +35,7 @@ func (r *Release) ToReleaseType() *types.PorterRelease {
 		ID:           r.ID,
 		WebhookToken: r.WebhookToken,
 		ImageRepoURI: r.ImageRepoURI,
+		IsStack:      r.StackResourceID != 0,
 	}
 
 	if r.GitActionConfig != nil {

+ 177 - 0
internal/models/stack.go

@@ -0,0 +1,177 @@
+package models
+
+import (
+	"github.com/porter-dev/porter/api/types"
+	"gorm.io/gorm"
+)
+
+// Stack represents the metadata for a stack on Porter
+type Stack struct {
+	gorm.Model
+
+	ProjectID uint
+
+	ClusterID uint
+
+	Namespace string
+
+	Name string
+
+	UID string `gorm:"unique"`
+
+	Revisions []StackRevision
+}
+
+func (s *Stack) ToStackType() *types.Stack {
+	revisions := []types.StackRevisionMeta{}
+
+	for _, rev := range s.Revisions {
+		revisions = append(revisions, rev.ToStackRevisionMetaType(s.UID))
+	}
+
+	var latestRevision *types.StackRevision
+
+	if len(s.Revisions) > 0 {
+		latestRevision = s.Revisions[0].ToStackRevisionType(s.UID)
+	}
+
+	return &types.Stack{
+		CreatedAt:      s.CreatedAt,
+		UpdatedAt:      s.UpdatedAt,
+		Name:           s.Name,
+		ID:             s.UID,
+		LatestRevision: latestRevision,
+		Revisions:      revisions,
+	}
+}
+
+// StackRevision represents the revision information for the stack
+type StackRevision struct {
+	gorm.Model
+
+	RevisionNumber uint
+
+	StackID uint
+
+	Status string
+
+	Reason  string
+	Message string
+
+	Resources []StackResource
+
+	SourceConfigs []StackSourceConfig
+}
+
+func (s StackRevision) ToStackRevisionMetaType(stackID string) types.StackRevisionMeta {
+	return types.StackRevisionMeta{
+		CreatedAt: s.CreatedAt,
+		ID:        s.RevisionNumber,
+		Status:    types.StackRevisionStatus(s.Status),
+		StackID:   stackID,
+	}
+}
+
+func (s StackRevision) ToStackRevisionType(stackID string) *types.StackRevision {
+	metaType := s.ToStackRevisionMetaType(stackID)
+
+	sourceConfigs := make([]types.StackSourceConfig, 0)
+
+	for _, sourceConfig := range s.SourceConfigs {
+		sourceConfigs = append(sourceConfigs, *sourceConfig.ToStackSourceConfigType(stackID, s.RevisionNumber))
+	}
+
+	resources := make([]types.StackResource, 0)
+
+	for _, stackResource := range s.Resources {
+		resources = append(resources, *stackResource.ToStackResource(stackID, s.RevisionNumber, s.SourceConfigs))
+	}
+
+	return &types.StackRevision{
+		StackRevisionMeta: &metaType,
+		SourceConfigs:     sourceConfigs,
+		Resources:         resources,
+		Reason:            s.Reason,
+		Message:           s.Message,
+	}
+}
+
+type StackResource struct {
+	gorm.Model
+
+	Name string
+
+	UID string
+
+	StackRevisionID uint
+
+	StackSourceConfigUID string
+
+	HelmRevisionID uint
+
+	Values []byte
+
+	TemplateRepoURL string
+
+	TemplateName string
+
+	TemplateVersion string
+}
+
+func (s StackResource) ToStackResource(stackID string, stackRevisionID uint, sourceConfigs []StackSourceConfig) *types.StackResource {
+	// find the relevant source config
+	var linkedSourceConfig StackSourceConfig
+
+	for _, sourceConfig := range sourceConfigs {
+		if sourceConfig.UID == s.StackSourceConfigUID {
+			linkedSourceConfig = sourceConfig
+			break
+		}
+	}
+
+	return &types.StackResource{
+		CreatedAt:         s.CreatedAt,
+		UpdatedAt:         s.UpdatedAt,
+		Name:              s.Name,
+		ID:                s.UID,
+		StackSourceConfig: linkedSourceConfig.ToStackSourceConfigType(stackID, stackRevisionID),
+		StackID:           stackID,
+		// Note that `StackRevisionID` on the API refers to the numerical auto-incremented revision ID, not
+		// the stack_revision_id in the database.
+		StackRevisionID: stackRevisionID,
+		StackAppData: &types.StackResourceAppData{
+			TemplateRepoURL: s.TemplateRepoURL,
+			TemplateName:    s.TemplateName,
+			TemplateVersion: s.TemplateVersion,
+		},
+	}
+}
+
+type StackSourceConfig struct {
+	gorm.Model
+
+	StackRevisionID uint
+
+	Name string
+
+	UID string
+
+	ImageRepoURI string
+
+	ImageTag string
+
+	// TODO: add git-specific information
+}
+
+func (s StackSourceConfig) ToStackSourceConfigType(stackID string, stackRevisionID uint) *types.StackSourceConfig {
+	return &types.StackSourceConfig{
+		CreatedAt:       s.CreatedAt,
+		UpdatedAt:       s.UpdatedAt,
+		StackID:         stackID,
+		StackRevisionID: stackRevisionID,
+		Name:            s.Name,
+		ID:              s.UID,
+		ImageRepoURI:    s.ImageRepoURI,
+		ImageTag:        s.ImageTag,
+	}
+}

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

@@ -51,6 +51,10 @@ func AutoMigrate(db *gorm.DB, debug bool) error {
 		&models.APIToken{},
 		&models.Policy{},
 		&models.Tag{},
+		&models.Stack{},
+		&models.StackRevision{},
+		&models.StackResource{},
+		&models.StackSourceConfig{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

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

@@ -47,6 +47,7 @@ type GormRepository struct {
 	apiToken                  repository.APITokenRepository
 	policy                    repository.PolicyRepository
 	tag                       repository.TagRepository
+	stack                     repository.StackRepository
 }
 
 func (t *GormRepository) User() repository.UserRepository {
@@ -209,6 +210,10 @@ func (t *GormRepository) Tag() repository.TagRepository {
 	return t.tag
 }
 
+func (t *GormRepository) Stack() repository.StackRepository {
+	return t.stack
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.CredentialStorage) repository.Repository {
@@ -253,5 +258,6 @@ func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.Creden
 		apiToken:                  NewAPITokenRepository(db),
 		policy:                    NewPolicyRepository(db),
 		tag:                       NewTagRepository(db),
+		stack:                     NewStackRepository(db),
 	}
 }

+ 167 - 0
internal/repository/gorm/stack.go

@@ -0,0 +1,167 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// StackRepository uses gorm.DB for querying the database
+type StackRepository struct {
+	db *gorm.DB
+}
+
+// NewStackRepository returns a StackRepository which uses
+// gorm.DB for querying the database
+func NewStackRepository(db *gorm.DB) repository.StackRepository {
+	return &StackRepository{db}
+}
+
+// CreateStack creates a new stack
+func (repo *StackRepository) CreateStack(stack *models.Stack) (*models.Stack, error) {
+	if err := repo.db.Create(stack).Error; err != nil {
+		return nil, err
+	}
+
+	return stack, nil
+}
+
+// ReadStack gets a stack specified by its string id
+func (repo *StackRepository) ListStacks(projectID, clusterID uint, namespace string) ([]*models.Stack, error) {
+	stacks := make([]*models.Stack, 0)
+	query := repo.db.Where("stacks.project_id = ? AND stacks.cluster_id = ?", projectID, clusterID)
+
+	if namespace != "" {
+		query = query.Where("stacks.namespace = ?", namespace)
+	}
+
+	// get stack IDs
+	if err := query.Find(&stacks).Error; err != nil {
+		return nil, err
+	}
+
+	stackIDs := make([]uint, 0)
+
+	for _, initStack := range stacks {
+		stackIDs = append(stackIDs, initStack.ID)
+	}
+
+	// query for each stack's revision
+	revisions := make([]*models.StackRevision, 0)
+
+	if err := repo.db.Preload("SourceConfigs").Preload("Resources").Where("stack_revisions.stack_id IN (?)", stackIDs).Where(`
+	stack_revisions.id IN (
+	  SELECT s2.id FROM (SELECT MAX(stack_revisions.id) id FROM stack_revisions WHERE stack_revisions.stack_id IN (?) GROUP BY stack_revisions.stack_id) s2
+	)
+  `, stackIDs).Find(&revisions).Error; err != nil {
+		return nil, err
+	}
+
+	// insert revisions into a map
+	stackIDToRevisionMap := make(map[uint]models.StackRevision)
+
+	for _, revision := range revisions {
+		stackIDToRevisionMap[revision.StackID] = *revision
+	}
+
+	// look up each revision for each stack
+	for _, stack := range stacks {
+		if _, exists := stackIDToRevisionMap[stack.ID]; exists {
+			stack.Revisions = append(stack.Revisions, stackIDToRevisionMap[stack.ID])
+		}
+	}
+
+	return stacks, nil
+}
+
+// ReadStack gets a stack specified by its string id
+func (repo *StackRepository) ReadStackByStringID(projectID uint, stackID string) (*models.Stack, error) {
+	stack := &models.Stack{}
+
+	if err := repo.db.
+		Preload("Revisions", func(db *gorm.DB) *gorm.DB {
+			return db.Order("stack_revisions.revision_number DESC").Limit(100)
+		}).
+		Preload("Revisions.Resources").
+		Preload("Revisions.SourceConfigs").
+		Where("stacks.project_id = ? AND stacks.uid = ?", projectID, stackID).First(&stack).Error; err != nil {
+		return nil, err
+	}
+
+	return stack, nil
+}
+
+// DeleteStack creates a new stack
+func (repo *StackRepository) DeleteStack(stack *models.Stack) (*models.Stack, error) {
+	if err := repo.db.Delete(stack).Error; err != nil {
+		return nil, err
+	}
+
+	return stack, nil
+}
+
+func (repo *StackRepository) UpdateStackRevision(revision *models.StackRevision) (*models.StackRevision, error) {
+	if err := repo.db.Save(revision).Error; err != nil {
+		return nil, err
+	}
+
+	return revision, nil
+}
+
+func (repo *StackRepository) ReadStackRevision(stackRevisionID uint) (*models.StackRevision, error) {
+	revision := &models.StackRevision{}
+
+	if err := repo.db.Preload("Resources").Preload("SourceConfigs").Where("id = ?", stackRevisionID).First(&revision).Error; err != nil {
+		return nil, err
+	}
+
+	return revision, nil
+}
+
+func (repo *StackRepository) ReadStackRevisionByNumber(stackID uint, revisionNumber uint) (*models.StackRevision, error) {
+	revision := &models.StackRevision{}
+
+	if err := repo.db.Preload("Resources").Preload("SourceConfigs").Where("stack_id = ? AND revision_number = ?", stackID, revisionNumber).First(&revision).Error; err != nil {
+		return nil, err
+	}
+
+	return revision, nil
+}
+
+func (repo *StackRepository) AppendNewRevision(revision *models.StackRevision) (*models.StackRevision, error) {
+	stack := &models.Stack{}
+
+	if err := repo.db.Where("id = ?", revision.StackID).First(&stack).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&stack).Association("Revisions")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(revision); err != nil {
+		return nil, err
+	}
+
+	return revision, nil
+}
+
+func (repo *StackRepository) ReadStackResource(resourceID uint) (*models.StackResource, error) {
+	resource := &models.StackResource{}
+
+	if err := repo.db.Where("id = ?", resourceID).First(&resource).Error; err != nil {
+		return nil, err
+	}
+
+	return resource, nil
+}
+
+func (repo *StackRepository) UpdateStackResource(resource *models.StackResource) (*models.StackResource, error) {
+	if err := repo.db.Save(resource).Error; err != nil {
+		return nil, err
+	}
+
+	return resource, nil
+}

+ 1 - 0
internal/repository/repository.go

@@ -41,4 +41,5 @@ type Repository interface {
 	APIToken() APITokenRepository
 	Policy() PolicyRepository
 	Tag() TagRepository
+	Stack() StackRepository
 }

+ 19 - 0
internal/repository/stack.go

@@ -0,0 +1,19 @@
+package repository
+
+import "github.com/porter-dev/porter/internal/models"
+
+// StackRepository represents the set of queries on the Stack model
+type StackRepository interface {
+	CreateStack(stack *models.Stack) (*models.Stack, error)
+	ReadStackByStringID(projectID uint, stackID string) (*models.Stack, error)
+	ListStacks(projectID uint, clusterID uint, namespace string) ([]*models.Stack, error)
+	DeleteStack(stack *models.Stack) (*models.Stack, error)
+
+	UpdateStackRevision(revision *models.StackRevision) (*models.StackRevision, error)
+	ReadStackRevision(stackRevisionID uint) (*models.StackRevision, error)
+	ReadStackRevisionByNumber(stackID uint, revisionNumber uint) (*models.StackRevision, error)
+	AppendNewRevision(revision *models.StackRevision) (*models.StackRevision, error)
+
+	ReadStackResource(resourceID uint) (*models.StackResource, error)
+	UpdateStackResource(resource *models.StackResource) (*models.StackResource, error)
+}

+ 78 - 0
internal/stacks/helpers.go

@@ -0,0 +1,78 @@
+package stacks
+
+import (
+	"fmt"
+
+	"github.com/porter-dev/porter/internal/encryption"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+func CloneSourceConfigs(sourceConfigs []models.StackSourceConfig) ([]models.StackSourceConfig, error) {
+	res := make([]models.StackSourceConfig, 0)
+
+	// for now, only write source configs which are deployed as a docker image
+	// TODO: add parsing/writes for git-based sources
+	for _, sourceConfig := range sourceConfigs {
+		uid, err := encryption.GenerateRandomBytes(16)
+
+		if err != nil {
+			return nil, err
+		}
+
+		res = append(res, models.StackSourceConfig{
+			UID:          uid,
+			Name:         sourceConfig.Name,
+			ImageRepoURI: sourceConfig.ImageRepoURI,
+			ImageTag:     sourceConfig.ImageTag,
+		})
+	}
+
+	return res, nil
+}
+
+func CloneAppResources(
+	appResources []models.StackResource,
+	prevSourceConfigs []models.StackSourceConfig,
+	newSourceConfigs []models.StackSourceConfig,
+) ([]models.StackResource, error) {
+	res := make([]models.StackResource, 0)
+
+	// for now, only write source configs which are deployed as a docker image
+	// TODO: add parsing/writes for git-based sources
+	for _, appResource := range appResources {
+		uid, err := encryption.GenerateRandomBytes(16)
+
+		if err != nil {
+			return nil, err
+		}
+
+		var linkedSourceConfigUID string
+
+		for _, prevSourceConfig := range prevSourceConfigs {
+			if prevSourceConfig.UID == appResource.StackSourceConfigUID {
+				// find the corresponding new source config
+				for _, newSourceConfig := range newSourceConfigs {
+					if newSourceConfig.Name == prevSourceConfig.Name {
+						linkedSourceConfigUID = newSourceConfig.UID
+					}
+				}
+			}
+		}
+
+		if linkedSourceConfigUID == "" {
+			return nil, fmt.Errorf("source config does not exist in source config list")
+		}
+
+		res = append(res, models.StackResource{
+			Name:                 appResource.Name,
+			UID:                  uid,
+			StackSourceConfigUID: linkedSourceConfigUID,
+			TemplateRepoURL:      appResource.TemplateRepoURL,
+			TemplateName:         appResource.TemplateName,
+			TemplateVersion:      appResource.TemplateVersion,
+			HelmRevisionID:       appResource.HelmRevisionID,
+		})
+	}
+
+	return res, nil
+}

+ 60 - 0
internal/stacks/hooks.go

@@ -0,0 +1,60 @@
+package stacks
+
+import (
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"gorm.io/gorm"
+	"helm.sh/helm/v3/pkg/release"
+)
+
+func UpdateHelmRevision(config *config.Config, projID, clusterID uint, rel *release.Release) error {
+	// read release by stack ID
+	relModel, err := config.Repo.Release().ReadRelease(clusterID, rel.Name, rel.Namespace)
+
+	if err != nil {
+		return err
+	}
+
+	if relModel.StackResourceID == 0 {
+		return nil
+	}
+
+	stackResource, err := config.Repo.Stack().ReadStackResource(relModel.StackResourceID)
+
+	if err != nil {
+		return err
+	}
+
+	// read the revision number and create a new revision of the stack
+	stackRevision, err := config.Repo.Stack().ReadStackRevision(stackResource.StackRevisionID)
+
+	if err != nil {
+		return err
+	}
+
+	clonedSourceConfigs, err := CloneSourceConfigs(stackRevision.SourceConfigs)
+
+	if err != nil {
+		return err
+	}
+
+	clonedAppResources, err := CloneAppResources(stackRevision.Resources, stackRevision.SourceConfigs, clonedSourceConfigs)
+
+	if err != nil {
+		return err
+	}
+
+	for i, appResource := range clonedAppResources {
+		if appResource.Name == rel.Name {
+			clonedAppResources[i].HelmRevisionID = uint(rel.Version)
+		}
+	}
+
+	stackRevision.Model = gorm.Model{}
+	stackRevision.RevisionNumber++
+	stackRevision.Resources = clonedAppResources
+	stackRevision.SourceConfigs = clonedSourceConfigs
+
+	_, err = config.Repo.Stack().AppendNewRevision(stackRevision)
+
+	return err
+}