Explorar o código

Merge branch 'nico/por-521-frontend-stacks-work' into dev

Alexander Belanger %!s(int64=4) %!d(string=hai) anos
pai
achega
21405d37fb
Modificáronse 50 ficheiros con 3850 adicións e 14 borrados
  1. 1 0
      .gitignore
  2. 2 0
      api/server/authz/policy.go
  3. 63 0
      api/server/authz/stack.go
  4. 3 1
      api/server/handlers/cluster/create_namespace.go
  5. 2 0
      api/server/handlers/registry/create.go
  6. 204 0
      api/server/handlers/stack/create.go
  7. 63 0
      api/server/handlers/stack/delete.go
  8. 30 0
      api/server/handlers/stack/get.go
  9. 47 0
      api/server/handlers/stack/get_revision.go
  10. 181 0
      api/server/handlers/stack/helpers.go
  11. 46 0
      api/server/handlers/stack/list.go
  12. 130 0
      api/server/handlers/stack/rollback.go
  13. 146 0
      api/server/handlers/stack/update_source_put.go
  14. 8 1
      api/server/router/router.go
  15. 102 0
      api/server/router/v1/cluster.go
  16. 20 0
      api/server/router/v1/namespace.go
  17. 9 0
      api/server/router/v1/project.go
  18. 166 0
      api/server/router/v1/registry.go
  19. 148 0
      api/server/router/v1/release.go
  20. 490 0
      api/server/router/v1/stack.go
  21. 16 1
      api/types/cluster.go
  22. 25 4
      api/types/namespace.go
  23. 2 0
      api/types/policy.go
  24. 11 0
      api/types/registry.go
  25. 4 1
      api/types/release.go
  26. 1 0
      api/types/request.go
  27. 257 0
      api/types/stacks.go
  28. 2 0
      cmd/app/main.go
  29. 1 0
      dashboard/src/main/home/Home.tsx
  30. 12 3
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  31. 53 0
      dashboard/src/main/home/cluster-dashboard/stacks/Dashboard.tsx
  32. 255 0
      dashboard/src/main/home/cluster-dashboard/stacks/_StackList.tsx
  33. 114 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/NewApp.tsx
  34. 485 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/Overview.tsx
  35. 58 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/SelectSource.tsx
  36. 138 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/Store.tsx
  37. 40 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/index.tsx
  38. 24 0
      dashboard/src/main/home/cluster-dashboard/stacks/routes.tsx
  39. 86 0
      dashboard/src/main/home/cluster-dashboard/stacks/types.ts
  40. 11 2
      dashboard/src/main/home/sidebar/Sidebar.tsx
  41. 75 0
      dashboard/src/shared/api.tsx
  42. 3 1
      dashboard/src/shared/routing.tsx
  43. 9 0
      dashboard/src/shared/types.tsx
  44. 172 0
      internal/models/stack.go
  45. 4 0
      internal/repository/gorm/migrate.go
  46. 6 0
      internal/repository/gorm/repository.go
  47. 108 0
      internal/repository/gorm/stack.go
  48. 1 0
      internal/repository/repository.go
  49. 15 0
      internal/repository/stack.go
  50. 1 0
      scripts/build/generate-spec.sh

+ 1 - 0
.gitignore

@@ -14,6 +14,7 @@ staging.sh
 *.crt
 *.crt
 *.key
 *.key
 bin
 bin
+openapi.yaml
 
 
 # Local docs directories
 # Local docs directories
 /docs/.obsidian
 /docs/.obsidian

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

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

+ 3 - 1
api/server/handlers/cluster/create_namespace.go

@@ -52,7 +52,9 @@ func (c *CreateNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	}
 	}
 
 
 	res := types.CreateNamespaceResponse{
 	res := types.CreateNamespaceResponse{
-		Namespace: namespace,
+		Metadata: types.CreateNamespaceResponseMeta{
+			Name: namespace.Name,
+		},
 	}
 	}
 
 
 	w.WriteHeader(http.StatusCreated)
 	w.WriteHeader(http.StatusCreated)

+ 2 - 0
api/server/handlers/registry/create.go

@@ -49,6 +49,8 @@ func (p *RegistryCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		return
 		return
 	}
 	}
 
 
+	//  TODO!!!: validate the credentials here!!!
+
 	// create a registry model
 	// create a registry model
 	regModel := &models.Registry{
 	regModel := &models.Registry{
 		Name:               request.Name,
 		Name:               request.Name,

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

@@ -0,0 +1,204 @@
+package stack
+
+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/encryption"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+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
+	}
+
+	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)
+
+	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
+	}
+
+	for _, appResource := range req.AppResources {
+		err = applyAppResource(&applyAppResourceOpts{
+			config:     p.Config(),
+			projectID:  proj.ID,
+			namespace:  namespace,
+			cluster:    cluster,
+			registries: registries,
+			helmAgent:  helmAgent,
+			request:    appResource,
+		})
+
+		if err != nil {
+			// TODO: mark stack with error
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	// update stack revision status
+	revision := &stack.Revisions[0]
+	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
+	}
+
+	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) ([]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))
+}

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

@@ -0,0 +1,181 @@
+package stack
+
+import (
+	"fmt"
+
+	"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/helm"
+	"github.com/porter-dev/porter/internal/helm/loader"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+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) 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 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,
+	}
+
+	_, err = opts.helmAgent.InstallChart(conf, opts.config.DOConf)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+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 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
+}
+
+// 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)
+}

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

@@ -0,0 +1,130 @@
+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"
+	"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 := cloneSourceConfigs(revision.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	appResources, err := 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
+	for _, resource := range revision.Resources {
+		err := rollbackAppResource(&rollbackAppResourceOpts{
+			helmAgent:      helmAgent,
+			helmRevisionID: resource.HelmRevisionID,
+			name:           resource.Name,
+		})
+
+		if err != nil {
+			// TODO: mark stack with error
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	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())
+}

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

@@ -0,0 +1,146 @@
+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"
+	"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 := 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
+	}
+
+	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 {
+			// TODO: mark stack with error
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		clonedAppResources[i].HelmRevisionID++
+	}
+
+	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()
 		v1RegistryRegisterer := v1.NewV1RegistryScopedRegisterer()
 		v1ReleaseRegisterer := v1.NewV1ReleaseScopedRegisterer()
 		v1ReleaseRegisterer := v1.NewV1ReleaseScopedRegisterer()
-		v1NamespaceRegisterer := v1.NewV1NamespaceScopedRegisterer(v1ReleaseRegisterer)
+		v1StackRegisterer := v1.NewV1StackScopedRegisterer()
+		v1NamespaceRegisterer := v1.NewV1NamespaceScopedRegisterer(v1ReleaseRegisterer, v1StackRegisterer)
 		v1ClusterRegisterer := v1.NewV1ClusterScopedRegisterer(v1NamespaceRegisterer)
 		v1ClusterRegisterer := v1.NewV1ClusterScopedRegisterer(v1NamespaceRegisterer)
 		v1ProjRegisterer := v1.NewV1ProjectScopedRegisterer(
 		v1ProjRegisterer := v1.NewV1ProjectScopedRegisterer(
 			v1ClusterRegisterer,
 			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.
 	// after authorization. Each subsequent http.Handler can lookup the release in context.
 	releaseFactory := authz.NewReleaseScopedFactory(config)
 	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.
 	// Policy doc loader loads the policy documents for a specific project.
 	policyDocLoader := policy.NewBasicPolicyDocumentLoader(config.Repo.Project(), config.Repo.Policy())
 	policyDocLoader := policy.NewBasicPolicyDocumentLoader(config.Repo.Project(), config.Repo.Policy())
 
 
@@ -257,6 +262,8 @@ func registerRoutes(config *config.Config, routes []*router.Route) {
 				atomicGroup.Use(releaseFactory.Middleware)
 				atomicGroup.Use(releaseFactory.Middleware)
 			case types.GitlabIntegrationScope:
 			case types.GitlabIntegrationScope:
 				atomicGroup.Use(gitlabIntFactory.Middleware)
 				atomicGroup.Use(gitlabIntFactory.Middleware)
+			case types.StackScope:
+				atomicGroup.Use(stackFactory.Middleware)
 			}
 			}
 		}
 		}
 
 

+ 102 - 0
api/server/router/v1/cluster.go

@@ -11,6 +11,21 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 )
 )
 
 
+// swagger:parameters createNamespace listNamespaces
+type clusterPathParams 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"`
+}
+
 func NewV1ClusterScopedRegisterer(children ...*router.Registerer) *router.Registerer {
 func NewV1ClusterScopedRegisterer(children ...*router.Registerer) *router.Registerer {
 	return &router.Registerer{
 	return &router.Registerer{
 		GetRoutes: GetV1ClusterScopedRoutes,
 		GetRoutes: GetV1ClusterScopedRoutes,
@@ -56,6 +71,31 @@ func getV1ClusterRoutes(
 	var routes []*router.Route
 	var routes []*router.Route
 
 
 	// POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces -> cluster.NewCreateNamespaceHandler
 	// POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces -> cluster.NewCreateNamespaceHandler
+	// swagger:operation POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces createNamespace
+	//
+	// Creates a new namespace
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Create a new namespace
+	// tags:
+	// - Namespaces
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - in: body
+	//     name: CreateNamespaceRequest
+	//     description: The namespace to create
+	//     schema:
+	//       $ref: '#/definitions/CreateNamespaceRequest'
+	// responses:
+	//   '201':
+	//     description: Successfully created a new namespace
+	//     schema:
+	//       $ref: '#/definitions/CreateNamespaceResponse'
+	//   '403':
+	//     description: Forbidden
 	createNamespaceEndpoint := factory.NewAPIEndpoint(
 	createNamespaceEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
 			Verb:   types.APIVerbCreate,
@@ -85,6 +125,29 @@ func getV1ClusterRoutes(
 	})
 	})
 
 
 	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace} -> cluster.NewGetNamespaceHandler
 	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace} -> cluster.NewGetNamespaceHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace} getNamespace
+	//
+	// Gets a namespace
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Get a namespace
+	// tags:
+	// - Namespaces
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	// responses:
+	//   '200':
+	//     description: Successfully got the namespace
+	//     schema:
+	//       $ref: '#/definitions/GetNamespaceResponse'
+	//   '403':
+	//     description: Forbidden
+	//   '404':
+	//     description: Not Found
 	getNamespaceEndpoint := factory.NewAPIEndpoint(
 	getNamespaceEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Verb:   types.APIVerbGet,
@@ -113,6 +176,26 @@ func getV1ClusterRoutes(
 	})
 	})
 
 
 	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces -> cluster.NewListNamespacesHandler
 	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces -> cluster.NewListNamespacesHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces listNamespaces
+	//
+	// Lists namespaces
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: List all namespaces
+	// tags:
+	// - Namespaces
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	// responses:
+	//   '200':
+	//     description: Successfully listed namespaces
+	//     schema:
+	//       $ref: '#/definitions/ListNamespacesResponse'
+	//   '403':
+	//     description: Forbidden
 	listNamespacesEndpoint := factory.NewAPIEndpoint(
 	listNamespacesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Verb:   types.APIVerbGet,
@@ -141,6 +224,25 @@ func getV1ClusterRoutes(
 	})
 	})
 
 
 	// DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace} -> cluster.NewDeleteNamespaceHandler
 	// DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace} -> cluster.NewDeleteNamespaceHandler
+	// swagger:operation DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace} deleteNamespace
+	//
+	// Deletes a namespace
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Delete a namespace
+	// tags:
+	// - Namespaces
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	// responses:
+	//   '200':
+	//     description: Successfully deleted namespace
+	//   '403':
+	//     description: Forbidden
 	deleteNamespaceEndpoint := factory.NewAPIEndpoint(
 	deleteNamespaceEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbDelete,
 			Verb:   types.APIVerbDelete,

+ 20 - 0
api/server/router/v1/namespace.go

@@ -8,6 +8,26 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 )
 )
 
 
+// swagger:parameters getNamespace deleteNamespace createRelease createStack listStacks
+type namespacePathParams 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 name
+	// in: path
+	// required: true
+	Namespace string `json:"namespace"`
+}
+
 func NewV1NamespaceScopedRegisterer(children ...*router.Registerer) *router.Registerer {
 func NewV1NamespaceScopedRegisterer(children ...*router.Registerer) *router.Registerer {
 	return &router.Registerer{
 	return &router.Registerer{
 		GetRoutes: GetV1NamespaceScopedRoutes,
 		GetRoutes: GetV1NamespaceScopedRoutes,

+ 9 - 0
api/server/router/v1/project.go

@@ -8,6 +8,15 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 )
 )
 
 
+// swagger:parameters createRegistry listRegistries
+type projectPathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+}
+
 func NewV1ProjectScopedRegisterer(children ...*router.Registerer) *router.Registerer {
 func NewV1ProjectScopedRegisterer(children ...*router.Registerer) *router.Registerer {
 	return &router.Registerer{
 	return &router.Registerer{
 		GetRoutes: GetV1ProjectScopedRoutes,
 		GetRoutes: GetV1ProjectScopedRoutes,

+ 166 - 0
api/server/router/v1/registry.go

@@ -11,6 +11,21 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 )
 )
 
 
+// swagger:parameters getRegistry deleteRegistry createRegistryRepository listRegistryRepositories listRegistryImages
+type registryPathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+
+	// The registry id
+	// in: path
+	// required: true
+	// minimum: 1
+	RegistryID uint `json:"registry_id"`
+}
+
 func NewV1RegistryScopedRegisterer(children ...*router.Registerer) *router.Registerer {
 func NewV1RegistryScopedRegisterer(children ...*router.Registerer) *router.Registerer {
 	return &router.Registerer{
 	return &router.Registerer{
 		GetRoutes: GetV1RegistryScopedRoutes,
 		GetRoutes: GetV1RegistryScopedRoutes,
@@ -56,6 +71,30 @@ func getV1RegistryRoutes(
 	var routes []*router.Route
 	var routes []*router.Route
 
 
 	// POST /api/v1/projects/{project_id}/registries -> registry.NewRegistryCreateHandler
 	// POST /api/v1/projects/{project_id}/registries -> registry.NewRegistryCreateHandler
+	// swagger:operation POST /api/v1/projects/{project_id}/registries createRegistry
+	//
+	// Connects a new image registry
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Connect an image registry
+	// tags:
+	// - Registries
+	// parameters:
+	//   - name: project_id
+	//   - in: body
+	//     name: CreateRegistryRequest
+	//     description: The registry to connect
+	//     schema:
+	//       $ref: '#/definitions/CreateRegistryRequest'
+	// responses:
+	//   '201':
+	//     description: Successfully connected the registry
+	//     schema:
+	//       $ref: '#/definitions/CreateRegistryResponse'
+	//   '403':
+	//     description: Forbidden
 	createRegistryEndpoint := factory.NewAPIEndpoint(
 	createRegistryEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
 			Verb:   types.APIVerbCreate,
@@ -84,6 +123,26 @@ func getV1RegistryRoutes(
 	})
 	})
 
 
 	// GET /api/v1/projects/{project_id}/registries/{registry_id} -> registry.NewRegistryGetHandler
 	// GET /api/v1/projects/{project_id}/registries/{registry_id} -> registry.NewRegistryGetHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/registries/{registry_id} getRegistry
+	//
+	// Gets an image registry
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Get an image registry
+	// tags:
+	// - Registries
+	// parameters:
+	//   - name: project_id
+	//   - name: registry_id
+	// responses:
+	//   '201':
+	//     description: Successfully got the registry
+	//     schema:
+	//       $ref: '#/definitions/GetRegistryResponse'
+	//   '403':
+	//     description: Forbidden
 	getEndpoint := factory.NewAPIEndpoint(
 	getEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Verb:   types.APIVerbGet,
@@ -112,6 +171,26 @@ func getV1RegistryRoutes(
 	})
 	})
 
 
 	// GET /api/v1/projects/{project_id}/registries -> registry.NewRegistryListHandler
 	// GET /api/v1/projects/{project_id}/registries -> registry.NewRegistryListHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/registries listRegistries
+	//
+	// Lists registries
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: List image registries
+	// tags:
+	// - Registries
+	// parameters:
+	//   - name: project_id
+	//   - name: registry_id
+	// responses:
+	//   '200':
+	//     description: Successfully listed registries
+	//     schema:
+	//       $ref: '#/definitions/ListRegistriesResponse'
+	//   '403':
+	//     description: Forbidden
 	listRegistriesEndpoint := factory.NewAPIEndpoint(
 	listRegistriesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbList,
 			Verb:   types.APIVerbList,
@@ -139,6 +218,24 @@ func getV1RegistryRoutes(
 	})
 	})
 
 
 	// DELETE /api/v1/projects/{project_id}/registries/{registry_id} -> registry.NewRegistryDeleteHandler
 	// DELETE /api/v1/projects/{project_id}/registries/{registry_id} -> registry.NewRegistryDeleteHandler
+	// swagger:operation DELETE /api/v1/projects/{project_id}/registries/{registry_id} deleteRegistry
+	//
+	// Deletes an image registry.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Disconnect image registry
+	// tags:
+	// - Registries
+	// parameters:
+	//   - name: project_id
+	//   - name: registry_id
+	// responses:
+	//   '200':
+	//     description: Successfully disconnected image registry
+	//   '403':
+	//     description: Forbidden
 	deleteEndpoint := factory.NewAPIEndpoint(
 	deleteEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbDelete,
 			Verb:   types.APIVerbDelete,
@@ -167,6 +264,30 @@ func getV1RegistryRoutes(
 	})
 	})
 
 
 	// POST /api/v1/projects/{project_id}/registries/{registry_id}/repositories -> registry.NewRegistryCreateRepositoryHandler
 	// POST /api/v1/projects/{project_id}/registries/{registry_id}/repositories -> registry.NewRegistryCreateRepositoryHandler
+	// swagger:operation POST /api/v1/projects/{project_id}/registries/{registry_id}/repositories createRegistryRepository
+	//
+	// Creates an image repository inside the registry specified by `registry_id`. This method **only** creates repositories for ECR-integrated
+	// repositories.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Create image repository
+	// tags:
+	// - Registries
+	// parameters:
+	//   - name: project_id
+	//   - name: registry_id
+	//   - in: body
+	//     name: CreateRepositoryRequest
+	//     description: The repository to create
+	//     schema:
+	//       $ref: '#/definitions/CreateRegistryRepositoryRequest'
+	// responses:
+	//   '201':
+	//     description: Successfully created the image repository
+	//   '403':
+	//     description: Forbidden
 	createRepositoryEndpoint := factory.NewAPIEndpoint(
 	createRepositoryEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
 			Verb:   types.APIVerbCreate,
@@ -196,6 +317,26 @@ func getV1RegistryRoutes(
 	})
 	})
 
 
 	// GET /api/v1/projects/{project_id}/registries/{registry_id}/repositories -> registry.NewRegistryListRepositoriesHandler
 	// GET /api/v1/projects/{project_id}/registries/{registry_id}/repositories -> registry.NewRegistryListRepositoriesHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/registries/{registry_id}/repositories listRegistryRepositories
+	//
+	// Lists image repositories inside the image registry given by `registry_id`
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: List image repositories
+	// tags:
+	// - Registries
+	// parameters:
+	//   - name: project_id
+	//   - name: registry_id
+	// responses:
+	//   '200':
+	//     description: Successfully listed image repositories
+	//     schema:
+	//       $ref: '#/definitions/ListRegistryRepositoriesResponse'
+	//   '403':
+	//     description: Forbidden
 	listRepositoriesEndpoint := factory.NewAPIEndpoint(
 	listRepositoriesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbList,
 			Verb:   types.APIVerbList,
@@ -224,6 +365,31 @@ func getV1RegistryRoutes(
 	})
 	})
 
 
 	// GET /api/v1/projects/{project_id}/registries/{registry_id}/repositories/* -> registry.NewRegistryListImagesHandler
 	// GET /api/v1/projects/{project_id}/registries/{registry_id}/repositories/* -> registry.NewRegistryListImagesHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/registries/{registry_id}/repositories/{repository} listRegistryImages
+	//
+	// Lists all images in an image repository.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: List images
+	// tags:
+	// - Registries
+	// parameters:
+	//   - name: project_id
+	//   - name: registry_id
+	//   - name: repository
+	//     in: path
+	//     description: the image repository name
+	//     type: string
+	//     required: true
+	// responses:
+	//   '200':
+	//     description: Successfully listed images
+	//     schema:
+	//       $ref: '#/definitions/ListImagesResponse'
+	//   '403':
+	//     description: Forbidden
 	listImagesEndpoint := factory.NewAPIEndpoint(
 	listImagesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbList,
 			Verb:   types.APIVerbList,

+ 148 - 0
api/server/router/v1/release.go

@@ -10,6 +10,43 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 )
 )
 
 
+// swagger:parameters getRelease updateRelease deleteRelease
+type releasePathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+
+	// The registry id
+	// in: path
+	// required: true
+	// minimum: 1
+	RegistryID uint `json:"registry_id"`
+
+	// The namespace name
+	// in: path
+	// required: true
+	Namespace string `json:"namespace"`
+
+	// The release name
+	// in: path
+	// required: true
+	Name string `json:"name"`
+
+	// The release version (`0` for latest version)
+	// in: path
+	// required: true
+	// minimum: 0
+	Version uint `json:"version"`
+}
+
+// swagger:parameters listReleases
+type listReleasesRequest struct {
+	*namespacePathParams
+	*types.ListReleasesRequest
+}
+
 func NewV1ReleaseScopedRegisterer(children ...*router.Registerer) *router.Registerer {
 func NewV1ReleaseScopedRegisterer(children ...*router.Registerer) *router.Registerer {
 	return &router.Registerer{
 	return &router.Registerer{
 		GetRoutes: GetV1ReleaseScopedRoutes,
 		GetRoutes: GetV1ReleaseScopedRoutes,
@@ -55,6 +92,30 @@ func getV1ReleaseRoutes(
 	var routes []*router.Route
 	var routes []*router.Route
 
 
 	// POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases -> release.NewCreateReleaseHandler
 	// POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases -> release.NewCreateReleaseHandler
+	// swagger:operation POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases createRelease
+	//
+	// Creates a new release
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Create a new release
+	// tags:
+	// - Releases
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - in: body
+	//     name: CreateReleaseRequest
+	//     description: The release to create
+	//     schema:
+	//       $ref: '#/definitions/CreateReleaseRequest'
+	// responses:
+	//   '201':
+	//     description: Successfully created the release
+	//   '403':
+	//     description: Forbidden
 	createReleaseEndpoint := factory.NewAPIEndpoint(
 	createReleaseEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
 			Verb:   types.APIVerbCreate,
@@ -85,6 +146,29 @@ func getV1ReleaseRoutes(
 	})
 	})
 
 
 	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} -> release.NewReleaseGetHandler
 	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} -> release.NewReleaseGetHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} getRelease
+	//
+	// Gets a release
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Get a release
+	// tags:
+	// - Releases
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: name
+	//   - name: version
+	// responses:
+	//   '201':
+	//     description: Successfully got the release
+	//     schema:
+	//       $ref: '#/definitions/GetReleaseResponse'
+	//   '403':
+	//     description: Forbidden
 	getEndpoint := factory.NewAPIEndpoint(
 	getEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Verb:   types.APIVerbGet,
@@ -115,6 +199,23 @@ func getV1ReleaseRoutes(
 	})
 	})
 
 
 	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases -> namespace.NewListReleasesHandler
 	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases -> namespace.NewListReleasesHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases listReleases
+	//
+	// List releases
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: List releases
+	// tags:
+	// - Releases
+	// responses:
+	//   '201':
+	//     description: Successfully listed releases
+	//     schema:
+	//       $ref: '#/definitions/ListReleasesResponse'
+	//   '403':
+	//     description: Forbidden
 	listReleasesEndpoint := factory.NewAPIEndpoint(
 	listReleasesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
 			Verb:   types.APIVerbGet,
@@ -146,6 +247,32 @@ func getV1ReleaseRoutes(
 
 
 	// PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} ->
 	// PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} ->
 	// release.NewUpgradeReleaseHandler
 	// release.NewUpgradeReleaseHandler
+	// swagger:operation PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} updateRelease
+	//
+	// Updates a release
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Update a release
+	// tags:
+	// - Releases
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: name
+	//   - name: version
+	//   - in: body
+	//     name: UpdateReleaseRequest
+	//     description: The release to update
+	//     schema:
+	//       $ref: '#/definitions/UpdateReleaseRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully updated the release
+	//   '403':
+	//     description: Forbidden
 	upgradeEndpoint := factory.NewAPIEndpoint(
 	upgradeEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbUpdate,
 			Verb:   types.APIVerbUpdate,
@@ -178,6 +305,27 @@ func getV1ReleaseRoutes(
 
 
 	// DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} ->
 	// DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} ->
 	// release.NewDeleteReleaseHandler
 	// release.NewDeleteReleaseHandler
+	// swagger:operation DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} deleteRelease
+	//
+	// Deletes a release
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Delete a release
+	// tags:
+	// - Releases
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: name
+	//   - name: version
+	// responses:
+	//   '200':
+	//     description: Successfully deleted the release
+	//   '403':
+	//     description: Forbidden
 	deleteEndpoint := factory.NewAPIEndpoint(
 	deleteEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbDelete,
 			Verb:   types.APIVerbDelete,

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

+ 16 - 1
api/types/cluster.go

@@ -185,18 +185,33 @@ const (
 	AWSData          ClusterResolverName = "upload-aws-data"
 	AWSData          ClusterResolverName = "upload-aws-data"
 )
 )
 
 
+// swagger:model
 type ListNamespacesResponse struct {
 type ListNamespacesResponse struct {
 	*v1.NamespaceList
 	*v1.NamespaceList
 }
 }
 
 
+// swagger:model
 type CreateNamespaceRequest struct {
 type CreateNamespaceRequest struct {
 	Name string `json:"name" form:"required"`
 	Name string `json:"name" form:"required"`
 }
 }
 
 
+type CreateNamespaceResponseMeta struct {
+	Name string `json:"name,omitempty"`
+}
+
+// swagger:model
 type CreateNamespaceResponse struct {
 type CreateNamespaceResponse struct {
-	*v1.Namespace
+	Metadata CreateNamespaceResponseMeta `json:"metadata,omitempty"`
+}
+
+// swagger:model
+type GetNamespaceResponse struct {
+	Metadata struct {
+		Name string `json:"name,omitempty"`
+	} `json:"metadata,omitempty"`
 }
 }
 
 
+// swagger:model
 type DeleteNamespaceRequest struct {
 type DeleteNamespaceRequest struct {
 	Name string `json:"name" form:"required"`
 	Name string `json:"name" form:"required"`
 }
 }

+ 25 - 4
api/types/namespace.go

@@ -16,10 +16,30 @@ const (
 // ReleaseListFilter is a struct that represents the various filter options used for
 // ReleaseListFilter is a struct that represents the various filter options used for
 // retrieving the releases
 // retrieving the releases
 type ReleaseListFilter struct {
 type ReleaseListFilter struct {
-	Namespace    string   `json:"namespace"`
-	Limit        int      `json:"limit"`
-	Skip         int      `json:"skip"`
-	ByDate       bool     `json:"byDate"`
+	// swagger:ignore
+	Namespace string `json:"namespace"`
+
+	// the pagination limit
+	//
+	// in: query
+	// example: 50
+	Limit int `json:"limit"`
+
+	// how many items to skip
+	//
+	// in: query
+	// example: 10
+	Skip int `json:"skip"`
+
+	// whether to sort by date
+	//
+	// in: query
+	// example: false
+	ByDate bool `json:"byDate"`
+
+	// which helm statuses to filter by
+	//
+	// in: query
 	StatusFilter []string `json:"statusFilter"`
 	StatusFilter []string `json:"statusFilter"`
 }
 }
 
 
@@ -60,6 +80,7 @@ type ListReleasesRequest struct {
 	*ReleaseListFilter
 	*ReleaseListFilter
 }
 }
 
 
+// swagger:model
 type ListReleasesResponse []*release.Release
 type ListReleasesResponse []*release.Release
 
 
 type GetConfigMapRequest struct {
 type GetConfigMapRequest struct {

+ 2 - 0
api/types/policy.go

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

+ 11 - 0
api/types/registry.go

@@ -81,8 +81,10 @@ const (
 	DockerHub RegistryService = "dockerhub"
 	DockerHub RegistryService = "dockerhub"
 )
 )
 
 
+// swagger:model ListRegistriesResponse
 type RegistryListResponse []Registry
 type RegistryListResponse []Registry
 
 
+// swagger:model
 type CreateRegistryRequest struct {
 type CreateRegistryRequest struct {
 	URL                string `json:"url"`
 	URL                string `json:"url"`
 	Name               string `json:"name" form:"required"`
 	Name               string `json:"name" form:"required"`
@@ -97,6 +99,13 @@ type CreateRegistryRequest struct {
 	ACRName              string `json:"acr_name"`
 	ACRName              string `json:"acr_name"`
 }
 }
 
 
+// swagger:model
+type CreateRegistryResponse Registry
+
+// swagger:model
+type GetRegistryResponse Registry
+
+// swagger:model
 type CreateRegistryRepositoryRequest struct {
 type CreateRegistryRepositoryRequest struct {
 	ImageRepoURI string `json:"image_repo_uri" form:"required"`
 	ImageRepoURI string `json:"image_repo_uri" form:"required"`
 }
 }
@@ -125,6 +134,8 @@ type GetRegistryDOCRTokenRequest struct {
 	ServerURL string `schema:"server_url"`
 	ServerURL string `schema:"server_url"`
 }
 }
 
 
+// swagger:model ListRegistryRepositoriesResponse
 type ListRegistryRepositoryResponse []*RegistryRepository
 type ListRegistryRepositoryResponse []*RegistryRepository
 
 
+// swagger:model ListImagesResponse
 type ListImageResponse []*Image
 type ListImageResponse []*Image

+ 4 - 1
api/types/release.go

@@ -24,6 +24,7 @@ type PorterRelease struct {
 	Tags            []string         `json:"tags,omitempty"`
 	Tags            []string         `json:"tags,omitempty"`
 }
 }
 
 
+// swagger:model
 type GetReleaseResponse Release
 type GetReleaseResponse Release
 
 
 type UpdateNotificationConfigRequest struct {
 type UpdateNotificationConfigRequest struct {
@@ -35,13 +36,14 @@ type UpdateNotificationConfigRequest struct {
 }
 }
 
 
 type CreateReleaseBaseRequest struct {
 type CreateReleaseBaseRequest struct {
-	RepoURL         string                 `schema:"repo_url"`
+	RepoURL         string                 `json:"-" schema:"repo_url"`
 	TemplateName    string                 `json:"template_name" form:"required"`
 	TemplateName    string                 `json:"template_name" form:"required"`
 	TemplateVersion string                 `json:"template_version" form:"required"`
 	TemplateVersion string                 `json:"template_version" form:"required"`
 	Values          map[string]interface{} `json:"values"`
 	Values          map[string]interface{} `json:"values"`
 	Name            string                 `json:"name" form:"required"`
 	Name            string                 `json:"name" form:"required"`
 }
 }
 
 
+// swagger:model
 type CreateReleaseRequest struct {
 type CreateReleaseRequest struct {
 	*CreateReleaseBaseRequest
 	*CreateReleaseBaseRequest
 
 
@@ -62,6 +64,7 @@ type RollbackReleaseRequest struct {
 	Revision int `json:"revision" form:"required"`
 	Revision int `json:"revision" form:"required"`
 }
 }
 
 
+// swagger:model UpdateReleaseRequest
 type UpgradeReleaseRequest struct {
 type UpgradeReleaseRequest struct {
 	Values       string `json:"values" form:"required"`
 	Values       string `json:"values" form:"required"`
 	ChartVersion string `json:"version"`
 	ChartVersion string `json:"version"`

+ 1 - 0
api/types/request.go

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

+ 257 - 0
api/types/stacks.go

@@ -0,0 +1,257 @@
+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 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
cmd/app/main.go

@@ -1,3 +1,5 @@
+//go:generate swagger generate spec
+
 package main
 package main
 
 
 import (
 import (

+ 1 - 0
dashboard/src/main/home/Home.tsx

@@ -468,6 +468,7 @@ class Home extends Component<PropsType, StateType> {
                 "/env-groups",
                 "/env-groups",
                 "/databases",
                 "/databases",
                 "/preview-environments",
                 "/preview-environments",
+                "/stacks",
               ]}
               ]}
               render={() => {
               render={() => {
                 let { currentCluster } = this.context;
                 let { currentCluster } = this.context;

+ 12 - 3
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -29,8 +29,6 @@ import LastRunStatusSelector from "./LastRunStatusSelector";
 import loadable from "@loadable/component";
 import loadable from "@loadable/component";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
 import JobRunTable from "./chart/JobRunTable";
 import JobRunTable from "./chart/JobRunTable";
-import SwitchBase from "@material-ui/core/internal/SwitchBase";
-import Selector from "components/Selector";
 import TabSelector from "components/TabSelector";
 import TabSelector from "components/TabSelector";
 import TagFilter from "./TagFilter";
 import TagFilter from "./TagFilter";
 
 
@@ -47,6 +45,14 @@ const LazyPreviewEnvironmentsRoutes = loadable(
   }
   }
 );
 );
 
 
+const LazyStackRoutes = loadable(
+  // @ts-ignore
+  () => import("./stacks/routes.tsx"),
+  {
+    fallback: <Loading />,
+  }
+);
+
 type PropsType = RouteComponentProps &
 type PropsType = RouteComponentProps &
   WithAuthProps & {
   WithAuthProps & {
     currentCluster: ClusterType;
     currentCluster: ClusterType;
@@ -273,6 +279,9 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     let { setSidebar } = this.props;
     let { setSidebar } = this.props;
     return (
     return (
       <Switch>
       <Switch>
+        <Route path={"/stacks"}>
+          <LazyStackRoutes />
+        </Route>
         <Route path={"/preview-environments"}>
         <Route path={"/preview-environments"}>
           <LazyPreviewEnvironmentsRoutes />
           <LazyPreviewEnvironmentsRoutes />
         </Route>
         </Route>
@@ -499,4 +508,4 @@ const SortFilterWrapper = styled.div`
   > div:not(:first-child) {
   > div:not(:first-child) {
     margin-left: 30px;
     margin-left: 30px;
   }
   }
-`;
+`;

+ 53 - 0
dashboard/src/main/home/cluster-dashboard/stacks/Dashboard.tsx

@@ -0,0 +1,53 @@
+import DynamicLink from "components/DynamicLink";
+import React, { useEffect, useState } from "react";
+import { useHistory, useLocation } from "react-router";
+import { useRouting } from "shared/routing";
+import styled from "styled-components";
+import DashboardHeader from "../DashboardHeader";
+import NamespaceSelector from "../NamespaceSelector";
+import StackList from "./_StackList";
+const Dashboard = () => {
+  const [currentNamespace, setCurrentNamespace] = useState("default");
+
+  const location = useLocation();
+  const history = useHistory();
+  const { getQueryParam, pushQueryParams } = useRouting();
+
+  const handleNamespaceChange = (namespace: string) => {
+    setCurrentNamespace(namespace);
+    pushQueryParams({ namespace });
+  };
+
+  useEffect(() => {
+    const newNamespace = getQueryParam("namespace");
+    if (newNamespace !== currentNamespace) {
+      setCurrentNamespace(newNamespace);
+    }
+  }, [location.search, history]);
+
+  return (
+    <>
+      <DashboardHeader
+        materialIconClass="material-icons-outlined"
+        image={"lan"}
+        title="Preview Environments"
+        description=""
+      />
+      <ActionRow>
+        <DynamicLink to={"/stacks/launch"}>Create stack</DynamicLink>
+        <NamespaceSelector
+          namespace={currentNamespace}
+          setNamespace={handleNamespaceChange}
+        />
+      </ActionRow>
+      <StackList namespace={currentNamespace} />
+    </>
+  );
+};
+
+export default Dashboard;
+
+const ActionRow = styled.div`
+  display: flex;
+  justify-content: space-between;
+`;

+ 255 - 0
dashboard/src/main/home/cluster-dashboard/stacks/_StackList.tsx

@@ -0,0 +1,255 @@
+import DynamicLink from "components/DynamicLink";
+import Loading from "components/Loading";
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+import { GetStacksResponse, Stack } from "./types";
+
+const StackList = ({ namespace }: { namespace: string }) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [stacks, setStacks] = useState<Stack[]>(null);
+  const [isLoading, setIsLoading] = useState(true);
+
+  useEffect(() => {
+    let isSubscribed = true;
+
+    setIsLoading(true);
+
+    api
+      .listStacks(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace,
+        }
+      )
+      .then((res) => {
+        if (isSubscribed) {
+          setStacks(res.data);
+        }
+      })
+      .catch((err) => {
+        if (isSubscribed) {
+          setCurrentError(err);
+        }
+      })
+      .finally(() => {
+        if (isSubscribed) {
+          setIsLoading(false);
+        }
+      });
+  }, [namespace]);
+
+  if (isLoading) {
+    return <Loading />;
+  }
+
+  if (stacks.length === 0) {
+    return (
+      <div>
+        <h3>No stacks found</h3>
+        <p>You can create a stack by clicking the "Create stack" button.</p>
+      </div>
+    );
+  }
+
+  return (
+    <>
+      {stacks.map((stack) => (
+        <Card to={`/stacks/${stack.id}`} key={stack.id}>
+          {stack.name} - Current Revision: {stack.latest_revision.id}
+        </Card>
+      ))}
+    </>
+  );
+};
+
+export default StackList;
+
+const Card = styled(DynamicLink)`
+  display: block;
+`;
+
+const mockApi = () =>
+  new Promise<{ data: GetStacksResponse }>((res) =>
+    setTimeout(() => res({ data: StacksMock }), 500)
+  );
+
+const StacksMock: GetStacksResponse = [
+  {
+    created_at: "2022-06-09T11:59:27.729463-04:00",
+    updated_at: "2022-06-09T11:59:27.729463-04:00",
+    name: "string",
+    id: "5433422f46f3ba52e49bb46dd1e12ab5",
+    latest_revision: {
+      created_at: "2022-06-09T11:59:27.731416-04:00",
+      id: 1,
+      status: "deploying",
+      stack_id: "5433422f46f3ba52e49bb46dd1e12ab5",
+      resources: [
+        {
+          created_at: "2022-06-09T11:59:27.732213-04:00",
+          updated_at: "2022-06-09T11:59:27.732213-04:00",
+          stack_id: "5433422f46f3ba52e49bb46dd1e12ab5",
+          stack_revision_id: 1,
+          name: "string",
+          id: "4b2cae112ca29203acdef784392e7ac0",
+          stack_app_data: {
+            template_repo_url: "",
+            template_name: "string",
+            template_version: "string",
+          },
+          stack_source_config: {
+            created_at: "2022-06-09T11:59:27.732334-04:00",
+            updated_at: "2022-06-09T11:59:27.732334-04:00",
+            stack_id: "5433422f46f3ba52e49bb46dd1e12ab5",
+            stack_revision_id: 1,
+            name: "my-source-config",
+            id: "0d6aa05dcb37e5a0a4e8febd4854dac2",
+            image_repo_uri: "image-repo-uri",
+            image_tag: "tag",
+          },
+        },
+      ],
+      source_configs: [
+        {
+          created_at: "2022-06-09T11:59:27.732334-04:00",
+          updated_at: "2022-06-09T11:59:27.732334-04:00",
+          stack_id: "5433422f46f3ba52e49bb46dd1e12ab5",
+          stack_revision_id: 1,
+          name: "my-source-config",
+          id: "0d6aa05dcb37e5a0a4e8febd4854dac2",
+          image_repo_uri: "image-repo-uri",
+          image_tag: "tag",
+        },
+      ],
+    },
+    revisions: [
+      {
+        created_at: "2022-06-09T11:59:27.731416-04:00",
+        id: 1,
+        status: "deploying",
+        stack_id: "5433422f46f3ba52e49bb46dd1e12ab5",
+      },
+    ],
+  },
+  {
+    created_at: "2022-06-09T11:59:27.729463-04:00",
+    updated_at: "2022-06-09T11:59:27.729463-04:00",
+    name: "string",
+    id: "9873422f46f3ba52e49bb46dd1e12ab5",
+    latest_revision: {
+      created_at: "2022-06-09T11:59:27.731416-04:00",
+      id: 1,
+      status: "deploying",
+      stack_id: "9873422f46f3ba52e49bb46dd1e12ab5",
+      resources: [
+        {
+          created_at: "2022-06-09T11:59:27.732213-04:00",
+          updated_at: "2022-06-09T11:59:27.732213-04:00",
+          stack_id: "9873422f46f3ba52e49bb46dd1e12ab5",
+          stack_revision_id: 1,
+          name: "string",
+          id: "4b2cae112ca29203acdef784392e7ac0",
+          stack_app_data: {
+            template_repo_url: "",
+            template_name: "string",
+            template_version: "string",
+          },
+          stack_source_config: {
+            created_at: "2022-06-09T11:59:27.732334-04:00",
+            updated_at: "2022-06-09T11:59:27.732334-04:00",
+            stack_id: "9873422f46f3ba52e49bb46dd1e12ab5",
+            stack_revision_id: 1,
+            name: "my-source-config",
+            id: "0d6aa05dcb37e5a0a4e8febd4854dac2",
+            image_repo_uri: "image-repo-uri",
+            image_tag: "tag",
+          },
+        },
+      ],
+      source_configs: [
+        {
+          created_at: "2022-06-09T11:59:27.732334-04:00",
+          updated_at: "2022-06-09T11:59:27.732334-04:00",
+          stack_id: "9873422f46f3ba52e49bb46dd1e12ab5",
+          stack_revision_id: 1,
+          name: "my-source-config",
+          id: "0d6aa05dcb37e5a0a4e8febd4854dac2",
+          image_repo_uri: "image-repo-uri",
+          image_tag: "tag",
+        },
+      ],
+    },
+    revisions: [
+      {
+        created_at: "2022-06-09T11:59:27.731416-04:00",
+        id: 1,
+        status: "deploying",
+        stack_id: "9873422f46f3ba52e49bb46dd1e12ab5",
+      },
+    ],
+  },
+  {
+    created_at: "2022-06-09T11:59:27.729463-04:00",
+    updated_at: "2022-06-09T11:59:27.729463-04:00",
+    name: "string",
+    id: "1753422f46f3ba52e49bb46dd1e12ab5",
+    latest_revision: {
+      created_at: "2022-06-09T11:59:27.731416-04:00",
+      id: 1,
+      status: "deploying",
+      stack_id: "1753422f46f3ba52e49bb46dd1e12ab5",
+      resources: [
+        {
+          created_at: "2022-06-09T11:59:27.732213-04:00",
+          updated_at: "2022-06-09T11:59:27.732213-04:00",
+          stack_id: "1753422f46f3ba52e49bb46dd1e12ab5",
+          stack_revision_id: 1,
+          name: "string",
+          id: "4b2cae112ca29203acdef784392e7ac0",
+          stack_app_data: {
+            template_repo_url: "",
+            template_name: "string",
+            template_version: "string",
+          },
+          stack_source_config: {
+            created_at: "2022-06-09T11:59:27.732334-04:00",
+            updated_at: "2022-06-09T11:59:27.732334-04:00",
+            stack_id: "1753422f46f3ba52e49bb46dd1e12ab5",
+            stack_revision_id: 1,
+            name: "my-source-config",
+            id: "0d6aa05dcb37e5a0a4e8febd4854dac2",
+            image_repo_uri: "image-repo-uri",
+            image_tag: "tag",
+          },
+        },
+      ],
+      source_configs: [
+        {
+          created_at: "2022-06-09T11:59:27.732334-04:00",
+          updated_at: "2022-06-09T11:59:27.732334-04:00",
+          stack_id: "1753422f46f3ba52e49bb46dd1e12ab5",
+          stack_revision_id: 1,
+          name: "my-source-config",
+          id: "0d6aa05dcb37e5a0a4e8febd4854dac2",
+          image_repo_uri: "image-repo-uri",
+          image_tag: "tag",
+        },
+      ],
+    },
+    revisions: [
+      {
+        created_at: "2022-06-09T11:59:27.731416-04:00",
+        id: 1,
+        status: "deploying",
+        stack_id: "1753422f46f3ba52e49bb46dd1e12ab5",
+      },
+    ],
+  },
+];

+ 114 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/NewApp.tsx

@@ -0,0 +1,114 @@
+import Helper from "components/form-components/Helper";
+import InputRow from "components/form-components/InputRow";
+import Loading from "components/Loading";
+import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
+import React, { useContext, useEffect, useState } from "react";
+import { useParams } from "react-router";
+import api from "shared/api";
+import { useRouting } from "shared/routing";
+import { ExpandedPorterTemplate } from "shared/types";
+import { StacksLaunchContext } from "./Store";
+
+const NewApp = () => {
+  const { addAppResource, newStack } = useContext(StacksLaunchContext);
+
+  const params = useParams<{
+    template_name: string;
+    version: string;
+  }>();
+
+  const [template, setTemplate] = useState<ExpandedPorterTemplate>();
+  const [isLoading, setIsLoading] = useState(true);
+  const [hasError, setHasError] = useState(false);
+  const [saveButtonStatus, setSaveButtonStatus] = useState("");
+
+  const [appName, setAppName] = useState("");
+
+  const { pushFiltered } = useRouting();
+
+  useEffect(() => {
+    let isSubscribed = true;
+    if (!params.template_name || !params.version) {
+      return () => {
+        isSubscribed = false;
+      };
+    }
+
+    setHasError(false);
+
+    api
+      .getTemplateInfo<ExpandedPorterTemplate>(
+        "<token>",
+        {},
+        { name: params.template_name, version: params.version }
+      )
+      .then((res) => {
+        if (isSubscribed) {
+          setTemplate(res.data);
+        }
+      })
+      .catch((err) => {
+        setHasError(true);
+      })
+      .finally(() => {
+        if (isSubscribed) {
+          setIsLoading(false);
+        }
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [params]);
+
+  if (isLoading) {
+    return <Loading />;
+  }
+
+  if (hasError) {
+    return <>Unexpected error</>;
+  }
+
+  const handleSubmit = (values: any) => {
+    if (appName === "") {
+      setSaveButtonStatus("App name cannot be empty");
+      return;
+    }
+
+    addAppResource({
+      name: appName,
+      source_config_name: newStack.source_configs[0]?.name || "",
+      template_name: params.template_name,
+      template_version: params.version,
+      values,
+    });
+
+    setSaveButtonStatus("successful");
+    setTimeout(() => {
+      setSaveButtonStatus("");
+      pushFiltered("/stacks/launch/overview", []);
+    }, 1000);
+  };
+
+  return (
+    <div style={{ position: "relative" }}>
+      <Helper>App name</Helper>
+      <InputRow
+        type="string"
+        value={appName}
+        setValue={(val: string) => setAppName(val)}
+        width={"300px"}
+      />
+      <Helper>App settings</Helper>
+      <PorterFormWrapper
+        formData={template.form}
+        onSubmit={handleSubmit}
+        isLaunch
+        saveValuesStatus={saveButtonStatus}
+        saveButtonText="Add application"
+      />
+    </div>
+  );
+};
+
+export default NewApp;

+ 485 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/Overview.tsx

@@ -0,0 +1,485 @@
+import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
+import semver from "semver";
+import { StacksLaunchContext } from "./Store";
+import InputRow from "components/form-components/InputRow";
+import Selector from "components/Selector";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { ClusterType, PorterTemplate } from "shared/types";
+import useAuth from "shared/auth/useAuth";
+import DynamicLink from "components/DynamicLink";
+import styled from "styled-components";
+import { useOutsideAlerter } from "shared/hooks/useOutsideAlerter";
+import { capitalize } from "shared/string_utils";
+import SaveButton from "components/SaveButton";
+import { useRouting } from "shared/routing";
+
+const Overview = () => {
+  const {
+    newStack,
+    clusterId,
+    namespace,
+    setStackName,
+    setStackNamespace,
+    setStackCluster,
+    submit,
+  } = useContext(StacksLaunchContext);
+  const { currentProject } = useContext(Context);
+  const [isAuthorized] = useAuth();
+
+  const [clusterOptions, setClusterOptions] = useState<
+    { label: string; value: string }[]
+  >([]);
+
+  const [namespaceOptions, setNamespaceOptions] = useState<
+    { label: string; value: string }[]
+  >([]);
+
+  const [submitButtonStatus, setSubmitButtonStatus] = useState("");
+
+  const { pushFiltered } = useRouting();
+
+  const getClusters = () => {
+    return api
+      .getClusters("<token>", {}, { id: currentProject.id })
+      .then((res) => {
+        if (res.data) {
+          let clusterOptions: {
+            label: string;
+            value: string;
+          }[] = res.data.map((cluster: ClusterType, i: number) => ({
+            label: cluster.name,
+            value: `${cluster.id}`,
+          }));
+
+          if (res.data.length > 0) {
+            setClusterOptions(clusterOptions);
+            console.log({ clusterId });
+            if (isNaN(clusterId)) {
+              const newClusterId = res.data[0].id;
+              setStackCluster(newClusterId);
+            }
+          }
+        }
+      });
+  };
+
+  const updateNamespaces = (cluster_id: number) => {
+    api
+      .getNamespaces(
+        "<token>",
+        {},
+        {
+          id: currentProject.id,
+          cluster_id,
+        }
+      )
+      .then((res) => {
+        if (res.data) {
+          const availableNamespaces = res.data.items.filter(
+            (namespace: any) => {
+              return namespace.status.phase !== "Terminating";
+            }
+          );
+          const namespaceOptions = availableNamespaces.map(
+            (x: { metadata: { name: string } }) => {
+              return { label: x.metadata.name, value: x.metadata.name };
+            }
+          );
+          if (availableNamespaces.length > 0) {
+            setNamespaceOptions(namespaceOptions);
+          }
+        }
+      })
+      .catch(console.log);
+  };
+
+  const handleSubmit = () => {
+    setSubmitButtonStatus("loading");
+
+    submit().then(() => {
+      console.log("submit");
+      setTimeout(() => {
+        setSubmitButtonStatus("");
+        pushFiltered("/stacks", []);
+      }, 1000);
+    });
+  };
+
+  useEffect(() => {
+    getClusters();
+  }, []);
+
+  useEffect(() => {
+    if (isNaN(clusterId)) {
+      return;
+    }
+    updateNamespaces(clusterId);
+  }, [clusterId]);
+
+  const isValid = useMemo(() => {
+    if (namespace === "") {
+      return false;
+    }
+    if (isNaN(clusterId)) {
+      return false;
+    }
+    if (newStack.name === "") {
+      return false;
+    }
+    return true;
+  }, [namespace, clusterId, newStack.name]);
+
+  return (
+    <div style={{ position: "relative" }}>
+      <InputRow
+        type="string"
+        value={newStack.name}
+        setValue={(newName: string) => setStackName(newName)}
+      />
+
+      <Selector
+        activeValue={`${clusterId}`}
+        setActiveValue={(cluster: string) => {
+          setStackCluster(Number(cluster));
+        }}
+        options={clusterOptions}
+        width="250px"
+        dropdownWidth="335px"
+        closeOverlay={true}
+      />
+
+      <Selector
+        key={"namespace"}
+        refreshOptions={() => {
+          updateNamespaces(clusterId);
+        }}
+        addButton={isAuthorized("namespace", "", ["get", "create"])}
+        activeValue={namespace}
+        setActiveValue={(val) => setStackNamespace(val)}
+        options={namespaceOptions}
+        width="250px"
+        dropdownWidth="335px"
+        closeOverlay={true}
+      />
+
+      <br />
+      <CardGrid>
+        {newStack.app_resources.map((app) => (
+          <Card key={app.name}>{app.name}</Card>
+        ))}
+
+        <AddResourceButton />
+      </CardGrid>
+
+      <SubmitButton
+        disabled={!isValid || submitButtonStatus !== ""}
+        text="Create Stack"
+        onClick={handleSubmit}
+        clearPosition
+        statusPosition="left"
+        status={submitButtonStatus}
+      >
+        Create stack
+      </SubmitButton>
+    </div>
+  );
+};
+
+export default Overview;
+
+const AddResourceButton = () => {
+  const [templates, setTemplates] = useState<PorterTemplate[]>([]);
+  const [currentTemplate, setCurrentTemplate] = useState<PorterTemplate>();
+  const [currentVersion, setCurrentVersion] = useState("");
+
+  const getTemplates = async () => {
+    try {
+      const res = await api.getTemplates<PorterTemplate[]>(
+        "<token>",
+        {
+          repo_url: process.env.APPLICATION_CHART_REPO_URL,
+        },
+        {}
+      );
+      let sortedVersionData = res.data
+        .map((template: PorterTemplate) => {
+          let versions = template.versions.reverse();
+
+          versions = template.versions.sort(semver.rcompare);
+
+          return {
+            ...template,
+            versions,
+            currentVersion: versions[0],
+          };
+        })
+        .sort((a, b) => {
+          if (a.name < b.name) {
+            return -1;
+          }
+          if (a.name > b.name) {
+            return 1;
+          }
+          return 0;
+        });
+
+      return sortedVersionData;
+    } catch (err) {}
+  };
+
+  useEffect(() => {
+    getTemplates().then((templates) => {
+      setTemplates(templates);
+      setCurrentTemplate(templates[0]);
+      setCurrentVersion(templates[0].currentVersion);
+    });
+  }, []);
+
+  return (
+    <AddResourceButtonStyles.Wrapper>
+      <AddResourceButtonStyles.Flex>
+        Add a new{" "}
+        <TemplateSelector
+          options={templates}
+          value={currentTemplate}
+          onChange={(template) => {
+            setCurrentTemplate(template);
+            setCurrentVersion(template.currentVersion);
+          }}
+        />
+        <VersionSelector
+          options={currentTemplate?.versions || []}
+          value={currentVersion}
+          onChange={setCurrentVersion}
+        />
+      </AddResourceButtonStyles.Flex>
+
+      <DynamicLink
+        to={`/stacks/launch/new-app/${currentTemplate?.name}/${currentVersion}`}
+      >
+        Create
+      </DynamicLink>
+    </AddResourceButtonStyles.Wrapper>
+  );
+};
+
+const TemplateSelector = ({
+  value,
+  options,
+  onChange,
+}: {
+  value: PorterTemplate;
+  options: PorterTemplate[];
+  onChange: (newValue: PorterTemplate) => void;
+}) => {
+  const wrapperRef = useRef();
+
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  useOutsideAlerter(wrapperRef, () => setIsExpanded(false));
+
+  const getName = (template: PorterTemplate) => {
+    if (template?.name === "web") {
+      return "Web Application";
+    }
+    return capitalize(template?.name || "");
+  };
+
+  return (
+    <>
+      <SelectorStyles.Wrapper ref={wrapperRef}>
+        <SelectorStyles.Button
+          expanded={isExpanded}
+          onClick={() => setIsExpanded((prev) => !prev)}
+        >
+          {getName(value)}
+          <i className="material-icons">arrow_drop_down</i>
+        </SelectorStyles.Button>
+
+        {isExpanded ? (
+          <SelectorStyles.Dropdown>
+            {options.map((template) => (
+              <SelectorStyles.Option
+                className={template.name === value.name ? "active" : ""}
+                onClick={() => {
+                  onChange(template);
+                  setIsExpanded(false);
+                }}
+              >
+                <SelectorStyles.OptionText>
+                  {getName(template)}
+                </SelectorStyles.OptionText>
+              </SelectorStyles.Option>
+            ))}
+          </SelectorStyles.Dropdown>
+        ) : null}
+      </SelectorStyles.Wrapper>
+    </>
+  );
+};
+
+const VersionSelector = ({
+  value,
+  options,
+  onChange,
+}: {
+  value: string;
+  options: string[];
+  onChange: (newValue: string) => void;
+}) => {
+  const wrapperRef = useRef();
+
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  useOutsideAlerter(wrapperRef, () => setIsExpanded(false));
+
+  return (
+    <>
+      <SelectorStyles.Wrapper ref={wrapperRef}>
+        <SelectorStyles.Button
+          expanded={isExpanded}
+          onClick={() => setIsExpanded((prev) => !prev)}
+        >
+          {capitalize(value)}
+          <i className="material-icons">arrow_drop_down</i>
+        </SelectorStyles.Button>
+
+        {isExpanded ? (
+          <SelectorStyles.Dropdown>
+            {options.map((version) => (
+              <SelectorStyles.Option
+                className={version === value ? "active" : ""}
+                onClick={() => {
+                  onChange(version);
+                  setIsExpanded(false);
+                }}
+              >
+                {capitalize(version)}
+              </SelectorStyles.Option>
+            ))}
+          </SelectorStyles.Dropdown>
+        ) : null}
+      </SelectorStyles.Wrapper>
+    </>
+  );
+};
+
+const CardGrid = styled.div`
+  margin-top: 32px;
+  margin-bottom: 32px;
+  display: grid;
+  grid-row-gap: 25px;
+`;
+
+const Card = styled.div`
+  display: flex;
+  color: #ffffff;
+  background: #2b2e3699;
+  justify-content: space-between;
+  border-radius: 5px;
+  cursor: pointer;
+  height: 75px;
+  padding: 12px;
+  padding-left: 14px;
+  border: 1px solid #ffffff0f;
+
+  :hover {
+    border: 1px solid #ffffff3c;
+  }
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const SubmitButton = styled(SaveButton)`
+  width: 100%;
+  display: flex;
+  justify-content: flex-end;
+`;
+
+const AddResourceButtonStyles = {
+  Wrapper: styled(Card)`
+    align-items: center;
+  `,
+  Text: styled.span`
+    font-size: 20px;
+  `,
+  Flex: styled.div`
+    display: flex;
+    align-items: center;
+  `,
+};
+
+const SelectorStyles = {
+  Wrapper: styled.div`
+    max-width: 200px;
+    position: relative;
+    font-size: 13px;
+
+    margin-left: 10px;
+  `,
+  Button: styled.div`
+    background-color: #ffffff11;
+    border: 1px solid #ffffff22;
+    border-radius: 5px;
+    min-width: 115px;
+    min-height: 30px;
+    padding: 0 15px;
+
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+
+    white-space: nowrap;
+    overflow-y: hidden;
+    text-overflow: ellipsis;
+    cursor: pointer;
+
+    > i {
+      font-size: 20px;
+      transform: ${(props: { expanded: boolean }) =>
+        props.expanded ? "rotate(180deg)" : ""};
+    }
+  `,
+  Dropdown: styled.div`
+    position: absolute;
+    background-color: #26282f;
+    width: 100%;
+    max-height: 200px;
+    overflow-y: auto;
+  `,
+  Option: styled.div`
+    min-height: 35px;
+    padding: 0 15px;
+
+    display: flex;
+    align-items: center;
+
+    cursor: pointer;
+
+    &.active {
+      background-color: #32343c;
+    }
+
+    :hover {
+      background-color: #32343c;
+    }
+
+    :not(:last-child) {
+      border-bottom: 1px solid #ffffff15;
+    }
+  `,
+  OptionText: styled.span`
+    max-width: 115px;
+    overflow-x: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+  `,
+};

+ 58 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/SelectSource.tsx

@@ -0,0 +1,58 @@
+import ImageSelector from "components/image-selector/ImageSelector";
+import React, { useContext, useState } from "react";
+import { StacksLaunchContext } from "./Store";
+import { CreateStackBody } from "../types";
+import { useRouting } from "shared/routing";
+import styled from "styled-components";
+import SaveButton from "components/SaveButton";
+
+const SelectSource = () => {
+  const { addSourceConfig } = useContext(StacksLaunchContext);
+
+  const [imageUrl, setImageUrl] = useState("");
+  const [imageTag, setImageTag] = useState("");
+  const { pushFiltered } = useRouting();
+
+  const handleNext = () => {
+    if (!imageUrl || !imageTag) {
+      return;
+    }
+
+    const newSource: Omit<CreateStackBody["source_configs"][0], "name"> = {
+      image_repo_uri: imageUrl,
+      image_tag: imageTag,
+    };
+
+    addSourceConfig(newSource);
+    pushFiltered("/stacks/launch/overview", []);
+  };
+
+  return (
+    <div style={{ position: "relative" }}>
+      <ImageSelector
+        selectedImageUrl={imageUrl}
+        setSelectedImageUrl={setImageUrl}
+        selectedTag={imageTag}
+        setSelectedTag={setImageTag}
+        forceExpanded
+      />
+
+      <SubmitButton
+        disabled={!imageUrl || !imageTag}
+        onClick={handleNext}
+        text="Next"
+        clearPosition
+        makeFlush
+      />
+    </div>
+  );
+};
+
+export default SelectSource;
+
+const SubmitButton = styled(SaveButton)`
+  width: 100%;
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 15px;
+`;

+ 138 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/Store.tsx

@@ -0,0 +1,138 @@
+import React, { createContext, useContext, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { CreateStackBody } from "../types";
+
+export type StacksLaunchContextType = {
+  newStack: CreateStackBody;
+
+  namespace: string;
+  clusterId: number;
+
+  setStackName: (name: string) => void;
+  setStackCluster: (clusterId: number) => void;
+  setStackNamespace: (namespace: string) => void;
+
+  addSourceConfig: (
+    sourceConfig: Omit<CreateStackBody["source_configs"][0], "name">
+  ) => void;
+
+  addAppResource: (appResource: CreateStackBody["app_resources"][0]) => void;
+
+  submit: () => Promise<void>;
+};
+
+const defaultValues: StacksLaunchContextType = {
+  newStack: {
+    name: "",
+    app_resources: [],
+    source_configs: [],
+  },
+
+  namespace: "",
+  clusterId: NaN,
+
+  setStackName: (name: string) => {},
+  setStackCluster: (clusterId: number) => {},
+  setStackNamespace: (namespace: string) => {},
+
+  addSourceConfig: (
+    sourceConfig: Omit<CreateStackBody["source_configs"][0], "name">
+  ) => {},
+
+  addAppResource: (appResource: CreateStackBody["app_resources"][0]) => {},
+
+  submit: async () => {},
+};
+
+export const StacksLaunchContext = createContext<StacksLaunchContextType>(
+  defaultValues
+);
+
+const StacksLaunchContextProvider: React.FC<{}> = ({ children }) => {
+  const { currentProject, setCurrentError } = useContext(Context);
+  const [newStack, setNewStack] = useState<CreateStackBody>(
+    defaultValues.newStack
+  );
+  const [clusterId, setClusterId] = useState<number>(NaN);
+  const [namespace, setNamespace] = useState("default");
+
+  const setStackName: StacksLaunchContextType["setStackName"] = (name) => {
+    setNewStack((prev) => ({
+      ...prev,
+      name,
+    }));
+  };
+  const setStackCluster: StacksLaunchContextType["setStackCluster"] = (
+    newClusterId
+  ) => {
+    setClusterId(newClusterId);
+  };
+  const setStackNamespace: StacksLaunchContextType["setStackNamespace"] = (
+    namespace
+  ) => {
+    setNamespace(namespace);
+  };
+
+  const addSourceConfig: StacksLaunchContextType["addSourceConfig"] = (
+    sourceConfig
+  ) => {
+    const newSourceConfigName = (index: number) =>
+      sourceConfig.build
+        ? `${sourceConfig.build.method}-${index}`
+        : `${sourceConfig.image_repo_uri}-${sourceConfig.image_tag}-${index}`;
+
+    setNewStack((prev) => ({
+      ...prev,
+      source_configs: [
+        ...prev.source_configs,
+        {
+          name: newSourceConfigName(prev.source_configs.length),
+          ...sourceConfig,
+        },
+      ],
+    }));
+  };
+
+  const addAppResource: StacksLaunchContextType["addAppResource"] = (
+    appResource
+  ) => {
+    setNewStack((prev) => ({
+      ...prev,
+      app_resources: [...prev.app_resources, appResource],
+    }));
+  };
+
+  const submit: StacksLaunchContextType["submit"] = async () => {
+    try {
+      await api.createStack("<token>", newStack, {
+        cluster_id: clusterId,
+        namespace: namespace,
+        project_id: currentProject.id,
+      });
+    } catch (error) {
+      setCurrentError(error);
+      throw error;
+    }
+  };
+
+  return (
+    <StacksLaunchContext.Provider
+      value={{
+        newStack,
+        namespace,
+        clusterId,
+        setStackName,
+        setStackCluster,
+        setStackNamespace,
+        addSourceConfig,
+        addAppResource,
+        submit,
+      }}
+    >
+      {children}
+    </StacksLaunchContext.Provider>
+  );
+};
+
+export default StacksLaunchContextProvider;

+ 40 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/index.tsx

@@ -0,0 +1,40 @@
+import React from "react";
+import { Redirect, Route, Switch, useRouteMatch } from "react-router";
+import styled from "styled-components";
+import NewApp from "./NewApp";
+import Overview from "./Overview";
+import SelectSource from "./SelectSource";
+import StacksLaunchContextProvider from "./Store";
+
+const LaunchRoutes = () => {
+  const { path } = useRouteMatch();
+
+  return (
+    <LaunchContainer>
+      <StacksLaunchContextProvider>
+        <Switch>
+          <Route path={`${path}/source`}>
+            <SelectSource />
+          </Route>
+          <Route path={`${path}/overview`}>
+            <Overview />
+          </Route>
+          <Route path={`${path}/new-app/:template_name/:version/:repo_url?`}>
+            <NewApp />
+          </Route>
+          <Route path={`*`}>
+            <Redirect to={`${path}/source`} />
+          </Route>
+        </Switch>
+      </StacksLaunchContextProvider>
+    </LaunchContainer>
+  );
+};
+
+export default LaunchRoutes;
+
+const LaunchContainer = styled.div`
+  max-width: 780px;
+  margin: 0 auto;
+  padding: 0 20px;
+`;

+ 24 - 0
dashboard/src/main/home/cluster-dashboard/stacks/routes.tsx

@@ -0,0 +1,24 @@
+import React from "react";
+import { Route, Switch, useLocation, useRouteMatch } from "react-router";
+import Dashboard from "./Dashboard";
+import LaunchRoutes from "./launch";
+
+const routes = () => {
+  const { path } = useRouteMatch();
+
+  return (
+    <Switch>
+      <Route path={`${path}/launch`}>
+        <LaunchRoutes />
+      </Route>
+      <Route path={`${path}/`} exact>
+        <Dashboard />
+      </Route>
+      <Route path={`*`}>
+        <div>Not found</div>
+      </Route>
+    </Switch>
+  );
+};
+
+export default routes;

+ 86 - 0
dashboard/src/main/home/cluster-dashboard/stacks/types.ts

@@ -0,0 +1,86 @@
+export type CreateStackBody = {
+  name: string;
+  app_resources: {
+    name: string;
+    source_config_name: string;
+    template_name: string;
+    template_version: string;
+    template_repo_url?: string;
+    values: unknown;
+  }[];
+  source_configs: {
+    name: string;
+    image_repo_uri: string;
+    image_tag: string;
+    build?: {
+      method: "pack" | "docker";
+      folder_path: string;
+      git?: unknown;
+      buildpack?: unknown;
+      dockerfile?: unknown;
+    };
+  }[];
+};
+
+export type CreateStackResponse = Stack;
+
+export type GetStacksResponse = Stack[];
+
+export type Stack = {
+  id: string;
+  name: string;
+  created_at: string;
+  updated_at: string;
+
+  revisions: StackRevision[];
+
+  latest_revision: StackRevision & {
+    resources: AppResource[];
+    source_configs: SourceConfig[];
+  };
+};
+
+export type StackRevision = {
+  id: number;
+  created_at: string;
+  status: string; // type with enum
+  stack_id: string;
+};
+
+export type SourceConfig = {
+  id: string;
+  name: string;
+  created_at: string;
+  updated_at: string;
+
+  image_repo_uri: string;
+  image_tag: string;
+
+  stack_id: string;
+  stack_revision_id: number;
+
+  build?: {
+    method: "pack" | "docker";
+    folder_path: string;
+    git?: unknown;
+    buildpack?: unknown;
+    dockerfile?: unknown;
+  };
+};
+
+export type AppResource = {
+  id: string;
+  name: string;
+  created_at: string;
+  updated_at: string;
+
+  stack_id: string;
+
+  stack_source_config: SourceConfig;
+  stack_revision_id: number;
+  stack_app_data: {
+    template_repo_url: string;
+    template_name: string;
+    template_version: string;
+  };
+};

+ 11 - 2
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -191,8 +191,13 @@ class Sidebar extends Component<PropsType, StateType> {
             )}
             )}
           {currentProject?.preview_envs_enabled && (
           {currentProject?.preview_envs_enabled && (
             <NavButton to="/preview-environments">
             <NavButton to="/preview-environments">
-              <InlineSVGWrapper id="Flat" fill="#FFFFFF" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
-                <path d="M103.99951,68a36,36,0,1,0-44,35.0929v49.8142a36,36,0,1,0,16,0V103.0929A36.05516,36.05516,0,0,0,103.99951,68Zm-56,0a20,20,0,1,1,20,20A20.0226,20.0226,0,0,1,47.99951,68Zm40,120a20,20,0,1,1-20-20A20.0226,20.0226,0,0,1,87.99951,188ZM196.002,152.907l-.00146-33.02563a55.63508,55.63508,0,0,0-16.40137-39.59619L155.31348,56h20.686a8,8,0,0,0,0-16h-40c-.02978,0-.05859.00415-.08838.00446-.2334.00256-.46631.01245-.69824.03527-.12891.01258-.25391.03632-.38086.05494-.13135.01928-.26318.03424-.39355.06-.14014.02778-.27686.06611-.41455.10114-.11475.02924-.23047.05426-.34424.08862-.13428.04059-.26367.0907-.395.13806-.11524.04151-.231.07929-.34473.12629-.12109.05011-.23681.10876-.35449.16455-.11914.05621-.23926.10907-.356.17144-.11133.0597-.21728.12757-.32519.1922-.11621.06928-.23389.13483-.34668.21051-.11719.07831-.227.16553-.33985.24976-.09668.07227-.1958.1394-.28955.21655-.18652.1529-.36426.31531-.53564.48413-.01612.01593-.03418.02918-.05029.04529-.02051.02051-.0376.04321-.05762.06391-.16358.16711-.32178.33941-.47022.52032-.083.10059-.15527.20648-.23193.31006-.07861.10571-.16064.20862-.23438.3183-.08056.12072-.15087.24591-.2246.36993-.05958.1-.12208.19757-.17725.30036-.06787.12591-.125.25531-.18506.384-.05078.1084-.10547.21466-.15137.32568-.05127.12463-.09326.25189-.13867.37848-.04248.11987-.08887.238-.126.36047-.03857.12775-.06738.25757-.09912.38678-.03125.124-.06591.24622-.0913.37244-.02979.15088-.04786.30328-.06934.45544-.01465.10645-.03516.21094-.0459.31867q-.03955.39752-.04.79706V88a8,8,0,0,0,16,0V67.31378l24.28516,24.28485a39.73874,39.73874,0,0,1,11.71582,28.28321l.00146,33.02533a36.00007,36.00007,0,1,0,16-.00019ZM188.00244,208a20,20,0,1,1,20-20A20.0226,20.0226,0,0,1,188.00244,208Z"/>
+              <InlineSVGWrapper
+                id="Flat"
+                fill="#FFFFFF"
+                xmlns="http://www.w3.org/2000/svg"
+                viewBox="0 0 256 256"
+              >
+                <path d="M103.99951,68a36,36,0,1,0-44,35.0929v49.8142a36,36,0,1,0,16,0V103.0929A36.05516,36.05516,0,0,0,103.99951,68Zm-56,0a20,20,0,1,1,20,20A20.0226,20.0226,0,0,1,47.99951,68Zm40,120a20,20,0,1,1-20-20A20.0226,20.0226,0,0,1,87.99951,188ZM196.002,152.907l-.00146-33.02563a55.63508,55.63508,0,0,0-16.40137-39.59619L155.31348,56h20.686a8,8,0,0,0,0-16h-40c-.02978,0-.05859.00415-.08838.00446-.2334.00256-.46631.01245-.69824.03527-.12891.01258-.25391.03632-.38086.05494-.13135.01928-.26318.03424-.39355.06-.14014.02778-.27686.06611-.41455.10114-.11475.02924-.23047.05426-.34424.08862-.13428.04059-.26367.0907-.395.13806-.11524.04151-.231.07929-.34473.12629-.12109.05011-.23681.10876-.35449.16455-.11914.05621-.23926.10907-.356.17144-.11133.0597-.21728.12757-.32519.1922-.11621.06928-.23389.13483-.34668.21051-.11719.07831-.227.16553-.33985.24976-.09668.07227-.1958.1394-.28955.21655-.18652.1529-.36426.31531-.53564.48413-.01612.01593-.03418.02918-.05029.04529-.02051.02051-.0376.04321-.05762.06391-.16358.16711-.32178.33941-.47022.52032-.083.10059-.15527.20648-.23193.31006-.07861.10571-.16064.20862-.23438.3183-.08056.12072-.15087.24591-.2246.36993-.05958.1-.12208.19757-.17725.30036-.06787.12591-.125.25531-.18506.384-.05078.1084-.10547.21466-.15137.32568-.05127.12463-.09326.25189-.13867.37848-.04248.11987-.08887.238-.126.36047-.03857.12775-.06738.25757-.09912.38678-.03125.124-.06591.24622-.0913.37244-.02979.15088-.04786.30328-.06934.45544-.01465.10645-.03516.21094-.0459.31867q-.03955.39752-.04.79706V88a8,8,0,0,0,16,0V67.31378l24.28516,24.28485a39.73874,39.73874,0,0,1,11.71582,28.28321l.00146,33.02533a36.00007,36.00007,0,1,0,16-.00019ZM188.00244,208a20,20,0,1,1,20-20A20.0226,20.0226,0,0,1,188.00244,208Z" />
               </InlineSVGWrapper>
               </InlineSVGWrapper>
               <EllipsisTextWrapper
               <EllipsisTextWrapper
                 onMouseOver={() => {
                 onMouseOver={() => {
@@ -218,6 +223,10 @@ class Sidebar extends Component<PropsType, StateType> {
               </EllipsisTextWrapper>
               </EllipsisTextWrapper>
             </NavButton>
             </NavButton>
           )}
           )}
+          <NavButton to="/stacks">
+            <Icon className="material-icons-outlined">lan</Icon>
+            Stacks
+          </NavButton>
         </>
         </>
       );
       );
     }
     }

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

@@ -4,6 +4,7 @@ import { release } from "process";
 import { baseApi } from "./baseApi";
 import { baseApi } from "./baseApi";
 
 
 import { BuildConfig, FullActionConfigType, StorageType } from "./types";
 import { BuildConfig, FullActionConfigType, StorageType } from "./types";
+import { CreateStackBody } from "main/home/cluster-dashboard/stacks/types";
 
 
 /**
 /**
  * Generic api call format
  * Generic api call format
@@ -1928,6 +1929,73 @@ const getGitlabFolderContent = baseApi<
     `/api/projects/${project_id}/integrations/gitlab/${integration_id}/repos/${repo_owner}/${repo_name}/${branch}/contents`
     `/api/projects/${project_id}/integrations/gitlab/${integration_id}/repos/${repo_owner}/${repo_name}/${branch}/contents`
 );
 );
 
 
+// STACKS
+
+const createStack = baseApi<
+  CreateStackBody,
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+  }
+>(
+  "POST",
+  ({ project_id, cluster_id, namespace }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks`
+);
+
+const listStacks = baseApi<
+  {},
+  { project_id: number; cluster_id: number; namespace: string }
+>(
+  "GET",
+  ({ project_id, cluster_id, namespace }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks`
+);
+
+const getStack = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id, namespace, stack_id }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}`
+);
+
+const getStackRevision = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+    revision_id: string;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id, namespace, stack_id, revision_id }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/${revision_id}`
+);
+
+const rollbackStack = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+  }
+>(
+  "POST",
+  ({ project_id, cluster_id, namespace, stack_id }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/rollback`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
 export default {
   checkAuth,
   checkAuth,
@@ -2110,4 +2178,11 @@ export default {
   getGitlabRepos,
   getGitlabRepos,
   getGitlabBranches,
   getGitlabBranches,
   getGitlabFolderContent,
   getGitlabFolderContent,
+
+  // STACKS
+  listStacks,
+  getStack,
+  getStackRevision,
+  createStack,
+  rollbackStack,
 };
 };

+ 3 - 1
dashboard/src/shared/routing.tsx

@@ -13,7 +13,8 @@ export type PorterUrl =
   | "jobs"
   | "jobs"
   | "onboarding"
   | "onboarding"
   | "databases"
   | "databases"
-  | "preview-environments";
+  | "preview-environments"
+  | "stacks";
 
 
 export const PorterUrls = [
 export const PorterUrls = [
   "dashboard",
   "dashboard",
@@ -29,6 +30,7 @@ export const PorterUrls = [
   "onboarding",
   "onboarding",
   "databases",
   "databases",
   "preview-environments",
   "preview-environments",
+  "stacks",
 ];
 ];
 
 
 // TODO: consolidate with pushFiltered
 // TODO: consolidate with pushFiltered

+ 9 - 0
dashboard/src/shared/types.tsx

@@ -1,3 +1,5 @@
+import ValuesYaml from "main/home/cluster-dashboard/expanded-chart/ValuesYaml";
+
 export interface ClusterType {
 export interface ClusterType {
   id: number;
   id: number;
   name: string;
   name: string;
@@ -164,6 +166,13 @@ export interface PorterTemplate {
   repo_url?: string;
   repo_url?: string;
 }
 }
 
 
+export interface ExpandedPorterTemplate {
+  form: FormYAML;
+  markdown: string;
+  metadata: ChartType["chart"]["metadata"];
+  values: ChartTypeWithExtendedConfig["config"];
+}
+
 // FormYAML represents a chart's values.yaml form abstraction
 // FormYAML represents a chart's values.yaml form abstraction
 export interface FormYAML {
 export interface FormYAML {
   name?: string;
   name?: string;

+ 172 - 0
internal/models/stack.go

@@ -0,0 +1,172 @@
+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
+
+	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,
+	}
+}
+
+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.APIToken{},
 		&models.Policy{},
 		&models.Policy{},
 		&models.Tag{},
 		&models.Tag{},
+		&models.Stack{},
+		&models.StackRevision{},
+		&models.StackResource{},
+		&models.StackSourceConfig{},
 		&ints.KubeIntegration{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
 		&ints.OIDCIntegration{},

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

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

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

@@ -0,0 +1,108 @@
+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)
+
+	if err := repo.db.Debug().
+		Preload("Revisions", func(db *gorm.DB) *gorm.DB {
+			return db.Debug().Order("stack_revisions.revision_number DESC").Limit(1)
+		}).
+		Preload("Revisions.Resources").
+		Preload("Revisions.SourceConfigs").
+		Where("stacks.project_id = ? AND stacks.cluster_id = ? AND stacks.namespace = ?", projectID, clusterID, namespace).Find(&stacks).Error; err != nil {
+		return nil, err
+	}
+
+	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")
+		}).
+		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) 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
+}

+ 1 - 0
internal/repository/repository.go

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

+ 15 - 0
internal/repository/stack.go

@@ -0,0 +1,15 @@
+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)
+
+	ReadStackRevisionByNumber(stackID uint, revisionNumber uint) (*models.StackRevision, error)
+	AppendNewRevision(revision *models.StackRevision) (*models.StackRevision, error)
+}

+ 1 - 0
scripts/build/generate-spec.sh

@@ -0,0 +1 @@
+swagger generate spec --scan-models --include github.com/porter-dev/porter --work-dir ./cmd/app/ --output openapi.yaml