Jelajahi Sumber

Merge branch 'master' of github.com:porter-dev/porter into nico/por-534-allow-github-deployed-apps-to-change-the

jnfrati 3 tahun lalu
induk
melakukan
8825601aea
100 mengubah file dengan 5991 tambahan dan 342 penghapusan
  1. 1 0
      .gitignore
  2. 2 2
      api/client/k8s.go
  3. 2 0
      api/server/authz/policy.go
  4. 63 0
      api/server/authz/stack.go
  5. 19 2
      api/server/handlers/cluster/create_namespace.go
  6. 5 10
      api/server/handlers/cluster/delete_namespace.go
  7. 13 2
      api/server/handlers/cluster/get_namespace.go
  8. 15 2
      api/server/handlers/cluster/list_namespaces.go
  9. 2 2
      api/server/handlers/environment/create.go
  10. 50 34
      api/server/handlers/environment/list_deployments_by_cluster.go
  11. 113 2
      api/server/handlers/registry/create.go
  12. 2 0
      api/server/handlers/registry/create_repository.go
  13. 29 10
      api/server/handlers/release/create.go
  14. 1 1
      api/server/handlers/release/create_webhook.go
  15. 1 1
      api/server/handlers/release/delete.go
  16. 3 3
      api/server/handlers/release/update_rollback.go
  17. 248 0
      api/server/handlers/release/upgrade.go
  18. 9 0
      api/server/handlers/release/upgrade_webhook.go
  19. 250 0
      api/server/handlers/stack/create.go
  20. 65 0
      api/server/handlers/stack/delete.go
  21. 30 0
      api/server/handlers/stack/get.go
  22. 47 0
      api/server/handlers/stack/get_revision.go
  23. 103 0
      api/server/handlers/stack/helpers.go
  24. 46 0
      api/server/handlers/stack/list.go
  25. 138 0
      api/server/handlers/stack/rollback.go
  26. 154 0
      api/server/handlers/stack/update_source_put.go
  27. 94 0
      api/server/handlers/v1/registry/list_images.go
  28. 8 5
      api/server/handlers/v1/release/upgrade.go
  29. 20 12
      api/server/handlers/webhook/github_incoming.go
  30. 2 2
      api/server/router/cluster.go
  31. 8 1
      api/server/router/router.go
  32. 109 0
      api/server/router/v1/cluster.go
  33. 20 0
      api/server/router/v1/namespace.go
  34. 9 0
      api/server/router/v1/project.go
  35. 199 1
      api/server/router/v1/registry.go
  36. 168 1
      api/server/router/v1/release.go
  37. 490 0
      api/server/router/v1/stack.go
  38. 1 0
      api/server/shared/config/env/envconfs.go
  39. 9 0
      api/server/shared/config/loader/loader.go
  40. 11 4
      api/types/build_config.go
  41. 29 10
      api/types/cluster.go
  42. 28 9
      api/types/git_action_config.go
  43. 25 4
      api/types/namespace.go
  44. 2 0
      api/types/policy.go
  45. 1 0
      api/types/project.go
  46. 93 10
      api/types/registry.go
  47. 65 15
      api/types/release.go
  48. 1 0
      api/types/request.go
  49. 263 0
      api/types/stacks.go
  50. 5 3
      cli/cmd/cluster.go
  51. 2 0
      cmd/app/main.go
  52. 3 1
      dashboard/src/components/repo-selector/RepoList.tsx
  53. 1 0
      dashboard/src/main/home/Home.tsx
  54. 12 102
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  55. 6 6
      dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx
  56. 27 6
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  57. 5 5
      dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx
  58. 3 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/BuildSettingsTab.tsx
  59. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  60. 19 6
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx
  61. 5 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  62. 29 27
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  63. 103 0
      dashboard/src/main/home/cluster-dashboard/stacks/Dashboard.tsx
  64. 153 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack.tsx
  65. 217 0
      dashboard/src/main/home/cluster-dashboard/stacks/_StackList.tsx
  66. 25 0
      dashboard/src/main/home/cluster-dashboard/stacks/components/Status.tsx
  67. 98 0
      dashboard/src/main/home/cluster-dashboard/stacks/components/styles.ts
  68. 286 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/NewApp.tsx
  69. 231 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/Overview.tsx
  70. 96 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/SelectSource.tsx
  71. 145 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/Store.tsx
  72. 106 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/AddResourceButton.tsx
  73. 54 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/AppCard.tsx
  74. 71 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/TemplateSelector.tsx
  75. 61 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/VersionSelector.tsx
  76. 148 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/styles.tsx
  77. 39 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/index.tsx
  78. 40 0
      dashboard/src/main/home/cluster-dashboard/stacks/routes.tsx
  79. 47 0
      dashboard/src/main/home/cluster-dashboard/stacks/shared.ts
  80. 88 0
      dashboard/src/main/home/cluster-dashboard/stacks/types.ts
  81. 1 1
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  82. 4 4
      dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx
  83. 2 1
      dashboard/src/main/home/modals/DeleteNamespaceModal.tsx
  84. 13 2
      dashboard/src/main/home/sidebar/Sidebar.tsx
  85. 94 5
      dashboard/src/shared/api.tsx
  86. 3 1
      dashboard/src/shared/routing.tsx
  87. 11 0
      dashboard/src/shared/types.tsx
  88. 2 0
      internal/models/project.go
  89. 1 1
      internal/models/registry.go
  90. 3 0
      internal/models/release.go
  91. 177 0
      internal/models/stack.go
  92. 151 21
      internal/registry/registry.go
  93. 4 0
      internal/repository/gorm/migrate.go
  94. 6 0
      internal/repository/gorm/repository.go
  95. 167 0
      internal/repository/gorm/stack.go
  96. 1 0
      internal/repository/repository.go
  97. 19 0
      internal/repository/stack.go
  98. 6 0
      internal/repository/test/repository.go
  99. 56 0
      internal/repository/test/stack.go
  100. 78 0
      internal/stacks/helpers.go

+ 1 - 0
.gitignore

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

+ 2 - 2
api/client/k8s.go

@@ -38,8 +38,8 @@ func (c *Client) CreateNewK8sNamespace(
 	projectID uint,
 	clusterID uint,
 	name string,
-) (*types.CreateNamespaceResponse, error) {
-	resp := &types.CreateNamespaceResponse{}
+) (*types.NamespaceResponse, error) {
+	resp := &types.NamespaceResponse{}
 
 	err := c.postRequest(
 		fmt.Sprintf(

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

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

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

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

+ 19 - 2
api/server/handlers/cluster/create_namespace.go

@@ -1,7 +1,9 @@
 package cluster
 
 import (
+	"fmt"
 	"net/http"
+	"time"
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -44,6 +46,15 @@ func (c *CreateNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
+	_, err = agent.GetNamespace(request.Name)
+
+	if err == nil { // namespace with name already exists
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("namespace already exists"), http.StatusPreconditionFailed,
+		))
+		return
+	}
+
 	namespace, err := agent.CreateNamespace(request.Name)
 
 	if err != nil {
@@ -51,8 +62,14 @@ func (c *CreateNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	res := types.CreateNamespaceResponse{
-		Namespace: namespace,
+	res := &types.NamespaceResponse{
+		Name:              namespace.Name,
+		CreationTimestamp: namespace.CreationTimestamp.Time.UTC().Format(time.RFC1123),
+		Status:            string(namespace.Status.Phase),
+	}
+
+	if namespace.DeletionTimestamp != nil {
+		res.DeletionTimestamp = namespace.DeletionTimestamp.Time.UTC().Format(time.RFC1123)
 	}
 
 	w.WriteHeader(http.StatusCreated)

+ 5 - 10
api/server/handlers/cluster/delete_namespace.go

@@ -29,20 +29,15 @@ func NewDeleteNamespaceHandler(
 }
 
 func (c *DeleteNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	namespace, _ := requestutils.GetURLParamString(r, types.URLParamNamespace)
-
-	if namespace == "" {
-		request := &types.DeleteNamespaceRequest{}
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
-		if ok := c.DecodeAndValidate(w, r, request); !ok {
-			return
-		}
+	namespace, reqErr := requestutils.GetURLParamString(r, types.URLParamNamespace)
 
-		namespace = request.Name
+	if reqErr != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(reqErr, http.StatusBadRequest))
+		return
 	}
 
-	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
-
 	agent, err := c.GetAgent(r, cluster, "")
 
 	if err != nil {

+ 13 - 2
api/server/handlers/cluster/get_namespace.go

@@ -2,6 +2,7 @@ package cluster
 
 import (
 	"net/http"
+	"time"
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -30,7 +31,7 @@ func NewGetNamespaceHandler(
 }
 
 func (c *GetNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	namespace, reqErr := requestutils.GetURLParamString(r, types.URLParamNamespace)
+	ns, reqErr := requestutils.GetURLParamString(r, types.URLParamNamespace)
 
 	if reqErr != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
@@ -45,7 +46,7 @@ func (c *GetNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	res, err := agent.GetNamespace(namespace)
+	namespace, err := agent.GetNamespace(ns)
 
 	if err != nil {
 		if errors.IsNotFound(err) {
@@ -57,5 +58,15 @@ func (c *GetNamespaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	res := &types.NamespaceResponse{
+		Name:              namespace.Name,
+		CreationTimestamp: namespace.CreationTimestamp.Time.UTC().Format(time.RFC1123),
+		Status:            string(namespace.Status.Phase),
+	}
+
+	if namespace.DeletionTimestamp != nil {
+		res.DeletionTimestamp = namespace.DeletionTimestamp.Time.UTC().Format(time.RFC1123)
+	}
+
 	c.WriteResult(w, r, res)
 }

+ 15 - 2
api/server/handlers/cluster/list_namespaces.go

@@ -2,6 +2,7 @@ package cluster
 
 import (
 	"net/http"
+	"time"
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -44,8 +45,20 @@ func (c *ListNamespacesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		return
 	}
 
-	res := types.ListNamespacesResponse{
-		NamespaceList: namespaceList,
+	res := types.ListNamespacesResponse{}
+
+	for _, ns := range namespaceList.Items {
+		namespace := &types.NamespaceResponse{
+			Name:              ns.Name,
+			CreationTimestamp: ns.CreationTimestamp.Time.UTC().Format(time.RFC1123),
+			Status:            string(ns.Status.Phase),
+		}
+
+		if ns.DeletionTimestamp != nil {
+			namespace.DeletionTimestamp = ns.DeletionTimestamp.Time.UTC().Format(time.RFC1123)
+		}
+
+		res = append(res, namespace)
 	}
 
 	c.WriteResult(w, r, res)

+ 2 - 2
api/server/handlers/environment/create.go

@@ -174,11 +174,11 @@ func getGithubClientFromEnvironment(config *config.Config, env *models.Environme
 	}
 
 	// authenticate as github app installation
-	itr, err := ghinstallation.NewKeyFromFile(
+	itr, err := ghinstallation.New(
 		http.DefaultTransport,
 		int64(ghAppId),
 		int64(env.GitInstallationID),
-		config.ServerConf.GithubAppSecretPath,
+		config.ServerConf.GithubAppSecret,
 	)
 
 	if err != nil {

+ 50 - 34
api/server/handlers/environment/list_deployments_by_cluster.go

@@ -71,6 +71,8 @@ func (c *ListDeploymentsByClusterHandler) ServeHTTP(w http.ResponseWriter, r *ht
 			deployments = append(deployments, deployment)
 		}
 
+		envToGithubClientMap := make(map[uint]*github.Client)
+
 		var wg sync.WaitGroup
 		wg.Add(len(deployments))
 
@@ -82,10 +84,21 @@ func (c *ListDeploymentsByClusterHandler) ServeHTTP(w http.ResponseWriter, r *ht
 				return
 			}
 
+			if _, ok := envToGithubClientMap[env.ID]; !ok {
+				client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+				if err != nil {
+					c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+					return
+				}
+
+				envToGithubClientMap[env.ID] = client
+			}
+
 			go func(depl *types.Deployment) {
 				defer wg.Done()
 
-				updateDeploymentWithGithubWorkflowRunStatus(c.Config(), env, depl)
+				updateDeploymentWithGithubWorkflowRunStatus(c.Config(), envToGithubClientMap[env.ID], env, depl)
 			}(deployment)
 		}
 
@@ -99,7 +112,18 @@ func (c *ListDeploymentsByClusterHandler) ServeHTTP(w http.ResponseWriter, r *ht
 		}
 
 		for _, env := range envList {
-			prs, err := fetchOpenPullRequests(r.Context(), c.Config(), env, deplInfoMap)
+			if _, ok := envToGithubClientMap[env.ID]; !ok {
+				client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+				if err != nil {
+					c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+					return
+				}
+
+				envToGithubClientMap[env.ID] = client
+			}
+
+			prs, err := fetchOpenPullRequests(r.Context(), c.Config(), envToGithubClientMap[env.ID], env, deplInfoMap)
 
 			if err != nil {
 				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -125,6 +149,13 @@ func (c *ListDeploymentsByClusterHandler) ServeHTTP(w http.ResponseWriter, r *ht
 
 		deplInfoMap := make(map[string]bool)
 
+		client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
 		for _, depl := range depls {
 			deployment := depl.ToDeploymentType()
 			deplInfoMap[fmt.Sprintf(
@@ -140,23 +171,16 @@ func (c *ListDeploymentsByClusterHandler) ServeHTTP(w http.ResponseWriter, r *ht
 		wg.Add(len(deployments))
 
 		for _, deployment := range deployments {
-			env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, deployment.EnvironmentID)
-
-			if err != nil {
-				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-				return
-			}
-
 			go func(depl *types.Deployment) {
 				defer wg.Done()
 
-				updateDeploymentWithGithubWorkflowRunStatus(c.Config(), env, depl)
+				updateDeploymentWithGithubWorkflowRunStatus(c.Config(), client, env, depl)
 			}(deployment)
 		}
 
 		wg.Wait()
 
-		prs, err := fetchOpenPullRequests(r.Context(), c.Config(), env, deplInfoMap)
+		prs, err := fetchOpenPullRequests(r.Context(), c.Config(), client, env, deplInfoMap)
 
 		if err != nil {
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -174,6 +198,7 @@ func (c *ListDeploymentsByClusterHandler) ServeHTTP(w http.ResponseWriter, r *ht
 
 func updateDeploymentWithGithubWorkflowRunStatus(
 	config *config.Config,
+	client *github.Client,
 	env *models.Environment,
 	deployment *types.Deployment,
 ) {
@@ -181,25 +206,21 @@ func updateDeploymentWithGithubWorkflowRunStatus(
 		return
 	}
 
-	client, err := getGithubClientFromEnvironment(config, env)
+	latestWorkflowRun, err := commonutils.GetLatestWorkflowRun(client, env.GitRepoOwner, env.GitRepoName,
+		fmt.Sprintf("porter_%s_env.yml", env.Name), deployment.PRBranchFrom)
 
 	if err == nil {
-		latestWorkflowRun, err := commonutils.GetLatestWorkflowRun(client, env.GitRepoOwner, env.GitRepoName,
-			fmt.Sprintf("porter_%s_env.yml", env.Name), deployment.PRBranchFrom)
-
-		if err == nil {
-			deployment.LastWorkflowRunURL = latestWorkflowRun.GetHTMLURL()
-
-			if (latestWorkflowRun.GetStatus() == "in_progress" ||
-				latestWorkflowRun.GetStatus() == "queued") &&
-				deployment.Status != types.DeploymentStatusCreating {
-				deployment.Status = types.DeploymentStatusUpdating
-			} else if latestWorkflowRun.GetStatus() == "completed" {
-				if latestWorkflowRun.GetConclusion() == "failure" {
-					deployment.Status = types.DeploymentStatusFailed
-				} else if latestWorkflowRun.GetConclusion() == "timed_out" {
-					deployment.Status = types.DeploymentStatusTimedOut
-				}
+		deployment.LastWorkflowRunURL = latestWorkflowRun.GetHTMLURL()
+
+		if (latestWorkflowRun.GetStatus() == "in_progress" ||
+			latestWorkflowRun.GetStatus() == "queued") &&
+			deployment.Status != types.DeploymentStatusCreating {
+			deployment.Status = types.DeploymentStatusUpdating
+		} else if latestWorkflowRun.GetStatus() == "completed" {
+			if latestWorkflowRun.GetConclusion() == "failure" {
+				deployment.Status = types.DeploymentStatusFailed
+			} else if latestWorkflowRun.GetConclusion() == "timed_out" {
+				deployment.Status = types.DeploymentStatusTimedOut
 			}
 		}
 	}
@@ -208,15 +229,10 @@ func updateDeploymentWithGithubWorkflowRunStatus(
 func fetchOpenPullRequests(
 	ctx context.Context,
 	config *config.Config,
+	client *github.Client,
 	env *models.Environment,
 	deplInfoMap map[string]bool,
 ) ([]*types.PullRequest, error) {
-	client, err := getGithubClientFromEnvironment(config, env)
-
-	if err != nil {
-		return nil, err
-	}
-
 	openPRs, resp, err := client.PullRequests.List(ctx, env.GitRepoOwner, env.GitRepoName,
 		&github.PullRequestListOptions{
 			ListOptions: github.ListOptions{

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

@@ -1,6 +1,7 @@
 package registry
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 
@@ -13,6 +14,7 @@ import (
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/registry"
+	"gorm.io/gorm"
 )
 
 type RegistryCreateHandler struct {
@@ -49,6 +51,114 @@ func (p *RegistryCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		return
 	}
 
+	// validate request before saving it to the DB
+	integrationIDs := []uint{
+		request.GCPIntegrationID,
+		request.AWSIntegrationID,
+		request.DOIntegrationID,
+		request.BasicIntegrationID,
+		request.AzureIntegrationID,
+	}
+
+	idCount := 0
+
+	for _, id := range integrationIDs {
+		if id != 0 {
+			idCount += 1
+		}
+	}
+
+	if idCount > 1 {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("only one integration ID should be set"), http.StatusBadRequest,
+		))
+		return
+	} else if idCount == 0 {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("at least one integration ID should be set"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	var err error
+
+	if request.GCPIntegrationID != 0 {
+		_, err = p.Repo().GCPIntegration().ReadGCPIntegration(proj.ID, request.GCPIntegrationID)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+					fmt.Errorf("no such GCP integration ID: %d for project ID: %d", request.GCPIntegrationID, proj.ID),
+					http.StatusNotFound,
+				))
+				return
+			}
+
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else if request.AWSIntegrationID != 0 {
+		_, err = p.Repo().AWSIntegration().ReadAWSIntegration(proj.ID, request.AWSIntegrationID)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+					fmt.Errorf("no such AWS integration ID: %d for project ID: %d", request.AWSIntegrationID, proj.ID),
+					http.StatusNotFound,
+				))
+				return
+			}
+
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else if request.DOIntegrationID != 0 {
+		_, err = p.Repo().OAuthIntegration().ReadOAuthIntegration(proj.ID, request.DOIntegrationID)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+					fmt.Errorf("no such DO integration ID: %d for project ID: %d", request.DOIntegrationID, proj.ID),
+					http.StatusNotFound,
+				))
+				return
+			}
+
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else if request.BasicIntegrationID != 0 {
+		_, err = p.Repo().BasicIntegration().ReadBasicIntegration(proj.ID, request.BasicIntegrationID)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+					fmt.Errorf("no such basic integration ID: %d for project ID: %d", request.BasicIntegrationID, proj.ID),
+					http.StatusNotFound,
+				))
+				return
+			}
+
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else if request.AzureIntegrationID != 0 {
+		_, err = p.Repo().AzureIntegration().ReadAzureIntegration(proj.ID, request.AzureIntegrationID)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+					fmt.Errorf("no such Azure integration ID: %d for project ID: %d", request.AzureIntegrationID, proj.ID),
+					http.StatusNotFound,
+				))
+				return
+			}
+
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
 	// create a registry model
 	regModel := &models.Registry{
 		Name:               request.Name,
@@ -99,7 +209,7 @@ func (p *RegistryCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		az.ACRName = request.ACRName
 		az.ACRResourceGroupName = request.ACRResourceGroupName
 
-		az, err = p.Repo().AzureIntegration().OverwriteAzureIntegration(az)
+		_, err = p.Repo().AzureIntegration().OverwriteAzureIntegration(az)
 
 		if err != nil {
 			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -108,7 +218,7 @@ func (p *RegistryCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 	}
 
 	// handle write to the database
-	regModel, err := p.Repo().Registry().CreateRegistry(regModel)
+	regModel, err = p.Repo().Registry().CreateRegistry(regModel)
 
 	if err != nil {
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -122,5 +232,6 @@ func (p *RegistryCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		},
 	))
 
+	w.WriteHeader(http.StatusCreated)
 	p.WriteResult(w, r, regModel.ToRegistryType())
 }

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

@@ -51,4 +51,6 @@ func (p *RegistryCreateRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *ht
 		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
+
+	w.WriteHeader(http.StatusCreated)
 }

+ 29 - 10
api/server/handlers/release/create.go

@@ -138,7 +138,7 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		}
 	}
 
-	release, err := createReleaseFromHelmRelease(c.Config(), cluster.ProjectID, cluster.ID, helmRelease)
+	release, err := CreateAppReleaseFromHelmRelease(c.Config(), cluster.ProjectID, cluster.ID, 0, helmRelease)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -214,11 +214,13 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 			FlowID: operationID,
 		},
 	))
+
+	w.WriteHeader(http.StatusCreated)
 }
 
-func createReleaseFromHelmRelease(
+func CreateAppReleaseFromHelmRelease(
 	config *config.Config,
-	projectID, clusterID uint,
+	projectID, clusterID, stackResourceID uint,
 	helmRelease *release.Release,
 ) (*models.Release, error) {
 	token, err := encryption.GenerateRandomBytes(16)
@@ -242,12 +244,29 @@ func createReleaseFromHelmRelease(
 	}
 
 	release := &models.Release{
-		ClusterID:    clusterID,
-		ProjectID:    projectID,
-		Namespace:    helmRelease.Namespace,
-		Name:         helmRelease.Name,
-		WebhookToken: token,
-		ImageRepoURI: repoStr,
+		ClusterID:       clusterID,
+		ProjectID:       projectID,
+		Namespace:       helmRelease.Namespace,
+		Name:            helmRelease.Name,
+		WebhookToken:    token,
+		ImageRepoURI:    repoStr,
+		StackResourceID: stackResourceID,
+	}
+
+	return config.Repo.Release().CreateRelease(release)
+}
+
+func CreateAddonReleaseFromHelmRelease(
+	config *config.Config,
+	projectID, clusterID, stackResourceID uint,
+	helmRelease *release.Release,
+) (*models.Release, error) {
+	release := &models.Release{
+		ClusterID:       clusterID,
+		ProjectID:       projectID,
+		Namespace:       helmRelease.Namespace,
+		Name:            helmRelease.Name,
+		StackResourceID: stackResourceID,
 	}
 
 	return config.Repo.Release().CreateRelease(release)
@@ -537,7 +556,7 @@ type containerEnvConfig struct {
 	} `yaml:"container"`
 }
 
-func getGARunner(
+func GetGARunner(
 	config *config.Config,
 	userID, projectID, clusterID uint,
 	ga *models.GitActionConfig,

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

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

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

@@ -88,7 +88,7 @@ func (c *DeleteReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 						return
 					}
 				} else {
-					gaRunner, err := getGARunner(
+					gaRunner, err := GetGARunner(
 						c.Config(),
 						user.ID,
 						cluster.ProjectID,

+ 3 - 3
api/server/handlers/release/update_rollback.go

@@ -65,7 +65,7 @@ func (c *RollbackReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		rel, err := c.Repo().Release().ReadRelease(cluster.ID, helmRelease.Name, helmRelease.Namespace)
 
 		if err == nil && rel != nil {
-			err = updateReleaseRepo(c.Config(), rel, helmRelease)
+			err = UpdateReleaseRepo(c.Config(), rel, helmRelease)
 
 			if err != nil {
 				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -75,7 +75,7 @@ func (c *RollbackReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 			gitAction := rel.GitActionConfig
 
 			if gitAction != nil && gitAction.ID != 0 && gitAction.GitlabIntegrationID == 0 {
-				gaRunner, err := getGARunner(
+				gaRunner, err := GetGARunner(
 					c.Config(),
 					user.ID,
 					cluster.ProjectID,
@@ -110,7 +110,7 @@ func (c *RollbackReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	}
 }
 
-func updateReleaseRepo(config *config.Config, release *models.Release, helmRelease *release.Release) error {
+func UpdateReleaseRepo(config *config.Config, release *models.Release, helmRelease *release.Release) error {
 	repository := helmRelease.Config["image"].(map[string]interface{})["repository"]
 	repoStr, ok := repository.(string)
 

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

@@ -0,0 +1,248 @@
+package release
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+
+	semver "github.com/Masterminds/semver/v3"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/integrations/slack"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
+	"helm.sh/helm/v3/pkg/release"
+)
+
+var (
+	createEnvSecretConstraint, _ = semver.NewConstraint(" < 0.1.0")
+)
+
+type UpgradeReleaseHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewUpgradeReleaseHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpgradeReleaseHandler {
+	return &UpgradeReleaseHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	helmRelease, _ := r.Context().Value(types.ReleaseScope).(*release.Release)
+
+	helmAgent, err := c.GetHelmAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	request := &types.UpgradeReleaseRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	registries, err := c.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	conf := &helm.UpgradeReleaseConfig{
+		Name:       helmRelease.Name,
+		Cluster:    cluster,
+		Repo:       c.Repo(),
+		Registries: registries,
+	}
+
+	// if the chart version is set, load a chart from the repo
+	if request.ChartVersion != "" {
+		cache := c.Config().URLCache
+		chartRepoURL, foundFirst := cache.GetURL(helmRelease.Chart.Metadata.Name)
+
+		if !foundFirst {
+			cache.Update()
+
+			var found bool
+
+			chartRepoURL, found = cache.GetURL(helmRelease.Chart.Metadata.Name)
+
+			if !found {
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+					fmt.Errorf("chart not found"),
+					http.StatusBadRequest,
+				))
+
+				return
+			}
+		}
+
+		chart, err := LoadChart(c.Config(), &LoadAddonChartOpts{
+			ProjectID:       cluster.ProjectID,
+			RepoURL:         chartRepoURL,
+			TemplateName:    helmRelease.Chart.Metadata.Name,
+			TemplateVersion: request.ChartVersion,
+		})
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("chart not found"),
+				http.StatusBadRequest,
+			))
+
+			return
+		}
+
+		conf.Chart = chart
+	}
+
+	newHelmRelease, upgradeErr := helmAgent.UpgradeRelease(conf, request.Values, c.Config().DOConf)
+
+	if upgradeErr == nil && newHelmRelease != nil {
+		helmRelease = newHelmRelease
+	}
+
+	slackInts, _ := c.Repo().SlackIntegration().ListSlackIntegrationsByProjectID(cluster.ProjectID)
+
+	rel, releaseErr := c.Repo().Release().ReadRelease(cluster.ID, helmRelease.Name, helmRelease.Namespace)
+
+	var notifConf *types.NotificationConfig
+	notifConf = nil
+	if rel != nil && rel.NotificationConfig != 0 {
+		conf, err := c.Repo().NotificationConfig().ReadNotificationConfig(rel.NotificationConfig)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		notifConf = conf.ToNotificationConfigType()
+	}
+
+	notifier := slack.NewSlackNotifier(notifConf, slackInts...)
+
+	notifyOpts := &slack.NotifyOpts{
+		ProjectID:   cluster.ProjectID,
+		ClusterID:   cluster.ID,
+		ClusterName: cluster.Name,
+		Name:        helmRelease.Name,
+		Namespace:   helmRelease.Namespace,
+		URL: fmt.Sprintf(
+			"%s/applications/%s/%s/%s?project_id=%d",
+			c.Config().ServerConf.ServerURL,
+			url.PathEscape(cluster.Name),
+			helmRelease.Namespace,
+			helmRelease.Name,
+			cluster.ProjectID,
+		),
+	}
+
+	if upgradeErr != nil {
+		notifyOpts.Status = slack.StatusHelmFailed
+		notifyOpts.Info = upgradeErr.Error()
+
+		if !cluster.NotificationsDisabled {
+			notifier.Notify(notifyOpts)
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			upgradeErr,
+			http.StatusBadRequest,
+		))
+
+		return
+	}
+
+	if helmRelease.Chart != nil && helmRelease.Chart.Metadata.Name != "job" {
+		notifyOpts.Status = slack.StatusHelmDeployed
+		notifyOpts.Version = helmRelease.Version
+
+		if !cluster.NotificationsDisabled {
+			notifier.Notify(notifyOpts)
+		}
+	}
+
+	// update the github actions env if the release exists and is built from source
+	if cName := helmRelease.Chart.Metadata.Name; cName == "job" || cName == "web" || cName == "worker" {
+		if releaseErr == nil && rel != nil {
+			err = UpdateReleaseRepo(c.Config(), rel, helmRelease)
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			gitAction := rel.GitActionConfig
+
+			if gitAction != nil && gitAction.ID != 0 && gitAction.GitlabIntegrationID == 0 {
+				gaRunner, err := GetGARunner(
+					c.Config(),
+					user.ID,
+					cluster.ProjectID,
+					cluster.ID,
+					rel.GitActionConfig,
+					helmRelease.Name,
+					helmRelease.Namespace,
+					rel,
+					helmRelease,
+				)
+
+				if err != nil {
+					c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+					return
+				}
+
+				actionVersion, err := semver.NewVersion(gaRunner.Version)
+
+				if err != nil {
+					c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+					return
+				}
+
+				if createEnvSecretConstraint.Check(actionVersion) {
+					if err := gaRunner.CreateEnvSecret(); err != nil {
+						c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+						return
+					}
+				}
+			}
+		}
+	}
+
+	c.WriteResult(w, r, nil)
+
+	err = postUpgrade(c.Config(), cluster.ProjectID, cluster.ID, helmRelease)
+
+	if err != nil {
+		c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}
+
+// postUpgrade runs any necessary scripting after the release has been upgraded.
+func postUpgrade(config *config.Config, projectID, clusterID uint, release *release.Release) error {
+	// update the relevant helm revision number if tied to a stack resource
+	return stacks.UpdateHelmRevision(config, projectID, clusterID, release)
+}

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

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

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

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

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

@@ -0,0 +1,65 @@
+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)
+
+	if len(stack.Revisions) > 0 {
+		revision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, stack.Revisions[0].ID)
+
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		helmAgent, err := p.GetHelmAgent(r, cluster, namespace)
+
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		// delete all resources in stack
+		for _, appResource := range revision.Resources {
+			deleteAppResource(&deleteAppResourceOpts{
+				helmAgent: helmAgent,
+				name:      appResource.Name,
+			})
+		}
+	}
+
+	stack, err := p.Repo().Stack().DeleteStack(stack)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

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

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

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

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

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

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

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

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

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

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

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

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

+ 94 - 0
api/server/handlers/v1/registry/list_images.go

@@ -0,0 +1,94 @@
+package registry
+
+import (
+	"fmt"
+	"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"
+	"github.com/porter-dev/porter/internal/registry"
+)
+
+type RegistryListImagesHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewRegistryListImagesHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *RegistryListImagesHandler {
+	return &RegistryListImagesHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *RegistryListImagesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	reg, _ := r.Context().Value(types.RegistryScope).(*models.Registry)
+
+	repoName, reqErr := requestutils.GetURLParamString(r, types.URLParamWildcard)
+
+	if reqErr != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(reqErr, http.StatusBadRequest))
+		return
+	}
+
+	request := &types.V1ListImageRequest{}
+
+	ok := c.DecodeAndValidate(w, r, request)
+
+	if !ok {
+		return
+	}
+
+	res := &types.V1ListImageResponse{}
+
+	// cast to a registry from registry package
+	_reg := registry.Registry(*reg)
+	regAPI := &_reg
+
+	if regAPI.AWSIntegrationID != 0 {
+		if request.Num == 0 {
+			request.Num = 1000
+		} else if request.Num < 1 || request.Num > 1000 {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("num should be between 1 and 1000 for ECR images"), http.StatusBadRequest,
+			))
+			return
+		}
+
+		var nextToken *string
+		if request.Next != "" {
+			nextToken = &request.Next
+		}
+
+		imgs, nextToken, err := regAPI.GetECRPaginatedImages(repoName, c.Repo(), request.Num, nextToken)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		if nextToken != nil {
+			res.Next = *nextToken
+		}
+
+		res.Images = append(res.Images, imgs...)
+	} else {
+		imgs, err := regAPI.ListImages(repoName, c.Repo(), c.Config().DOConf)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		res.Images = append(res.Images, imgs...)
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 8 - 5
api/server/handlers/release/ugprade.go → api/server/handlers/v1/release/upgrade.go

@@ -9,6 +9,7 @@ import (
 
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
+	baseReleaseHandler "github.com/porter-dev/porter/api/server/handlers/release"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
@@ -51,7 +52,7 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		return
 	}
 
-	request := &types.UpgradeReleaseRequest{}
+	request := &types.V1UpgradeReleaseRequest{}
 
 	if ok := c.DecodeAndValidate(w, r, request); !ok {
 		return
@@ -93,7 +94,7 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 			}
 		}
 
-		chart, err := LoadChart(c.Config(), &LoadAddonChartOpts{
+		chart, err := baseReleaseHandler.LoadChart(c.Config(), &baseReleaseHandler.LoadAddonChartOpts{
 			ProjectID:       cluster.ProjectID,
 			RepoURL:         chartRepoURL,
 			TemplateName:    helmRelease.Chart.Metadata.Name,
@@ -117,7 +118,9 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		conf.Chart = chart
 	}
 
-	newHelmRelease, upgradeErr := helmAgent.UpgradeRelease(conf, request.Values, c.Config().DOConf)
+	conf.Values = request.Values
+
+	newHelmRelease, upgradeErr := helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf)
 
 	if upgradeErr == nil && newHelmRelease != nil {
 		helmRelease = newHelmRelease
@@ -186,7 +189,7 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 	// update the github actions env if the release exists and is built from source
 	if cName := helmRelease.Chart.Metadata.Name; cName == "job" || cName == "web" || cName == "worker" {
 		if releaseErr == nil && rel != nil {
-			err = updateReleaseRepo(c.Config(), rel, helmRelease)
+			err = baseReleaseHandler.UpdateReleaseRepo(c.Config(), rel, helmRelease)
 
 			if err != nil {
 				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -196,7 +199,7 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 			gitAction := rel.GitActionConfig
 
 			if gitAction != nil && gitAction.ID != 0 && gitAction.GitlabIntegrationID == 0 {
-				gaRunner, err := getGARunner(
+				gaRunner, err := baseReleaseHandler.GetGARunner(
 					c.Config(),
 					user.ID,
 					cluster.ProjectID,

+ 20 - 12
api/server/handlers/webhook/github_incoming.go

@@ -37,14 +37,15 @@ func NewGithubIncomingWebhookHandler(
 
 func (c *GithubIncomingWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	payload, err := github.ValidatePayload(r, []byte(c.Config().ServerConf.GithubIncomingWebhookSecret))
+
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error validating webhook payload: %w", err)))
 		return
 	}
 
 	event, err := github.ParseWebHook(github.WebHookType(r), payload)
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error parsing webhook: %w", err)))
 		return
 	}
 
@@ -53,7 +54,7 @@ func (c *GithubIncomingWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.
 		err = c.processPullRequestEvent(event, r)
 
 		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error processing pull request webhook event: %w", err)))
 			return
 		}
 	}
@@ -73,14 +74,15 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 	env, err := c.Repo().Environment().ReadEnvironmentByWebhookIDOwnerRepoName(webhookID, owner, repo)
 
 	if err != nil {
-		return err
+		return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s] error reading environment: %w", webhookID, owner, repo, err)
 	}
 
 	// create deployment on GitHub API
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 
 	if err != nil {
-		return err
+		return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d] error getting github client: %w",
+			webhookID, owner, repo, env.ID, err)
 	}
 
 	if env.Mode == "auto" && event.GetAction() == "opened" {
@@ -98,7 +100,8 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 		)
 
 		if err != nil {
-			return err
+			return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d] error creating workflow dispatch event: %w",
+				webhookID, owner, repo, env.ID, err)
 		}
 	} else if event.GetAction() == "synchronize" || event.GetAction() == "closed" {
 		depl, err := c.Repo().Environment().ReadDeploymentByGitDetails(
@@ -106,7 +109,8 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 		)
 
 		if err != nil {
-			return err
+			return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d] error reading deployment: %w",
+				webhookID, owner, repo, env.ID, err)
 		}
 
 		if depl.Status != types.DeploymentStatusInactive {
@@ -125,13 +129,15 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 				)
 
 				if err != nil {
-					return err
+					return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, deploymentID: %d] error creating workflow dispatch event: %w",
+						webhookID, owner, repo, env.ID, depl.ID, err)
 				}
 			} else {
 				err = c.deleteDeployment(r, depl, env, client)
 
 				if err != nil {
-					return err
+					return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, deploymentID: %d] error deleting deployment: %w",
+						webhookID, owner, repo, env.ID, depl.ID, err)
 				}
 			}
 		}
@@ -149,7 +155,7 @@ func (c *GithubIncomingWebhookHandler) deleteDeployment(
 	cluster, err := c.Repo().Cluster().ReadCluster(env.ProjectID, env.ClusterID)
 
 	if err != nil {
-		return err
+		return fmt.Errorf("[projectID: %d, clusterID: %d] error reading cluster: %w", env.ProjectID, env.ClusterID, err)
 	}
 
 	agent, err := c.GetAgent(r, cluster, "")
@@ -163,7 +169,8 @@ func (c *GithubIncomingWebhookHandler) deleteDeployment(
 		err = agent.DeleteNamespace(depl.Namespace)
 
 		if err != nil {
-			return err
+			return fmt.Errorf("[owner: %s, repo: %s, environmentID: %d, deploymentID: %d] error deleting namespace '%s': %w",
+				env.GitRepoOwner, env.GitRepoName, env.ID, depl.ID, depl.Namespace, err)
 		}
 	}
 
@@ -188,7 +195,8 @@ func (c *GithubIncomingWebhookHandler) deleteDeployment(
 	_, err = c.Repo().Environment().UpdateDeployment(depl)
 
 	if err != nil {
-		return err
+		return fmt.Errorf("[owner: %s, repo: %s, environmentID: %d, deploymentID: %d] error updating deployment: %w",
+			env.GitRepoOwner, env.GitRepoName, env.ID, depl.ID, err)
 	}
 
 	return nil

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

@@ -609,14 +609,14 @@ func getClusterRoutes(
 		Router:   r,
 	})
 
-	// DELETE /api/projects/{project_id}/clusters/{cluster_id}/namespaces/delete -> cluster.NewDeleteNamespaceHandler
+	// DELETE /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace} -> cluster.NewDeleteNamespaceHandler
 	deleteNamespaceEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbDelete,
 			Method: types.HTTPVerbDelete,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/namespaces/delete",
+				RelativePath: fmt.Sprintf("%s/namespaces/{%s}", relPath, types.URLParamNamespace),
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,

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

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

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

@@ -11,6 +11,21 @@ import (
 	"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 {
 	return &router.Registerer{
 		GetRoutes: GetV1ClusterScopedRoutes,
@@ -56,6 +71,34 @@ func getV1ClusterRoutes(
 	var routes []*router.Route
 
 	// 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 in the cluster denoted by `cluster_id`. The cluster should belong to the project
+	// denoted by `project_id`.
+	//
+	// ---
+	// 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/NamespaceResponse'
+	//   '403':
+	//     description: Forbidden
+	//   '412':
+	//     description: Namespace already exists
 	createNamespaceEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
@@ -85,6 +128,30 @@ func getV1ClusterRoutes(
 	})
 
 	// 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 denoted by the name `namespace`. The namespace should belong to the cluster
+	// denoted by `cluster_id` which itself should belong to the project denoted by `project_id`.
+	//
+	// ---
+	// 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/NamespaceResponse'
+	//   '403':
+	//     description: Forbidden
+	//   '404':
+	//     description: Not Found
 	getNamespaceEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
@@ -113,6 +180,27 @@ func getV1ClusterRoutes(
 	})
 
 	// 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 all namespaces in the cluster denoted by `cluster_id`. The cluster should belong to
+	// the project denoted by `project_id`.
+	//
+	// ---
+	// 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(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
@@ -141,6 +229,27 @@ func getV1ClusterRoutes(
 	})
 
 	// 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 with the name `namespace`. The namespace should belong to the cluster
+	// denoted by `cluster_id` which itself should belong to the project denoted by `project_id`.
+	// Note that this endpoint does not indicate if the namespace does not exist.
+	//
+	// ---
+	// 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(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbDelete,

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

@@ -8,6 +8,26 @@ import (
 	"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 {
 	return &router.Registerer{
 		GetRoutes: GetV1NamespaceScopedRoutes,

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

@@ -8,6 +8,15 @@ import (
 	"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 {
 	return &router.Registerer{
 		GetRoutes: GetV1ProjectScopedRoutes,

+ 199 - 1
api/server/router/v1/registry.go

@@ -5,12 +5,28 @@ import (
 
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/api/server/handlers/registry"
+	v1Registry "github.com/porter-dev/porter/api/server/handlers/v1/registry"
 	"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 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 {
 	return &router.Registerer{
 		GetRoutes: GetV1RegistryScopedRoutes,
@@ -56,6 +72,34 @@ func getV1RegistryRoutes(
 	var routes []*router.Route
 
 	// POST /api/v1/projects/{project_id}/registries -> registry.NewRegistryCreateHandler
+	// swagger:operation POST /api/v1/projects/{project_id}/registries createRegistry
+	//
+	// Connects a new image registry to the project denoted by `project_id`.
+	//
+	// ---
+	// 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'
+	//   '400':
+	//     description: A malformed or bad request
+	//   '403':
+	//     description: Forbidden
+	//   '404':
+	//     description: A subresource was not found
 	createRegistryEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
@@ -84,6 +128,27 @@ func getV1RegistryRoutes(
 	})
 
 	// 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 denoted by `registry_id`. The registry should belong to the
+	// project denoted by `project_id`.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Get an image registry
+	// tags:
+	// - Registries
+	// parameters:
+	//   - name: project_id
+	//   - name: registry_id
+	// responses:
+	//   '200':
+	//     description: Successfully got the registry
+	//     schema:
+	//       $ref: '#/definitions/GetRegistryResponse'
+	//   '403':
+	//     description: Forbidden
 	getEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
@@ -112,6 +177,26 @@ func getV1RegistryRoutes(
 	})
 
 	// GET /api/v1/projects/{project_id}/registries -> registry.NewRegistryListHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/registries listRegistries
+	//
+	// Lists all registries connected to the project denoted by `project_id`.
+	//
+	// ---
+	// 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(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbList,
@@ -139,6 +224,25 @@ func getV1RegistryRoutes(
 	})
 
 	// DELETE /api/v1/projects/{project_id}/registries/{registry_id} -> registry.NewRegistryDeleteHandler
+	// swagger:operation DELETE /api/v1/projects/{project_id}/registries/{registry_id} deleteRegistry
+	//
+	// Deletes a registry denoted by `registry_id`. The registry should belong to
+	// the project denoted by `project_id`.
+	//
+	// ---
+	// 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(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbDelete,
@@ -167,6 +271,30 @@ func getV1RegistryRoutes(
 	})
 
 	// 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(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
@@ -196,6 +324,27 @@ func getV1RegistryRoutes(
 	})
 
 	// 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 denoted by `registry_id`. The registry
+	// should belong to the project denoted by `project_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(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbList,
@@ -224,6 +373,54 @@ func getV1RegistryRoutes(
 	})
 
 	// 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 the image repository denoted by the name `repository`. The repository should belong
+	// to the registry denoted by `registry_id` which should itself belong to the project denoted by
+	// `project_id`.
+	//
+	// ---
+	// 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
+	//   - name: num
+	//     in: query
+	//     description: |
+	//       The number of images to list.
+	//       For ECR images, a maximum of 1000 is allowed.
+	//     type: integer
+	//     required: false
+	//     minimum: 1
+	//   - name: next
+	//     in: query
+	//     description: The next page string used for pagination, from a previous request.
+	//     type: string
+	//   - name: page
+	//     in: query
+	//     description: |
+	//       The page number used for pagination, possibly from a previous request.
+	//       (**DigitalOcean only**)
+	//     type: integer
+	//     minimum: 1
+	// responses:
+	//   '200':
+	//     description: Successfully listed images
+	//     schema:
+	//       $ref: '#/definitions/V1ListImageResponse'
+	//   '400':
+	//     description: A malformed or bad request
+	//   '403':
+	//     description: Forbidden
 	listImagesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbList,
@@ -244,8 +441,9 @@ func getV1RegistryRoutes(
 		},
 	)
 
-	listImagesHandler := registry.NewRegistryListImagesHandler(
+	listImagesHandler := v1Registry.NewRegistryListImagesHandler(
 		config,
+		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 

+ 168 - 1
api/server/router/v1/release.go

@@ -4,12 +4,50 @@ import (
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/api/server/handlers/namespace"
 	"github.com/porter-dev/porter/api/server/handlers/release"
+	v1Release "github.com/porter-dev/porter/api/server/handlers/v1/release"
 	"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 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 {
 	return &router.Registerer{
 		GetRoutes: GetV1ReleaseScopedRoutes,
@@ -55,6 +93,39 @@ func getV1ReleaseRoutes(
 	var routes []*router.Route
 
 	// 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 in the namespace denoted by `namespace`. The namespace should belong to the
+	// cluster denoted by `cluster_id` which itself should belong to the project denoted by `project_id`.
+	//
+	// ---
+	// 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
+	//   '400':
+	//     description: A malformed or bad request
+	//   '403':
+	//     description: Forbidden
+	//   '404':
+	//     description: A subresource was not found
+	//   '409':
+	//     description: A conflict occurred with another external service
+	//   '412':
+	//     description: A precondition failed for the request
 	createReleaseEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
@@ -85,6 +156,31 @@ func getV1ReleaseRoutes(
 	})
 
 	// 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 the release denoted by the name `name` and its version `version`. The release should belong to the namespace
+	// denoted by `namespace` which itself should belong to the cluster denoted by `cluster_id` and project
+	// denoted by `project_id`.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Get a release
+	// tags:
+	// - Releases
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: name
+	//   - name: version
+	// responses:
+	//   '200':
+	//     description: Successfully got the release
+	//     schema:
+	//       $ref: '#/definitions/GetReleaseResponse'
+	//   '403':
+	//     description: Forbidden
 	getEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
@@ -115,6 +211,24 @@ func getV1ReleaseRoutes(
 	})
 
 	// 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 all releases in the namespace denoted by `namespace`. The namespace should belong to the cluster
+	// denoted by `cluster_id` and project denoted by `project_id`.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: List releases
+	// tags:
+	// - Releases
+	// responses:
+	//   '201':
+	//     description: Successfully listed releases
+	//     schema:
+	//       $ref: '#/definitions/ListReleasesResponse'
+	//   '403':
+	//     description: Forbidden
 	listReleasesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
@@ -146,6 +260,36 @@ func getV1ReleaseRoutes(
 
 	// PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} ->
 	// release.NewUpgradeReleaseHandler
+	// swagger:operation PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} updateRelease
+	//
+	// Upgrades the release with the name denoted by `name` and version denoted by `version`. The release should belong
+	// to the namespace denoted by `namespace` which itself should belong to the cluster denoted by `cluster_id` and project
+	// denoted by `project_id`.
+	//
+	// ---
+	// 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
+	//   '400':
+	//     description: A malformed or bad request
+	//   '403':
+	//     description: Forbidden
 	upgradeEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbUpdate,
@@ -164,7 +308,7 @@ func getV1ReleaseRoutes(
 		},
 	)
 
-	upgradeHandler := release.NewUpgradeReleaseHandler(
+	upgradeHandler := v1Release.NewUpgradeReleaseHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
@@ -178,6 +322,29 @@ func getV1ReleaseRoutes(
 
 	// DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} ->
 	// release.NewDeleteReleaseHandler
+	// swagger:operation DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version} deleteRelease
+	//
+	// Deletes the release with the name denoted by `name` and version denoted by `version`. The release should belong
+	// to the namespace denoted by `namespace` which itself should belong to the cluster denoted by `cluster_id` and project
+	// denoted by `project_id`.
+	//
+	// ---
+	// 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(
 		&types.APIRequestMetadata{
 			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 new stack and triggers a deployment for all resources in the stack.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Create a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - in: body
+	//     name: CreateStackRequest
+	//     description: The stack to create
+	//     schema:
+	//       $ref: '#/definitions/CreateStackRequest'
+	// responses:
+	//   '201':
+	//     description: Successfully created the stack
+	//     schema:
+	//       $ref: '#/definitions/Stack'
+	//   '403':
+	//     description: Forbidden
+	createEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	createHandler := stack.NewStackCreateHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: createEndpoint,
+		Handler:  createHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks -> stack.NewStackListHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks listStacks
+	//
+	// Lists stacks in a namespace
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: List stacks
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	// responses:
+	//   '200':
+	//     description: Successfully listed stacks
+	//     schema:
+	//       $ref: '#/definitions/StackListResponse'
+	//   '403':
+	//     description: Forbidden
+	listEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	listHandler := stack.NewStackListHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listEndpoint,
+		Handler:  listHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id} -> stack.NewStackGetHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id} getStack
+	//
+	// Gets a stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Get a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	// responses:
+	//   '200':
+	//     description: Successfully got the stack
+	//     schema:
+	//       $ref: '#/definitions/Stack'
+	//   '403':
+	//     description: Forbidden
+	getEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	getHandler := stack.NewStackGetHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getEndpoint,
+		Handler:  getHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/{stack_revision_number} -> stack.NewStackGetRevisionHandler
+	// swagger:operation GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/{stack_revision_number} getStackRevision
+	//
+	// Gets a stack revision
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Get a stack revision
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - name: stack_revision_number
+	// responses:
+	//   '200':
+	//     description: Successfully got the stack revision
+	//     schema:
+	//       $ref: '#/definitions/StackRevision'
+	//   '403':
+	//     description: Forbidden
+	getRevisionEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/{stack_revision_number}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	getRevisionHandler := stack.NewStackGetRevisionHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getRevisionEndpoint,
+		Handler:  getRevisionHandler,
+		Router:   r,
+	})
+
+	// PUT /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/source -> stack.NewStackPutSourceConfig
+	// swagger:operation PUT /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/source putStackSource
+	//
+	// Updates a stack's source configuration
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Update source configuration
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - in: body
+	//     name: PutStackSourceConfigRequest
+	//     description: The source configurations to update
+	//     schema:
+	//       $ref: '#/definitions/PutStackSourceConfigRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully updated the source configuration
+	//     schema:
+	//       $ref: '#/definitions/Stack'
+	//   '403':
+	//     description: Forbidden
+	putSourceEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPut,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/source",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	putSourceHandler := stack.NewStackPutSourceConfigHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: putSourceEndpoint,
+		Handler:  putSourceHandler,
+		Router:   r,
+	})
+
+	// POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/rollback -> stack.NewStackRollbackHandler
+	// swagger:operation POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/rollback rollbackStack
+	//
+	// Performs a rollback for a stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Rollback stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - in: body
+	//     name: StackRollbackRequest
+	//     description: The target revision to roll back to
+	//     schema:
+	//       $ref: '#/definitions/StackRollbackRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully rolled the stack back
+	//     schema:
+	//       $ref: '#/definitions/Stack'
+	//   '403':
+	//     description: Forbidden
+	rollbackEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/rollback",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	rollbackHandler := stack.NewStackRollbackHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: rollbackEndpoint,
+		Handler:  rollbackHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id} -> stack.NewStackDeleteHandler
+	// swagger:operation DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id} deleteStack
+	//
+	// Deletes a stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Delete a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	// responses:
+	//   '200':
+	//     description: Successfully deleted the stack
+	//   '403':
+	//     description: Forbidden
+	deleteEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	deleteHandler := stack.NewStackDeleteHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: deleteEndpoint,
+		Handler:  deleteHandler,
+		Router:   r,
+	})
+
+	return routes, newPath
+}

+ 1 - 0
api/server/shared/config/env/envconfs.go

@@ -46,6 +46,7 @@ type ServerConf struct {
 	GithubAppWebhookSecret string `env:"GITHUB_APP_WEBHOOK_SECRET"`
 	GithubAppID            string `env:"GITHUB_APP_ID"`
 	GithubAppSecretPath    string `env:"GITHUB_APP_SECRET_PATH"`
+	GithubAppSecret        []byte
 
 	GoogleClientID         string `env:"GOOGLE_CLIENT_ID"`
 	GoogleClientSecret     string `env:"GOOGLE_CLIENT_SECRET"`

+ 9 - 0
api/server/shared/config/loader/loader.go

@@ -2,6 +2,7 @@ package loader
 
 import (
 	"fmt"
+	"io/ioutil"
 	"net/http"
 	"strconv"
 
@@ -167,6 +168,14 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 				BaseURL:      sc.ServerURL,
 			}, sc.GithubAppName, sc.GithubAppWebhookSecret, sc.GithubAppSecretPath, AppID)
 		}
+
+		secret, err := ioutil.ReadFile(sc.GithubAppSecretPath)
+
+		if err != nil {
+			return nil, fmt.Errorf("could not read github app secret: %s", err)
+		}
+
+		sc.GithubAppSecret = append(sc.GithubAppSecret, secret...)
 	}
 
 	if sc.SlackClientID != "" && sc.SlackClientSecret != "" {

+ 11 - 4
api/types/build_config.go

@@ -1,16 +1,23 @@
 package types
 
-// BuildConfig
+// The build configuration for this release when using buildpacks
 type BuildConfig struct {
 	Builder    string   `json:"builder"`
 	Buildpacks []string `json:"buildpacks"`
 	Config     []byte   `json:"config"`
 }
 
+// The build configuration for this new release
 type CreateBuildConfigRequest struct {
-	Builder    string                 `json:"builder" form:"required"`
-	Buildpacks []string               `json:"buildpacks"`
-	Config     map[string]interface{} `json:"config,omitempty"`
+	// The name of the builder to use with `pack` (Heroku or Paketo)
+	// required: true
+	Builder string `json:"builder" form:"required"`
+
+	// The list of buildpacks to use for the release
+	Buildpacks []string `json:"buildpacks"`
+
+	// UNUSED
+	Config map[string]interface{} `json:"config,omitempty"`
 }
 
 type UpdateBuildConfigRequest struct {

+ 29 - 10
api/types/cluster.go

@@ -2,7 +2,6 @@ package types
 
 import (
 	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
-	v1 "k8s.io/api/core/v1"
 )
 
 const (
@@ -185,19 +184,39 @@ const (
 	AWSData          ClusterResolverName = "upload-aws-data"
 )
 
-type ListNamespacesResponse struct {
-	*v1.NamespaceList
-}
-
-type CreateNamespaceRequest struct {
+// NamespaceResponse represents the response type of requests to the namespace resource
+//
+// swagger:model
+type NamespaceResponse struct {
+	// the name of the namespace
+	// example: default
 	Name string `json:"name" form:"required"`
-}
 
-type CreateNamespaceResponse struct {
-	*v1.Namespace
+	// the creation timestamp in UTC of the namespace in RFC 1123 format
+	// example: Mon, 13 Jun 2022 17:49:12 GMT
+	CreationTimestamp string `json:"creationTimestamp" form:"required"`
+
+	// the deletion timestamp in UTC of the namespace in RFC 1123 format, if the namespace is deleted
+	// example: Mon, 13 Jun 2022 17:49:12 GMT
+	DeletionTimestamp string `json:"deletionTimestamp,omitempty"`
+
+	// the status of the namespace
+	// enum: active,terminating
+	// example: active
+	Status string `json:"status" form:"required"`
 }
 
-type DeleteNamespaceRequest struct {
+// ListNamespacesResponse represents the list of all namespaces
+//
+// swagger:model
+type ListNamespacesResponse []*NamespaceResponse
+
+// CreateNamespaceRequest represents the request body to create a namespace
+//
+// swagger:model
+type CreateNamespaceRequest struct {
+	// the name of the namespace to create
+	// example: sampleNS
 	Name string `json:"name" form:"required"`
 }
 

+ 28 - 9
api/types/git_action_config.go

@@ -1,6 +1,6 @@
 package types
 
-// GitActionConfig
+// The git configuration for this release (when deployed from a git repository)
 type GitActionConfig struct {
 	// The git repo in ${owner}/${repo} form
 	GitRepo string `json:"git_repo"`
@@ -24,15 +24,34 @@ type GitActionConfig struct {
 	FolderPath string `json:"folder_path"`
 }
 
+// The git configuration for this new release (when deploying from a git repository)
 type CreateGitActionConfigRequest struct {
-	GitRepo             string `json:"git_repo" form:"required"`
-	GitBranch           string `json:"git_branch"`
-	ImageRepoURI        string `json:"image_repo_uri" form:"required"`
-	DockerfilePath      string `json:"dockerfile_path"`
-	FolderPath          string `json:"folder_path"`
-	GitRepoID           uint   `json:"git_repo_id"`
-	GitlabIntegrationID uint   `json:"gitlab_integration_id"`
-	RegistryID          uint   `json:"registry_id"`
+	// The git repo in ${owner}/${repo} form
+	// required: true
+	GitRepo string `json:"git_repo" form:"required"`
+
+	// The branch name of the git repository
+	GitBranch string `json:"git_branch"`
+
+	// The complete image repository URI to pull from
+	// required: true
+	ImageRepoURI string `json:"image_repo_uri" form:"required"`
+
+	// The path to the Dockerfile in the git repository
+	DockerfilePath string `json:"dockerfile_path"`
+
+	// The path to use as the base directory in the git repository
+	FolderPath string `json:"folder_path"`
+
+	// The Github installation ID with access to the repository
+	GitRepoID uint `json:"git_repo_id"`
+
+	// The Gitlab integration ID with access to the repository
+	GitlabIntegrationID uint `json:"gitlab_integration_id"`
+
+	// The Porter registry ID to link against
+	RegistryID uint `json:"registry_id"`
 
+	// Denotes if the Github workflow files need to be created
 	ShouldCreateWorkflow bool `json:"should_create_workflow"`
 }

+ 25 - 4
api/types/namespace.go

@@ -16,10 +16,30 @@ const (
 // ReleaseListFilter is a struct that represents the various filter options used for
 // retrieving the releases
 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"`
 }
 
@@ -60,6 +80,7 @@ type ListReleasesRequest struct {
 	*ReleaseListFilter
 }
 
+// swagger:model
 type ListReleasesResponse []*release.Release
 
 type GetConfigMapRequest struct {

+ 2 - 0
api/types/policy.go

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

+ 1 - 0
api/types/project.go

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

+ 93 - 10
api/types/registry.go

@@ -7,37 +7,58 @@ const (
 )
 
 type Registry struct {
+	// The ID of the registry
+	// minimum: 1
+	// example: 2
 	ID uint `json:"id"`
 
 	// The project that this integration belongs to
+	// minimum: 1
+	// example: 1
 	ProjectID uint `json:"project_id"`
 
 	// Name of the registry
+	// example: my-ecr-reg
 	Name string `json:"name"`
 
 	// URL of the registry
+	// example: 123456789.dkr.ecr.us-west-2.amazonaws.com
 	URL string `json:"url"`
 
 	// The integration service for this registry
-	Service RegistryService `json:"service"`
+	// enum: gcr,ecr,acr,docr,dockerhub
+	// example: ecr
+	Service string `json:"service"`
 
 	// The infra id, if registry was provisioned with Porter
+	// minimum: 1
+	// example: 2
 	InfraID uint `json:"infra_id"`
 
 	// The AWS integration that was used to create or connect the registry
+	// minimum: 1
+	// example: 1
 	AWSIntegrationID uint `json:"aws_integration_id,omitempty"`
 
 	// The Azure integration that was used to create or connect the registry
+	// minimum: 1
+	// example: 0
 	AzureIntegrationID uint `json:"azure_integration_id,omitempty"`
 
 	// The GCP integration that was used to create or connect the registry
+	// minimum: 1
+	// example: 0
 	GCPIntegrationID uint `json:"gcp_integration_id,omitempty"`
 
 	// The DO integration that was used to create or connect the registry:
 	// this points to an OAuthIntegrationID
+	// minimum: 1
+	// example: 0
 	DOIntegrationID uint `json:"do_integration_id,omitempty"`
 
-	// The basic integration that was used to connect the registry:
+	// The basic integration that was used to connect the registry.
+	// minimum: 1
+	// example: 0
 	BasicIntegrationID uint `json:"basic_integration_id,omitempty"`
 }
 
@@ -71,6 +92,7 @@ type Image struct {
 	PushedAt *time.Time `json:"pushed_at"`
 }
 
+// Type of registry service
 type RegistryService string
 
 const (
@@ -81,23 +103,64 @@ const (
 	DockerHub RegistryService = "dockerhub"
 )
 
+// swagger:model ListRegistriesResponse
 type RegistryListResponse []Registry
 
+// swagger:model
 type CreateRegistryRequest struct {
-	URL                string `json:"url"`
-	Name               string `json:"name" form:"required"`
-	GCPIntegrationID   uint   `json:"gcp_integration_id"`
-	AWSIntegrationID   uint   `json:"aws_integration_id"`
-	DOIntegrationID    uint   `json:"do_integration_id"`
-	BasicIntegrationID uint   `json:"basic_integration_id"`
-	AzureIntegrationID uint   `json:"azure_integration_id"`
+	// URL of the container registry
+	// example: 123456789.dkr.ecr.us-west-2.amazonaws.com
+	URL string `json:"url"`
+
+	// Name of the container registry
+	// required: true
+	// example: my-ecr-reg
+	Name string `json:"name" form:"required"`
+
+	// The GCP integration ID to be used for this registry
+	// minimum: 1
+	// example: 0
+	GCPIntegrationID uint `json:"gcp_integration_id"`
+
+	// The AWS integration ID to be used for this registry
+	// minimum: 1
+	// example: 1
+	AWSIntegrationID uint `json:"aws_integration_id"`
+
+	// The DigitalOcean integration ID to be used for this registry
+	// minimum: 1
+	// example: 0
+	DOIntegrationID uint `json:"do_integration_id"`
+
+	// The Basic integration ID to be used for this registry
+	// minimum: 1
+	// example: 0
+	BasicIntegrationID uint `json:"basic_integration_id"`
+
+	// The Azure integration ID to be used for this registry
+	// minimum: 1
+	// example: 0
+	AzureIntegrationID uint `json:"azure_integration_id"`
 
 	// Additional Azure-specific fields
+
+	// ACR resource group name (**Azure only**)
 	ACRResourceGroupName string `json:"acr_resource_group_name"`
-	ACRName              string `json:"acr_name"`
+
+	// ACR name (**Azure only**)
+	ACRName string `json:"acr_name"`
 }
 
+// swagger:model
+type CreateRegistryResponse Registry
+
+// swagger:model
+type GetRegistryResponse Registry
+
+// swagger:model
 type CreateRegistryRepositoryRequest struct {
+	// The URL to the repository of a registry (**ECR only**)
+	// required: true
 	ImageRepoURI string `json:"image_repo_uri" form:"required"`
 }
 
@@ -125,6 +188,26 @@ type GetRegistryDOCRTokenRequest struct {
 	ServerURL string `schema:"server_url"`
 }
 
+// swagger:model ListRegistryRepositoriesResponse
 type ListRegistryRepositoryResponse []*RegistryRepository
 
+// swagger:model ListImagesResponse
 type ListImageResponse []*Image
+
+type V1ListImageRequest struct {
+	Num  int64  `schema:"num"`
+	Next string `schema:"next"`
+	Page uint   `schema:"page"`
+}
+
+// swagger:model V1ListImageResponse
+type V1ListImageResponse struct {
+	// The list of repository images with tags
+	Images []*Image `json:"images" form:"required"`
+
+	// The next page number used for pagination (**DigitalOcean only**)
+	NextPage uint `json:"next_page,omitempty"`
+
+	// The next page cursor used for pagination
+	Next string `json:"next,omitempty"`
+}

+ 65 - 15
api/types/release.go

@@ -15,15 +15,32 @@ type Release struct {
 }
 
 type PorterRelease struct {
-	ID              uint             `json:"id"`
-	WebhookToken    string           `json:"webhook_token"`
-	LatestVersion   string           `json:"latest_version"`
+	// The ID of this Porter release
+	ID uint `json:"id"`
+
+	// The webhook token used to secure Github repository webhooks
+	WebhookToken string `json:"webhook_token"`
+
+	// The latest version of this Porter release
+	LatestVersion string `json:"latest_version"`
+
+	// Configuration regarding the connected git repository
 	GitActionConfig *GitActionConfig `json:"git_action_config,omitempty"`
-	ImageRepoURI    string           `json:"image_repo_uri"`
-	BuildConfig     *BuildConfig     `json:"build_config,omitempty"`
-	Tags            []string         `json:"tags,omitempty"`
+
+	// The complete image repository URI for this release
+	ImageRepoURI string `json:"image_repo_uri"`
+
+	// The build configuration for this release when using buildpacks
+	BuildConfig *BuildConfig `json:"build_config,omitempty"`
+
+	// The list of tags for this release
+	Tags []string `json:"tags,omitempty"`
+
+	// Whether this release is tied to a stack or not
+	IsStack bool `json:"is_stack"`
 }
 
+// swagger:model
 type GetReleaseResponse Release
 
 type UpdateNotificationConfigRequest struct {
@@ -35,21 +52,44 @@ type UpdateNotificationConfigRequest struct {
 }
 
 type CreateReleaseBaseRequest struct {
-	RepoURL         string                 `schema:"repo_url"`
-	TemplateName    string                 `json:"template_name" form:"required"`
-	TemplateVersion string                 `json:"template_version" form:"required"`
-	Values          map[string]interface{} `json:"values"`
-	Name            string                 `json:"name" form:"required"`
+	// The repository URL for this release
+	RepoURL string `json:"repo_url,omitempty" schema:"repo_url"`
+
+	// the Porter charts templated name
+	// required: true
+	TemplateName string `json:"template_name" form:"required"`
+
+	// The Porter charts template version
+	// required: true
+	TemplateVersion string `json:"template_version" form:"required"`
+
+	// The Helm values for this release
+	Values map[string]interface{} `json:"values"`
+
+	// The name of this release
+	// required: true
+	Name string `json:"name" form:"required"`
 }
 
+// swagger:model
 type CreateReleaseRequest struct {
 	*CreateReleaseBaseRequest
 
-	ImageURL        string                        `json:"image_url" form:"required"`
+	// The repository image URL for this release
+	// required: true
+	ImageURL string `json:"image_url" form:"required"`
+
+	// Configuration regarding the connected git repository
 	GitActionConfig *CreateGitActionConfigRequest `json:"git_action_config,omitempty"`
-	BuildConfig     *CreateBuildConfigRequest     `json:"build_config,omitempty"`
-	Tags            []string                      `json:"tags,omitempty"`
-	SyncedEnvGroups []string                      `json:"synced_env_groups,omitempty"`
+
+	// Build configuration options for this release
+	BuildConfig *CreateBuildConfigRequest `json:"build_config,omitempty"`
+
+	// The list of tags for this release
+	Tags []string `json:"tags,omitempty"`
+
+	// The list of synced environment groups for this release
+	SyncedEnvGroups []string `json:"synced_env_groups,omitempty"`
 }
 
 type CreateAddonRequest struct {
@@ -62,6 +102,16 @@ type RollbackReleaseRequest struct {
 	Revision int `json:"revision" form:"required"`
 }
 
+// swagger:model UpdateReleaseRequest
+type V1UpgradeReleaseRequest struct {
+	// The Helm values to upgrade the release with
+	// required: true
+	Values map[string]interface{} `json:"values" form:"required"`
+
+	// The Porter charts version to upgrade the release with
+	ChartVersion string `json:"version"`
+}
+
 type UpgradeReleaseRequest struct {
 	Values       string `json:"values" form:"required"`
 	ChartVersion string `json:"version"`

+ 1 - 0
api/types/request.go

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

+ 263 - 0
api/types/stacks.go

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

+ 5 - 3
cli/cmd/cluster.go

@@ -144,7 +144,7 @@ func listNamespaces(user *types.GetAuthenticatedUserResponse, client *api.Client
 	cID := cliConf.Cluster
 
 	// get the list of namespaces
-	namespaces, err := client.GetK8sNamespaces(
+	namespaceList, err := client.GetK8sNamespaces(
 		context.Background(),
 		pID,
 		cID,
@@ -159,8 +159,10 @@ func listNamespaces(user *types.GetAuthenticatedUserResponse, client *api.Client
 
 	fmt.Fprintf(w, "%s\t%s\n", "NAME", "STATUS")
 
-	for _, namespace := range namespaces.Items {
-		fmt.Fprintf(w, "%s\t%s\n", namespace.Name, namespace.Status.Phase)
+	namespaces := *namespaceList
+
+	for _, namespace := range namespaces {
+		fmt.Fprintf(w, "%s\t%s\n", namespace.Name, namespace.Status)
 	}
 
 	w.Flush()

+ 2 - 0
cmd/app/main.go

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

+ 3 - 1
dashboard/src/components/repo-selector/RepoList.tsx

@@ -1,6 +1,6 @@
 import React, { useContext, useEffect, useRef, useState } from "react";
 import styled from "styled-components";
-import github from "assets/github.png";
+import github from "assets/github-white.png";
 
 import api from "shared/api";
 import { ActionConfigType, RepoType } from "shared/types";
@@ -401,6 +401,8 @@ const ProviderSelectorStyles = {
     position: absolute;
     background: #37393f;
     border-radius: 3px;
+    max-height: 300px;
+    overflow-y: auto;
     width: calc(100% - 4px);
     box-shadow: 0 8px 20px 0px #00000088;
   `,

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

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

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

@@ -29,8 +29,6 @@ import LastRunStatusSelector from "./LastRunStatusSelector";
 import loadable from "@loadable/component";
 import Loading from "components/Loading";
 import JobRunTable from "./chart/JobRunTable";
-import SwitchBase from "@material-ui/core/internal/SwitchBase";
-import Selector from "components/Selector";
 import TabSelector from "components/TabSelector";
 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 &
   WithAuthProps & {
     currentCluster: ClusterType;
@@ -273,6 +279,9 @@ class ClusterDashboard extends Component<PropsType, StateType> {
     let { setSidebar } = this.props;
     return (
       <Switch>
+        <Route path={"/stacks"}>
+          <LazyStackRoutes />
+        </Route>
         <Route path={"/preview-environments"}>
           <LazyPreviewEnvironmentsRoutes />
         </Route>
@@ -338,11 +347,6 @@ const HidableElement = styled.div<{ show: boolean }>`
   display: ${(props) => (props.show ? "unset" : "none")};
 `;
 
-const Br = styled.div`
-  width: 100%;
-  height: 1px;
-`;
-
 const ControlRow = styled.div`
   display: flex;
   margin-left: auto;
@@ -352,39 +356,6 @@ const ControlRow = styled.div`
   padding-left: 0px;
 `;
 
-const TopRow = styled.div`
-  display: flex;
-  align-items: center;
-`;
-
-const Description = styled.div`
-  color: #aaaabb;
-  margin-top: 13px;
-  margin-left: 2px;
-  font-size: 13px;
-`;
-
-const InfoLabel = styled.div`
-  width: 72px;
-  height: 20px;
-  display: flex;
-  align-items: center;
-  color: #7a838f;
-  font-size: 13px;
-  > i {
-    color: #8b949f;
-    font-size: 18px;
-    margin-right: 5px;
-  }
-`;
-
-const InfoSection = styled.div`
-  margin-top: 20px;
-  font-family: "Work Sans", sans-serif;
-  margin-left: 0px;
-  margin-bottom: 35px;
-`;
-
 const Button = styled.div`
   display: flex;
   flex-direction: row;
@@ -431,67 +402,6 @@ const Button = styled.div`
   }
 `;
 
-const ButtonAlt = styled(Button)`
-  min-width: 150px;
-  max-width: 150px;
-  background: #7a838fdd;
-
-  :hover {
-    background: #69727eee;
-  }
-`;
-
-const LineBreak = styled.div`
-  width: calc(100% - 0px);
-  height: 2px;
-  background: #ffffff20;
-  margin: 10px 0px 35px;
-`;
-
-const Overlay = styled.div`
-  height: 100%;
-  width: 100%;
-  position: absolute;
-  background: #00000028;
-  top: 0;
-  left: 0;
-  border-radius: 5px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  font-size: 24px;
-  font-weight: 500;
-  font-family: "Work Sans", sans-serif;
-  color: white;
-`;
-
-const DashboardImage = styled.img`
-  height: 45px;
-  width: 45px;
-  border-radius: 5px;
-`;
-
-const DashboardIcon = styled.div`
-  position: relative;
-  height: 45px;
-  min-width: 45px;
-  width: 45px;
-  border-radius: 5px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: #676c7c;
-  border: 2px solid #8e94aa;
-
-  > i {
-    font-size: 22px;
-  }
-`;
-
-const Img = styled.img`
-  width: 30px;
-`;
-
 const SortFilterWrapper = styled.div`
   display: flex;
   justify-content: space-between;
@@ -499,4 +409,4 @@ const SortFilterWrapper = styled.div`
   > div:not(:first-child) {
     margin-left: 30px;
   }
-`;
+`;

+ 6 - 6
dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx

@@ -50,18 +50,18 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
           }
 
           let defaultNamespace = "default";
-          const availableNamespaces = res.data.items.filter(
+          const availableNamespaces = res.data.filter(
             (namespace: any) => {
-              return namespace.status.phase !== "Terminating";
+              return namespace.status !== "Terminating";
             }
           );
           availableNamespaces.forEach(
-            (x: { metadata: { name: string } }, i: number) => {
+            (x: { name: string }, i: number) => {
               namespaceOptions.push({
-                label: x.metadata.name,
-                value: x.metadata.name,
+                label: x.name,
+                value: x.name,
               });
-              if (x.metadata.name === urlNamespace) {
+              if (x.name === urlNamespace) {
                 defaultNamespace = urlNamespace;
               }
             }

+ 27 - 6
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -28,6 +28,7 @@ type Props = {
   disableBottomPadding?: boolean;
   closeChartRedirectUrl?: string;
   selectedTag?: any;
+  appFilters?: string[];
 };
 
 interface JobStatusWithTimeAndVersion extends JobStatusWithTimeType {
@@ -42,6 +43,7 @@ const ChartList: React.FunctionComponent<Props> = ({
   disableBottomPadding,
   closeChartRedirectUrl,
   selectedTag,
+  appFilters,
 }) => {
   const {
     newWebsocket,
@@ -336,12 +338,22 @@ const ChartList: React.FunctionComponent<Props> = ({
         });
       })
       .filter((chart: ChartType) => {
-        return (
-          (currentView == "jobs" && chart.chart.metadata.name == "job") ||
-          ((currentView == "applications" ||
-            currentView == "cluster-dashboard") &&
-            chart.chart.metadata.name != "job")
-        );
+        if (currentView === "jobs" && chart.chart.metadata.name === "job") {
+          return true;
+        }
+
+        if (
+          ["applications", "cluster-dashboard"].includes(currentView) &&
+          chart.chart.metadata.name !== "job"
+        ) {
+          return true;
+        }
+
+        if (currentView === "stacks") {
+          return true;
+        }
+
+        return false;
       })
       .filter((chart: ChartType) => {
         if (currentView !== "jobs") {
@@ -356,6 +368,15 @@ const ChartList: React.FunctionComponent<Props> = ({
           { status: null } as any
         );
         return status.status === lastRunStatus;
+      })
+      .filter((chart: ChartType) => {
+        if (!Array.isArray(appFilters) || appFilters?.length === 0) {
+          return true;
+        }
+
+        return appFilters.some((filter) => {
+          return chart.name.toLowerCase() === filter.toLowerCase();
+        });
       });
 
     if (sortType == "Newest") {

+ 5 - 5
dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx

@@ -122,14 +122,14 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
       )
       .then((res) => {
         if (res.data) {
-          const availableNamespaces = res.data.items.filter(
+          const availableNamespaces = res.data.filter(
             (namespace: any) => {
-              return namespace.status.phase !== "Terminating";
+              return namespace.status !== "Terminating";
             }
           );
           const namespaceOptions = availableNamespaces.map(
-            (x: { metadata: { name: string } }) => {
-              return { label: x.metadata.name, value: x.metadata.name };
+            (x: { name: string }) => {
+              return { label: x.name, value: x.name };
             }
           );
           if (availableNamespaces.length > 0) {
@@ -340,7 +340,7 @@ const HeaderSection = styled.div`
 
   > i {
     cursor: pointer;
-    font-size 20px;
+    font-size: 20px;
     color: #969Fbbaa;
     padding: 2px;
     border: 2px solid #969fbbaa;

+ 3 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/BuildSettingsTab.tsx

@@ -232,7 +232,7 @@ const BuildSettingsTab: React.FC<Props> = ({ chart, isPreviousVersion }) => {
     }
   };
 
-  const getActionConfig = () => {
+  const currentActionConfig = useMemo(() => {
     const actionConf = chart.git_action_config;
     if (actionConf && actionConf.gitlab_integration_id) {
       return {
@@ -245,7 +245,7 @@ const BuildSettingsTab: React.FC<Props> = ({ chart, isPreviousVersion }) => {
       kind: "github",
       ...actionConf,
     } as FullActionConfigType;
-  };
+  }, [chart]);
 
   return (
     <Wrapper>
@@ -308,7 +308,7 @@ const BuildSettingsTab: React.FC<Props> = ({ chart, isPreviousVersion }) => {
             <Heading>Buildpack Settings</Heading>
             <BuildpackConfigSection
               currentChart={chart}
-              actionConfig={getActionConfig()}
+              actionConfig={currentActionConfig}
               onChange={(buildConfig) => setBuildConfig(buildConfig)}
             />
           </>

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -528,7 +528,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
       );
     }
 
-    if (currentChart?.git_action_config?.git_repo) {
+    if (currentChart?.git_action_config?.git_repo && !currentChart.is_stack) {
       rightTabOptions.push({
         label: "Build Settings",
         value: "build-settings",

+ 19 - 6
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChartWrapper.tsx

@@ -10,7 +10,7 @@ import {
 } from "shared/types";
 import api from "shared/api";
 import { getQueryParam, pushFiltered } from "shared/routing";
-import ExpandedJobChart, { ExpandedJobChartFC } from "./ExpandedJobChart";
+import { ExpandedJobChartFC } from "./ExpandedJobChart";
 import ExpandedChart from "./ExpandedChart";
 import Loading from "components/Loading";
 import PageNotFound from "components/PageNotFound";
@@ -61,12 +61,18 @@ class ExpandedChartWrapper extends Component<PropsType, StateType> {
           }/${chart.namespace}/${chart.name}`;
 
           if (isJob && this.props.match.params?.baseRoute === "applications") {
-            pushFiltered(this.props, route, ["project_id"]);
+            pushFiltered(this.props, route, [
+              "project_id",
+              "closeChartRedirectUrl",
+            ]);
             return;
           }
 
           if (!isJob && this.props.match.params?.baseRoute !== "applications") {
-            pushFiltered(this.props, route, ["project_id"]);
+            pushFiltered(this.props, route, [
+              "project_id",
+              "closeChartRedirectUrl",
+            ]);
             return;
           }
         })
@@ -100,12 +106,19 @@ class ExpandedChartWrapper extends Component<PropsType, StateType> {
           namespace={namespace}
           currentChart={currentChart}
           currentCluster={this.context.currentCluster}
-          closeChart={() =>
+          closeChart={() => {
+            let urlParams = new URLSearchParams(window.location.search);
+
+            if (urlParams.get("closeChartRedirectUrl")) {
+              this.props.history.push(urlParams.get("closeChartRedirectUrl"));
+              return;
+            }
+
             pushFiltered(this.props, "/jobs", ["project_id"], {
               cluster: this.context.currentCluster.name,
               namespace: namespace,
-            })
-          }
+            });
+          }}
           setSidebar={setSidebar}
         />
       );

+ 5 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx

@@ -232,7 +232,11 @@ class RevisionSection extends Component<PropsType, StateType> {
             <RollbackButton
               disabled={
                 isCurrent ||
-                !this.props.isAuthorized("application", "", ["get", "update"])
+                !this.props.isAuthorized("application", "", [
+                  "get",
+                  "update",
+                ]) ||
+                this.props.chart.is_stack
               }
               onClick={() =>
                 this.setState({ rollbackRevision: revision.version })

+ 29 - 27
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -217,33 +217,35 @@ const SettingsSection: React.FC<PropsType> = ({
 
     return (
       <>
-        <>
-          <Heading>Source Settings</Heading>
-          <Helper>Specify an image tag to use.</Helper>
-          <ImageSelector
-            selectedTag={selectedTag}
-            selectedImageUrl={selectedImageUrl}
-            setSelectedImageUrl={(x: string) => setSelectedImageUrl(x)}
-            setSelectedTag={(x: string) => setSelectedTag(x)}
-            forceExpanded={true}
-            disableImageSelect={isDeployedFromGithub(currentChart)}
-          />
-          {!loadingWebhookToken && (
-            <>
-              <Br />
-              <Br />
-              <Br />
-              <SaveButton
-                clearPosition={true}
-                statusPosition="right"
-                text="Save Source Settings"
-                status={saveValuesStatus}
-                onClick={handleSubmit}
-              />
-            </>
-          )}
-          <Br />
-        </>
+        {!currentChart.is_stack ? (
+          <>
+            <Heading>Source Settings</Heading>
+            <Helper>Specify an image tag to use.</Helper>
+            <ImageSelector
+              selectedTag={selectedTag}
+              selectedImageUrl={selectedImageUrl}
+              setSelectedImageUrl={(x: string) => setSelectedImageUrl(x)}
+              setSelectedTag={(x: string) => setSelectedTag(x)}
+              forceExpanded={true}
+              disableImageSelect={isDeployedFromGithub(currentChart)}
+            />
+            {!loadingWebhookToken && (
+              <>
+                <Br />
+                <Br />
+                <Br />
+                <SaveButton
+                  clearPosition={true}
+                  statusPosition="right"
+                  text="Save Source Settings"
+                  status={saveValuesStatus}
+                  onClick={handleSubmit}
+                />
+              </>
+            )}
+            <Br />
+          </>
+        ) : null}
 
         <>
           <Heading>Redeploy Webhook</Heading>

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

@@ -0,0 +1,103 @@
+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="Stacks"
+        description="Groups of applications deployed from a shared source."
+      />
+      <ActionRow>
+        <Button to={"/stacks/launch"}>
+          <i className="material-icons">add</i>
+          Create Stack
+        </Button>
+        <NamespaceSelector
+          namespace={currentNamespace}
+          setNamespace={handleNamespaceChange}
+        />
+      </ActionRow>
+      <StackList namespace={currentNamespace} />
+    </>
+  );
+};
+
+export default Dashboard;
+
+const Button = styled(DynamicLink)`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 20px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  min-width: 130px;
+  padding-bottom: 1px;
+  margin-right: 10px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const ActionRow = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+`;

+ 153 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack.tsx

@@ -0,0 +1,153 @@
+import Loading from "components/Loading";
+import TitleSection from "components/TitleSection";
+import React, { useContext, useEffect, useState } from "react";
+import { useParams } from "react-router";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { readableDate } from "shared/string_utils";
+import styled from "styled-components";
+import ChartList from "../chart/ChartList";
+import SortSelector from "../SortSelector";
+import Status from "./components/Status";
+import {
+  Br,
+  InfoWrapper,
+  LastDeployed,
+  LineBreak,
+  SepDot,
+  Text,
+} from "./components/styles";
+import { getStackStatus, getStackStatusMessage } from "./shared";
+import { Stack } from "./types";
+
+const ExpandedStack = () => {
+  const { namespace, stack_id } = useParams<{
+    namespace: string;
+    stack_id: string;
+  }>();
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const [stack, setStack] = useState<Stack>();
+  const [sortType, setSortType] = useState("Alphabetical");
+  const [isLoading, setIsLoading] = useState(true);
+
+  useEffect(() => {
+    console.log(stack_id);
+    let isSubscribed = true;
+
+    api
+      .getStack(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          stack_id: stack_id,
+          namespace,
+        }
+      )
+      .then((res) => {
+        if (isSubscribed) {
+          setStack(res.data);
+        }
+      })
+      .finally(() => {
+        if (isSubscribed) {
+          setIsLoading(false);
+        }
+      });
+  }, [stack_id]);
+
+  if (isLoading) {
+    return <Loading />;
+  }
+
+  return (
+    <div>
+      <TitleSection
+        materialIconClass="material-icons-outlined"
+        icon={"lan"}
+        capitalize
+      >
+        {stack.name}
+      </TitleSection>
+      <Br />
+      <InfoWrapper>
+        <LastDeployed>
+          <Status
+            status={getStackStatus(stack)}
+            message={getStackStatusMessage(stack)}
+          />
+          <SepDot>•</SepDot>
+          <Text color="#aaaabb">
+            {!stack.latest_revision?.id
+              ? `No version found`
+              : `v${stack.latest_revision.id}`}
+          </Text>
+          <SepDot>•</SepDot>
+          Last updated {readableDate(stack.updated_at)}
+        </LastDeployed>
+      </InfoWrapper>
+
+      {/* Stack error message */}
+      {stack.latest_revision &&
+      stack.latest_revision.status === "failed" &&
+      stack.latest_revision.message?.length > 0 ? (
+        <StackErrorMessageStyles.Wrapper>
+          <StackErrorMessageStyles.Title color="#b7b7c9">
+            Error reason:
+          </StackErrorMessageStyles.Title>
+          <StackErrorMessageStyles.Text color="#aaaabb">
+            {stack.latest_revision.message}
+          </StackErrorMessageStyles.Text>
+        </StackErrorMessageStyles.Wrapper>
+      ) : null}
+
+      <LineBreak />
+
+      <SortSelector
+        setSortType={setSortType}
+        sortType={sortType}
+        currentView="stacks"
+      />
+
+      <ChartListWrapper>
+        <ChartList
+          currentCluster={currentCluster}
+          currentView="stacks"
+          namespace={namespace}
+          sortType="Alphabetical"
+          appFilters={
+            stack?.latest_revision?.resources?.map((res) => res.name) || []
+          }
+          closeChartRedirectUrl={`${window.location.pathname}${window.location.search}`}
+        />
+      </ChartListWrapper>
+    </div>
+  );
+};
+
+export default ExpandedStack;
+
+const ChartListWrapper = styled.div`
+  width: 100%;
+  margin: auto;
+  margin-top: 20px;
+  padding-bottom: 125px;
+`;
+
+const StackErrorMessageStyles = {
+  Text: styled(Text)`
+    font-size: 14px;
+    margin-bottom: 10px;
+  `,
+  Wrapper: styled.div`
+    display: flex;
+    flex-direction: column;
+    margin-top: 5px;
+  `,
+  Title: styled(Text)`
+    font-size: 16px;
+    font-weight: bold;
+  `,
+};

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

@@ -0,0 +1,217 @@
+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 Placeholder from "components/Placeholder";
+import styled from "styled-components";
+import { Stack } from "./types";
+import { readableDate } from "shared/string_utils";
+import { CardGrid, Card } from "./launch/components/styles";
+import Status, { StatusProps } from "./components/Status";
+import {
+  Flex,
+  InfoWrapper,
+  LastDeployed,
+  SepDot,
+  Text,
+} from "./components/styles";
+import { getStackStatus, getStackStatusMessage } from "./shared";
+
+const StackList = ({ namespace }: { namespace: string }) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [stacks, setStacks] = useState<Stack[]>(null);
+  const [isLoading, setIsLoading] = useState(true);
+  const [deleting, setDeleting] = useState<string | null>(null);
+
+  const handleDelete = (stack: Stack) => {
+    setDeleting(stack.id);
+    api
+      .deleteStack(
+        "<token>",
+        {},
+        {
+          namespace,
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          stack_id: stack.id,
+        }
+      )
+      .then(() => {
+        setStacks((prev) => prev.filter((s) => s.id !== stack.id));
+      })
+      .catch((err) => {
+        setCurrentError(err);
+      })
+      .finally(() => {
+        setDeleting(null);
+      });
+  };
+
+  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 (
+      <Placeholder height="250px">
+        <div>
+          <h3>No stacks found</h3>
+          <p>You can create a stack by clicking the "Create Stack" button.</p>
+        </div>
+      </Placeholder>
+    );
+  }
+
+  return (
+    <>
+      <CardGrid>
+        {stacks.map((stack) => (
+          <StackCard
+            as={DynamicLink}
+            key={stack?.id}
+            to={`/stacks/${namespace}/${stack?.id}`}
+          >
+            <DataContainer>
+              <StackName>
+                <StackIcon>
+                  <i className="material-icons-outlined">lan</i>
+                </StackIcon>
+                <span>{stack.name}</span>
+              </StackName>
+
+              <InfoWrapper>
+                <LastDeployed>
+                  <Status
+                    status={getStackStatus(stack)}
+                    message={getStackStatusMessage(stack)}
+                  />
+                  <SepDot>•</SepDot>
+                  <Text color="#aaaabb">
+                    {!stack.latest_revision?.id
+                      ? `No version found`
+                      : `v${stack.latest_revision.id}`}
+                  </Text>
+                  <SepDot>•</SepDot>
+                  Last updated {readableDate(stack.updated_at)}
+                </LastDeployed>
+              </InfoWrapper>
+            </DataContainer>
+            <Flex>
+              <RowButton
+                onClick={(e) => {
+                  e.preventDefault();
+                  e.stopPropagation();
+                  handleDelete(stack);
+                }}
+                disabled={
+                  deleting === stack.id || (deleting && deleting === stack.id)
+                }
+              >
+                <i className="material-icons">delete</i>
+
+                {deleting === stack.id ? <Loading /> : "Delete"}
+              </RowButton>
+            </Flex>
+          </StackCard>
+        ))}
+      </CardGrid>
+    </>
+  );
+};
+
+export default StackList;
+
+const RowButton = styled.button`
+  min-width: 82px;
+  white-space: nowrap;
+  font-size: 12px;
+  padding: 8px 10px;
+  font-weight: 400;
+  height: 32px;
+  margin-right: 5px;
+  margin-left: 10px;
+  border-radius: 5px;
+  color: #ffffff;
+  border: 1px solid #aaaabb;
+  display: flex;
+  align-items: center;
+  background: #ffffff08;
+  cursor: pointer;
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    font-size: 14px;
+    margin-right: 8px;
+  }
+`;
+
+const StackIcon = styled.div`
+  margin-bottom: -4px;
+
+  > i {
+    font-size: 18px;
+    margin-left: -1px;
+    margin-right: 9px;
+    color: #ffffff66;
+  }
+`;
+
+const StackName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+  display: flex;
+  font-size: 14px;
+  align-items: center;
+  margin-bottom: 10px;
+`;
+
+const DataContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  max-width: calc(100% - 100px);
+  overflow: hidden;
+`;
+
+const StackCard = styled(Card)`
+  font-size: 13px;
+  font-weight: 500;
+`;

+ 25 - 0
dashboard/src/main/home/cluster-dashboard/stacks/components/Status.tsx

@@ -0,0 +1,25 @@
+import React from "react";
+import { StatusStyles } from "./styles";
+import loading from "assets/loading.gif";
+
+export type StatusProps = {
+  status: "loading" | "failed" | "successful" | "unknown";
+  message: string;
+  className?: string;
+};
+
+const Status = ({ status, message, className }: StatusProps) => {
+  return (
+    <>
+      <StatusStyles.Status className={className}>
+        {status === "loading" && <StatusStyles.Spinner src={loading} />}
+        {status === "failed" && <StatusStyles.Failed />}
+        {status === "successful" && <StatusStyles.Successful />}
+        {status === "unknown" && <StatusStyles.Unknown />}
+        {message}
+      </StatusStyles.Status>
+    </>
+  );
+};
+
+export default Status;

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

@@ -0,0 +1,98 @@
+import styled from "styled-components";
+
+const StatusBase = styled.div`
+  margin-top: 1px;
+  width: 8px;
+  height: 8px;
+  border-radius: 20px;
+  margin-left: 3px;
+  margin-right: 16px;
+`;
+
+export const StatusStyles = {
+  Spinner: styled.img`
+    width: 15px;
+    height: 15px;
+    margin-right: 15px;
+    margin-bottom: -3px;
+  `,
+  Failed: styled(StatusBase)`
+    background: #ed5f85;
+  `,
+  Successful: styled(StatusBase)`
+    background: #4797ff;
+  `,
+  Unknown: styled(StatusBase)`
+    background: #f5cb42;
+  `,
+  Status: styled.div`
+    display: flex;
+    height: 20px;
+    font-size: 13px;
+    flex-direction: row;
+    text-transform: capitalize;
+    align-items: center;
+    font-family: "Work Sans", sans-serif;
+    color: #aaaabb;
+    animation: fadeIn 0.5s;
+
+    @keyframes fadeIn {
+      from {
+        opacity: 0;
+      }
+      to {
+        opacity: 1;
+      }
+    }
+  `,
+};
+
+export const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 10px 0px 35px;
+`;
+
+export const Br = styled.div`
+  width: 100%;
+  height: 1px;
+`;
+
+export const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-right: 8px;
+
+  height: 20px;
+  font-size: 13px;
+  position: relative;
+  font-weight: 400;
+  color: #ffffff66;
+  margin-left: 1px;
+`;
+
+export const LastDeployed = styled.div`
+  font-size: 13px;
+  margin-top: -1px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb66;
+`;
+
+export const Text = styled.span<{ color: string }>`
+  color: ${({ color }) => color};
+`;
+
+export const SepDot = styled.div`
+  color: #aaaabb66;
+  margin: 0 9px;
+`;
+
+export const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;

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

@@ -0,0 +1,286 @@
+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 _ from "lodash";
+import React, { useContext, useEffect, useState } from "react";
+import { useParams } from "react-router";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import { ExpandedPorterTemplate } from "shared/types";
+import { StacksLaunchContext } from "./Store";
+import DynamicLink from "components/DynamicLink";
+import styled from "styled-components";
+import Heading from "components/form-components/Heading";
+import TitleSection from "components/TitleSection";
+import { hardcodedIcons } from "shared/hardcodedNameDict";
+
+const DEFAULT_STACK_SOURCE_CONFIG_INDEX = 0;
+
+const NewApp = () => {
+  const { addAppResource, newStack } = useContext(StacksLaunchContext);
+  const { currentCluster } = useContext(Context);
+
+  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 <Wrapper><Loading /></Wrapper>;
+  }
+
+  if (hasError) {
+    return <>Unexpected error</>;
+  }
+
+  const handleSubmit = async (rawValues: any) => {
+    setSaveButtonStatus("loading");
+
+    // Convert dotted keys to nested objects
+    let values: any = {};
+    for (let key in rawValues) {
+      _.set(values, key, rawValues[key]);
+    }
+
+    const stackSourceConfig =
+      newStack.source_configs[DEFAULT_STACK_SOURCE_CONFIG_INDEX];
+    if (!stackSourceConfig) {
+      return;
+    }
+
+    let url = stackSourceConfig.image_repo_uri;
+    let tag = stackSourceConfig.image_tag;
+
+    if (url?.includes(":")) {
+      let splits = url.split(":");
+      url = splits[0];
+      tag = splits[1];
+    } else if (!tag) {
+      tag = "latest";
+    }
+
+    if (!_.isEmpty(stackSourceConfig.build)) {
+      if (template?.metadata?.name === "job") {
+        url = "public.ecr.aws/o1j4x7p4/hello-porter-job";
+        tag = "latest";
+      } else {
+        url = "public.ecr.aws/o1j4x7p4/hello-porter";
+        tag = "latest";
+      }
+    }
+
+    let provider;
+    switch (currentCluster.service) {
+      case "eks":
+        provider = "aws";
+        break;
+      case "gke":
+        provider = "gcp";
+        break;
+      case "doks":
+        provider = "digitalocean";
+        break;
+      case "aks":
+        provider = "azure";
+        break;
+      case "vke":
+        provider = "vultr";
+        break;
+      default:
+        provider = "";
+    }
+
+    // Check the server URL to see if we can detect the cluster provider.
+    // There's no standard URL format for GCP that's why it's not currently included
+    if (provider === "") {
+      const server = currentCluster.server;
+
+      if (server.includes("eks")) provider = "eks";
+      else if (server.includes("ondigitalocean")) provider = "digitalocean";
+      else if (server.includes("azmk8s")) provider = "azure";
+      else if (server.includes("vultr")) provider = "vultr";
+    }
+
+    // don't overwrite for templates that already have a source (i.e. non-Docker templates)
+    if (url && tag) {
+      _.set(values, "image.repository", url);
+      _.set(values, "image.tag", tag);
+    }
+
+    _.set(values, "ingress.provider", provider);
+
+    // pause jobs automatically
+    if (template?.metadata?.name == "job") {
+      _.set(values, "paused", true);
+    }
+
+    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 (
+    <StyledLaunchFlow style={{ position: "relative" }}>
+      <TitleSection>
+        <DynamicLink to={`/stacks/launch/overview`}>
+          <BackButton>
+            <i className="material-icons">
+              keyboard_backspace
+            </i>
+          </BackButton>
+        </DynamicLink>
+        <Polymer>
+        <Icon src={hardcodedIcons[template.metadata.name]} />
+        </Polymer>
+        Add {template.metadata.name.charAt(0).toUpperCase() + template.metadata.name.slice(1)} to Stack
+      </TitleSection>
+      <Heading>Application Name <Required>*</Required></Heading>
+      <InputRow
+        type="string"
+        value={appName}
+        setValue={(val: string) => setAppName(val)}
+        placeholder="ex: perspective-vortex"
+        width="470px"
+      />
+
+      <div style={{ position: "relative" }}>
+      <Heading>Application Settings</Heading>
+      <Helper>Configure settings for this application.</Helper>
+      <PorterFormWrapper
+        formData={template.form}
+        onSubmit={handleSubmit}
+        isLaunch
+        saveValuesStatus={saveButtonStatus}
+        saveButtonText="Add Application"
+      />
+      </div>
+    </StyledLaunchFlow>
+  );
+};
+
+export default NewApp;
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+  display: inline-block;
+`;
+
+const Wrapper = styled.div`
+  margin-top: calc(50vh - 150px);
+`;
+
+const Icon = styled.img`
+  width: 40px;
+  margin-right: 14px;
+
+  opacity: 0;
+  animation: floatIn 0.5s 0.2s;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const BackButton = styled.div`
+  > i {
+    cursor: pointer;
+    font-size: 24px;
+    color: #969fbbaa;
+    margin-right: 10px;
+    padding: 3px;
+    margin-left: 0px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+`;
+
+const Polymer = styled.div`
+  margin-bottom: -6px;
+
+  > i {
+    color: #ffffff;
+    font-size: 24px;
+    margin-left: 5px;
+    margin-right: 18px;
+  }
+`;
+
+const StyledLaunchFlow = styled.div`
+  min-width: 300px;
+  width: calc(100% - 100px);
+  margin-left: 50px;
+  margin-top: ${(props: { disableMarginTop?: boolean }) =>
+    props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
+  padding-bottom: 150px;
+`;

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

@@ -0,0 +1,231 @@
+import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
+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 useAuth from "shared/auth/useAuth";
+import { useRouting } from "shared/routing";
+import { CardGrid, SubmitButton } from "./components/styles";
+import { AppCard } from "./components/AppCard";
+import { AddResourceButton } from "./components/AddResourceButton";
+import styled from "styled-components";
+
+import Helper from "components/form-components/Helper";
+import Heading from "components/form-components/Heading";
+import TitleSection from "components/TitleSection";
+
+const Overview = () => {
+  const {
+    newStack,
+    namespace,
+    setStackName,
+    setStackNamespace,
+    submit,
+  } = useContext(StacksLaunchContext);
+  const { currentProject, currentCluster } = useContext(Context);
+  const [isAuthorized] = useAuth();
+
+  const [namespaceOptions, setNamespaceOptions] = useState<
+    { label: string; value: string }[]
+  >([]);
+
+  const [submitButtonStatus, setSubmitButtonStatus] = useState("");
+
+  const { pushFiltered } = useRouting();
+
+  const updateNamespaces = (cluster_id: number) => {
+    api
+      .getNamespaces(
+        "<token>",
+        {},
+        {
+          id: currentProject.id,
+          cluster_id,
+        }
+      )
+      .then((res) => {
+        if (res.data) {
+          const availableNamespaces = res.data.filter((namespace: any) => {
+            return namespace.status !== "Terminating";
+          });
+          const namespaceOptions = availableNamespaces.map(
+            (x: { name: string }) => {
+              return { label: x.name, value: x.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(() => {
+    updateNamespaces(currentCluster.id);
+  }, [currentCluster]);
+
+  const isValid = useMemo(() => {
+    if (namespace === "") {
+      return false;
+    }
+
+    if (newStack.name === "") {
+      return false;
+    }
+
+    if (newStack.source_configs.length === 0) {
+      return false;
+    }
+
+    if (newStack.app_resources.length === 0) {
+      return false;
+    }
+
+    return true;
+  }, [namespace, newStack.name]);
+
+  return (
+    <StyledLaunchFlow style={{ position: "relative" }}>
+      <TitleSection handleNavBack={() => window.open("/stacks", "_self")}>
+        <Polymer>
+          <i className="material-icons">lan</i>
+        </Polymer>
+        New Application Stack
+      </TitleSection>
+
+      <Heading>Stack Name</Heading>
+      <Helper>
+        Give this application stack a unique name:
+        <Required>*</Required>
+      </Helper>
+      <InputRow
+        type="string"
+        placeholder="ex: perspective-vortices"
+        width="470px"
+        value={newStack.name}
+        setValue={(newName: string) => setStackName(newName)}
+      />
+
+      <Heading>Destination</Heading>
+      <Helper>
+        Specify the namespace you would like to deploy this stack to.
+      </Helper>
+      <ClusterSection>
+        <NamespaceLabel>
+          <i className="material-icons">view_list</i> Namespace
+        </NamespaceLabel>
+        <Selector
+          key={"namespace"}
+          refreshOptions={() => {
+            updateNamespaces(currentCluster.id);
+          }}
+          addButton={isAuthorized("namespace", "", ["get", "create"])}
+          activeValue={namespace}
+          setActiveValue={(val) => setStackNamespace(val)}
+          options={namespaceOptions}
+          width="250px"
+          dropdownWidth="335px"
+          closeOverlay={true}
+        />
+      </ClusterSection>
+
+      <Heading>Applications</Heading>
+      <Helper>
+        At least one application is required:
+        <Required>*</Required>
+      </Helper>
+      <CardGrid>
+        {newStack.app_resources.map((app) => (
+          <AppCard key={app.name} app={app} />
+        ))}
+
+        <AddResourceButton />
+      </CardGrid>
+
+      <SubmitButton
+        disabled={!isValid || submitButtonStatus !== ""}
+        text="Create Stack"
+        onClick={handleSubmit}
+        clearPosition
+        statusPosition="left"
+        status={submitButtonStatus}
+      >
+        Create Stack
+      </SubmitButton>
+    </StyledLaunchFlow>
+  );
+};
+
+export default Overview;
+
+const NamespaceLabel = styled.div`
+  margin-right: 10px;
+  display: flex;
+  align-items: center;
+  > i {
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const ClusterSection = styled.div`
+  display: flex;
+  align-items: center;
+  color: #ffffff;
+  font-family: "Work Sans", sans-serif;
+  font-size: 14px;
+  margin-top: 20px;
+  font-weight: 500;
+  margin-bottom: 32px;
+
+  > i {
+    font-size: 25px;
+    color: #ffffff44;
+    margin-right: 13px;
+  }
+`;
+
+const Br = styled.div<{ height?: string }>`
+  width: 100%;
+  height: ${(props) => props.height || "1px"};
+`;
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+  display: inline-block;
+`;
+
+const Polymer = styled.div`
+  margin-bottom: -6px;
+
+  > i {
+    color: #ffffff;
+    font-size: 24px;
+    margin-left: 5px;
+    margin-right: 18px;
+  }
+`;
+
+const StyledLaunchFlow = styled.div`
+  min-width: 300px;
+  width: calc(100% - 100px);
+  margin-left: 50px;
+  margin-top: ${(props: { disableMarginTop?: boolean }) =>
+    props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
+  padding-bottom: 150px;
+`;

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

@@ -0,0 +1,96 @@
+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 { SubmitButton } from "./components/styles";
+import Helper from "components/form-components/Helper";
+import Heading from "components/form-components/Heading";
+import styled from "styled-components";
+import TitleSection from "components/TitleSection";
+
+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 (
+    <StyledLaunchFlow style={{ position: "relative" }}>
+      <TitleSection handleNavBack={() => window.open("/stacks", "_self")}>
+        <Polymer>
+          <i className="material-icons">lan</i>
+        </Polymer>
+        New Application Stack
+      </TitleSection>
+      <Heading>Stack Source</Heading>
+      <Helper>
+        Specify a source to deploy all stack applications from:
+        <Required>*</Required>
+      </Helper>
+      <Br />
+      <ImageSelector
+        selectedImageUrl={imageUrl}
+        setSelectedImageUrl={setImageUrl}
+        selectedTag={imageTag}
+        setSelectedTag={setImageTag}
+        forceExpanded
+      />
+      <Br height="30px" />
+      <SubmitButton
+        disabled={!imageUrl || !imageTag}
+        onClick={handleNext}
+        text="Continue"
+        clearPosition
+        makeFlush
+      />
+    </StyledLaunchFlow>
+  );
+};
+
+export default SelectSource;
+
+const Br = styled.div<{ height?: string }>`
+  width: 100%;
+  height: ${props => props.height || "1px"};
+`;
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+  display: inline-block;
+`;
+
+const Polymer = styled.div`
+  margin-bottom: -6px;
+
+  > i {
+    color: #ffffff;
+    font-size: 24px;
+    margin-left: 5px;
+    margin-right: 18px;
+  }
+`;
+
+const StyledLaunchFlow = styled.div`
+  width: calc(100% - 100px);
+  margin-left: 50px;
+  min-width: 300px;
+  margin-top: ${(props: { disableMarginTop?: boolean }) =>
+    props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
+`;

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

@@ -0,0 +1,145 @@
+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;
+
+  setStackName: (name: string) => void;
+  setStackNamespace: (namespace: string) => void;
+
+  addSourceConfig: (
+    sourceConfig: Omit<CreateStackBody["source_configs"][0], "name">
+  ) => void;
+
+  addAppResource: (appResource: CreateStackBody["app_resources"][0]) => void;
+
+  removeAppResource: (appResource: CreateStackBody["app_resources"][0]) => void;
+
+  submit: () => Promise<void>;
+};
+
+const defaultValues: StacksLaunchContextType = {
+  newStack: {
+    name: "",
+    app_resources: [],
+    source_configs: [],
+  },
+
+  namespace: "",
+
+  setStackName: (name: string) => {},
+  setStackNamespace: (namespace: string) => {},
+
+  addSourceConfig: (
+    sourceConfig: Omit<CreateStackBody["source_configs"][0], "name">
+  ) => {},
+
+  addAppResource: (appResource: CreateStackBody["app_resources"][0]) => {},
+
+  removeAppResource: (appResource: CreateStackBody["app_resources"][0]) => {},
+
+  submit: async () => {},
+};
+
+export const StacksLaunchContext = createContext<StacksLaunchContextType>(
+  defaultValues
+);
+
+const StacksLaunchContextProvider: React.FC<{}> = ({ children }) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [newStack, setNewStack] = useState<CreateStackBody>(
+    defaultValues.newStack
+  );
+  const [namespace, setNamespace] = useState("default");
+
+  const setStackName: StacksLaunchContextType["setStackName"] = (name) => {
+    setNewStack((prev) => ({
+      ...prev,
+      name,
+    }));
+  };
+
+  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 removeAppResource: StacksLaunchContextType["removeAppResource"] = (
+    appResource
+  ) => {
+    setNewStack((prev) => ({
+      ...prev,
+      app_resources: prev.app_resources.filter(
+        (ar) => ar.name !== appResource.name
+      ),
+    }));
+  };
+
+  const submit: StacksLaunchContextType["submit"] = async () => {
+    try {
+      await api.createStack("<token>", newStack, {
+        cluster_id: currentCluster.id,
+        namespace: namespace,
+        project_id: currentProject.id,
+      });
+    } catch (error) {
+      setCurrentError(error);
+      throw error;
+    }
+  };
+
+  return (
+    <StacksLaunchContext.Provider
+      value={{
+        newStack,
+        namespace,
+        setStackName,
+        setStackNamespace,
+        addSourceConfig,
+        addAppResource,
+        removeAppResource,
+        submit,
+      }}
+    >
+      {children}
+    </StacksLaunchContext.Provider>
+  );
+};
+
+export default StacksLaunchContextProvider;

+ 106 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/components/AddResourceButton.tsx

@@ -0,0 +1,106 @@
+import React, { useEffect, useState } from "react";
+import api from "shared/api";
+import { PorterTemplate } from "shared/types";
+import semver from "semver";
+import { AddResourceButtonStyles } from "./styles";
+import { TemplateSelector } from "./TemplateSelector";
+import { VersionSelector } from "./VersionSelector";
+import DynamicLink from "components/DynamicLink";
+
+import styled from "styled-components";
+
+export 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[1]);
+      setCurrentVersion(templates[1].currentVersion);
+    });
+  }, []);
+
+  return (
+    <AddResourceButtonStyles.Wrapper>
+      <AddResourceButtonStyles.Flex>
+        <LinkMask
+          to={`/stacks/launch/new-app/${currentTemplate?.name}/${currentVersion}`}
+        >
+          
+        </LinkMask>
+        <Icon>
+          <i className="material-icons">add</i>
+        </Icon>
+        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>
+    </AddResourceButtonStyles.Wrapper>
+  );
+};
+
+const LinkMask = styled(DynamicLink)`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+`;
+
+const Icon = styled.div`
+  margin-bottom: -3px;
+  > i {
+    margin-right: 20px;
+    margin-left: 9px;
+    font-size: 20px;
+    color: #aaaabb;
+  }
+`;

+ 54 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/components/AppCard.tsx

@@ -0,0 +1,54 @@
+import React, { useContext } from "react";
+import { StacksLaunchContext, StacksLaunchContextType } from "../Store";
+import { ButtonWithIcon, Card } from "./styles";
+import { hardcodedIcons } from "shared/hardcodedNameDict";
+
+import styled from "styled-components";
+
+export const AppCard = ({
+  app,
+}: {
+  app: StacksLaunchContextType["newStack"]["app_resources"][0];
+}) => {
+  const { removeAppResource } = useContext(StacksLaunchContext);
+
+  const handleDelete = () => {
+    removeAppResource(app);
+  };
+
+  return (
+    <UnclickableCard>
+      <Flex>
+        <Icon src={hardcodedIcons[app.template_name]} />
+        {app.name}
+      </Flex>
+      <DeleteButton onClick={handleDelete}>
+        <i className="material-icons-outlined">close</i>
+      </DeleteButton>
+    </UnclickableCard>
+  );
+};
+
+const UnclickableCard = styled(Card)`
+  cursor: default;
+  :hover {
+    border: 1px solid #ffffff0f;
+  }
+`;
+
+const DeleteButton = styled(ButtonWithIcon)`
+  margin-right: 5px;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  font-size: 14px;
+  font-weight: 500;
+`;
+
+const Icon = styled.img`
+  height: 30px;
+  margin-right: 15px;
+  margin-left: 5px;
+`;

+ 71 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/components/TemplateSelector.tsx

@@ -0,0 +1,71 @@
+import Loading from "components/Loading";
+import React, { useRef, useState } from "react";
+import { useOutsideAlerter } from "shared/hooks/useOutsideAlerter";
+import { capitalize } from "shared/string_utils";
+import { PorterTemplate } from "shared/types";
+import { SelectorStyles } from "./styles";
+
+export 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 || "");
+  };
+
+  if (!Array.isArray(options) || options.length === 0) {
+    return (
+      <SelectorStyles.Wrapper>
+        <SelectorStyles.Button expanded={false}>
+          <Loading />
+        </SelectorStyles.Button>
+      </SelectorStyles.Wrapper>
+    );
+  }
+
+  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>
+    </>
+  );
+};

+ 61 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/components/VersionSelector.tsx

@@ -0,0 +1,61 @@
+import Loading from "components/Loading";
+import React, { useRef, useState } from "react";
+import { useOutsideAlerter } from "shared/hooks/useOutsideAlerter";
+import { capitalize } from "shared/string_utils";
+import { SelectorStyles } from "./styles";
+
+export 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));
+
+  if (!Array.isArray(options) || options.length === 0) {
+    return (
+      <SelectorStyles.Wrapper>
+        <SelectorStyles.Button expanded={false}>
+          <Loading />
+        </SelectorStyles.Button>
+      </SelectorStyles.Wrapper>
+    );
+  }
+
+  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>
+    </>
+  );
+};

+ 148 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/components/styles.tsx

@@ -0,0 +1,148 @@
+import SaveButton from "components/SaveButton";
+import styled from "styled-components";
+
+export const CardGrid = styled.div`
+  margin-top: 32px;
+  margin-bottom: 32px;
+  display: grid;
+  grid-row-gap: 25px;
+`;
+
+export 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;
+  align-items: center;
+
+  :hover {
+    border: 1px solid #ffffff3c;
+  }
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+export const SubmitButton = styled(SaveButton)`
+  width: 100%;
+  display: flex;
+  justify-content: flex-end;
+`;
+
+export const AddResourceButtonStyles = {
+  Wrapper: styled(Card)`
+    align-items: center;
+    position: relative;
+    font-size: 14px;
+    height: 50px;
+    :hover {
+      background: #ffffff19;
+    }
+  `,
+  Text: styled.span`
+    font-size: 20px;
+  `,
+  Flex: styled.div`
+    display: flex;
+    align-items: center;
+  `,
+};
+
+export 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;
+  `,
+};
+
+export const ButtonWithIcon = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 30px;
+  height: 30px;
+  border-radius: 50%;
+  background-color: #ffffff11;
+  border: 1px solid #ffffff22;
+  cursor: pointer;
+
+  &:hover {
+    background-color: #ffffff3c;
+  }
+
+  > i {
+    font-size: 18px;
+  }
+`;

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

@@ -0,0 +1,39 @@
+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`
+  margin: 0 auto;
+  width: 100%;
+`;

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

@@ -0,0 +1,40 @@
+import React, { useContext } from "react";
+import {
+  Redirect,
+  Route,
+  Switch,
+  useLocation,
+  useRouteMatch,
+} from "react-router";
+import { Context } from "shared/Context";
+import Dashboard from "./Dashboard";
+import ExpandedStack from "./ExpandedStack";
+import LaunchRoutes from "./launch";
+
+const routes = () => {
+  const { path } = useRouteMatch();
+  const { currentProject } = useContext(Context);
+
+  if (!currentProject?.stacks_enabled) {
+    return <Redirect to={`/`} />;
+  }
+
+  return (
+    <Switch>
+      <Route path={`${path}/launch`}>
+        <LaunchRoutes />
+      </Route>
+      <Route path={`${path}/:namespace/:stack_id`}>
+        <ExpandedStack />
+      </Route>
+      <Route path={`${path}/`} exact>
+        <Dashboard />
+      </Route>
+      <Route path={`*`}>
+        <div>Not found</div>
+      </Route>
+    </Switch>
+  );
+};
+
+export default routes;

+ 47 - 0
dashboard/src/main/home/cluster-dashboard/stacks/shared.ts

@@ -0,0 +1,47 @@
+import { StatusProps } from "./components/Status";
+import { Stack } from "./types";
+
+export const getStackStatus = (stack: Stack): StatusProps["status"] => {
+  const latestRevision = stack.latest_revision;
+
+  if (latestRevision === null) {
+    return "unknown";
+  }
+
+  if (latestRevision.status === "deployed") {
+    return "successful";
+  }
+
+  if (latestRevision.status === "deploying") {
+    return "loading";
+  }
+
+  if (latestRevision.status === "failed") {
+    return "failed";
+  }
+
+  return "unknown";
+};
+
+export const getStackStatusMessage = (stack: Stack): StatusProps["message"] => {
+  const latestRevision = stack.latest_revision;
+
+  if (latestRevision === null) {
+    return "";
+  }
+
+  if (latestRevision.status === "failed") {
+    return latestRevision.reason.split(/(?=[A-Z])/).join(" ");
+  }
+
+  switch (latestRevision.status) {
+    case "deploying":
+      return "Deploying";
+    case "deployed":
+      return "Deployed";
+    case "deploying":
+      return "Deploying";
+    default:
+      return "";
+  }
+};

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

@@ -0,0 +1,88 @@
+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: "deploying" | "deployed" | "failed"; // type with enum
+  stack_id: string;
+  reason: "DeployError" | "SaveError" | "RollbackError";
+  message: 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;
+  };
+};

+ 1 - 1
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -499,5 +499,5 @@ const StyledLaunchFlow = styled.div`
   width: calc(90% - 130px);
   min-width: 300px;
   margin-top: ${(props: { disableMarginTop: boolean }) =>
-    props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
+    props.disableMarginTop ? "inherit" : "calc(40vh - 310px)"};
 `;

+ 4 - 4
dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx

@@ -99,14 +99,14 @@ class SettingsPage extends Component<PropsType, StateType> {
       )
       .then((res) => {
         if (res.data) {
-          const availableNamespaces = res.data.items.filter(
+          const availableNamespaces = res.data.filter(
             (namespace: any) => {
-              return namespace.status.phase !== "Terminating";
+              return namespace.status !== "Terminating";
             }
           );
           const namespaceOptions = availableNamespaces.map(
-            (x: { metadata: { name: string } }) => {
-              return { label: x.metadata.name, value: x.metadata.name };
+            (x: { name: string }) => {
+              return { label: x.name, value: x.name };
             }
           );
           if (availableNamespaces.length > 0) {

+ 2 - 1
dashboard/src/main/home/modals/DeleteNamespaceModal.tsx

@@ -26,10 +26,11 @@ const DeleteNamespaceModal = () => {
     api
       .deleteNamespace(
         "<token>",
-        { name: currentModalData?.metadata?.name },
+        {},
         {
           id: currentProject.id,
           cluster_id: currentCluster.id,
+          namespace: currentModalData?.metadata?.name,
         }
       )
       .then((res) => {

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

@@ -191,8 +191,13 @@ class Sidebar extends Component<PropsType, StateType> {
             )}
           {currentProject?.preview_envs_enabled && (
             <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>
               <EllipsisTextWrapper
                 onMouseOver={() => {
@@ -218,6 +223,12 @@ class Sidebar extends Component<PropsType, StateType> {
               </EllipsisTextWrapper>
             </NavButton>
           )}
+          {currentProject?.stacks_enabled ? (
+            <NavButton to="/stacks">
+              <Icon className="material-icons-outlined">lan</Icon>
+              Stacks
+            </NavButton>
+          ) : null}
         </>
       );
     }

+ 94 - 5
dashboard/src/shared/api.tsx

@@ -4,6 +4,7 @@ import { release } from "process";
 import { baseApi } from "./baseApi";
 
 import { BuildConfig, FullActionConfigType, StorageType } from "./types";
+import { CreateStackBody } from "main/home/cluster-dashboard/stacks/types";
 
 /**
  * Generic api call format
@@ -1440,16 +1441,15 @@ const createNamespace = baseApi<
 });
 
 const deleteNamespace = baseApi<
-  {
-    name: string;
-  },
+  {},
   {
     id: number;
     cluster_id: number;
+    namespace: string;
   }
 >("DELETE", (pathParams) => {
-  let { id, cluster_id } = pathParams;
-  return `/api/projects/${id}/clusters/${cluster_id}/namespaces/delete`;
+  let { id, cluster_id, namespace } = pathParams;
+  return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}`;
 });
 
 const deleteJob = baseApi<
@@ -1928,6 +1928,87 @@ const getGitlabFolderContent = baseApi<
     `/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`
+);
+
+const deleteStack = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+  }
+>(
+  "DELETE",
+  ({ project_id, cluster_id, namespace, stack_id }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -2110,4 +2191,12 @@ export default {
   getGitlabRepos,
   getGitlabBranches,
   getGitlabFolderContent,
+
+  // STACKS
+  listStacks,
+  getStack,
+  getStackRevision,
+  createStack,
+  rollbackStack,
+  deleteStack,
 };

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

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

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

@@ -1,3 +1,5 @@
+import ValuesYaml from "main/home/cluster-dashboard/expanded-chart/ValuesYaml";
+
 export interface ClusterType {
   id: number;
   name: string;
@@ -19,6 +21,7 @@ export interface DetailedIngressError {
 }
 
 export interface ChartType {
+  is_stack: boolean;
   image_repo_uri: string;
   git_action_config: any;
   build_config: BuildConfig;
@@ -164,6 +167,13 @@ export interface PorterTemplate {
   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
 export interface FormYAML {
   name?: string;
@@ -248,6 +258,7 @@ export interface ProjectType {
   enable_rds_databases: boolean;
   managed_infra_enabled: boolean;
   api_tokens_enabled: boolean;
+  stacks_enabled: boolean;
   roles: {
     id: number;
     kind: string;

+ 2 - 0
internal/models/project.go

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

+ 1 - 1
internal/models/registry.go

@@ -66,7 +66,7 @@ func (r *Registry) ToRegistryType() *types.Registry {
 		ProjectID:          r.ProjectID,
 		Name:               r.Name,
 		URL:                uri,
-		Service:            serv,
+		Service:            string(serv),
 		InfraID:            r.InfraID,
 		GCPIntegrationID:   r.GCPIntegrationID,
 		AWSIntegrationID:   r.AWSIntegrationID,

+ 3 - 0
internal/models/release.go

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

+ 177 - 0
internal/models/stack.go

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

+ 151 - 21
internal/registry/registry.go

@@ -8,6 +8,7 @@ import (
 	"net/http"
 	"net/url"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
@@ -664,6 +665,118 @@ func (r *Registry) ListImages(
 	return nil, fmt.Errorf("error listing images")
 }
 
+func (r *Registry) GetECRPaginatedImages(
+	repoName string,
+	repo repository.Repository,
+	maxResults int64,
+	nextToken *string,
+) ([]*ptypes.Image, *string, error) {
+	aws, err := repo.AWSIntegration().ReadAWSIntegration(
+		r.ProjectID,
+		r.AWSIntegrationID,
+	)
+
+	if err != nil {
+		return nil, nil, err
+	}
+
+	sess, err := aws.GetSession()
+
+	if err != nil {
+		return nil, nil, err
+	}
+
+	svc := ecr.New(sess)
+
+	resp, err := svc.ListImages(&ecr.ListImagesInput{
+		RepositoryName: &repoName,
+		MaxResults:     &maxResults,
+		NextToken:      nextToken,
+	})
+
+	if err != nil {
+		return nil, nil, err
+	}
+
+	if len(resp.ImageIds) == 0 {
+		return []*ptypes.Image{}, nil, nil
+	}
+
+	imageIDLen := len(resp.ImageIds)
+	imageDetails := make([]*ecr.ImageDetail, 0)
+	imageIDMap := make(map[string]bool)
+
+	for _, id := range resp.ImageIds {
+		imageIDMap[*id.ImageTag] = true
+	}
+
+	var wg sync.WaitGroup
+	var mu sync.Mutex
+
+	// AWS API expects the length of imageIDs to be at max 100 at a time
+	for start := 0; start < imageIDLen; start += 100 {
+		end := start + 100
+		if end > imageIDLen {
+			end = imageIDLen
+		}
+
+		wg.Add(1)
+
+		go func(start, end int) {
+			defer wg.Done()
+
+			describeResp, err := svc.DescribeImages(&ecr.DescribeImagesInput{
+				RepositoryName: &repoName,
+				ImageIds:       resp.ImageIds[start:end],
+			})
+
+			if err != nil {
+				return
+			}
+
+			mu.Lock()
+			imageDetails = append(imageDetails, describeResp.ImageDetails...)
+			mu.Unlock()
+		}(start, end)
+	}
+
+	wg.Wait()
+
+	res := make([]*ptypes.Image, 0)
+	imageInfoMap := make(map[string]*ptypes.Image)
+
+	for _, img := range imageDetails {
+		for _, tag := range img.ImageTags {
+			newImage := &ptypes.Image{
+				Digest:         *img.ImageDigest,
+				Tag:            *tag,
+				RepositoryName: repoName,
+				PushedAt:       img.ImagePushedAt,
+			}
+
+			if _, ok := imageIDMap[*tag]; ok {
+				if _, ok := imageInfoMap[*tag]; !ok {
+					imageInfoMap[*tag] = newImage
+				}
+			}
+
+			if len(imageInfoMap) == int(maxResults) {
+				break
+			}
+		}
+
+		if len(imageInfoMap) == int(maxResults) {
+			break
+		}
+	}
+
+	for _, v := range imageInfoMap {
+		res = append(res, v)
+	}
+
+	return res, resp.NextToken, nil
+}
+
 func (r *Registry) listECRImages(repoName string, repo repository.Repository) ([]*ptypes.Image, error) {
 	aws, err := repo.AWSIntegration().ReadAWSIntegration(
 		r.ProjectID,
@@ -718,46 +831,63 @@ func (r *Registry) listECRImages(repoName string, repo repository.Repository) ([
 		nextToken = resp.NextToken
 	}
 
-	describeResp, err := svc.DescribeImages(&ecr.DescribeImagesInput{
-		RepositoryName: &repoName,
-		ImageIds:       imageIDs,
-	})
+	imageIDLen := len(imageIDs)
+	imageDetails := make([]*ecr.ImageDetail, 0)
 
-	if err != nil {
-		return nil, err
-	}
+	var wg sync.WaitGroup
+	var mu sync.Mutex
 
-	imageDetails := describeResp.ImageDetails
+	// AWS API expects the length of imageIDs to be at max 100 at a time
+	for start := 0; start < imageIDLen; start += 100 {
+		end := start + 100
+		if end > imageIDLen {
+			end = imageIDLen
+		}
 
-	nextToken = describeResp.NextToken
+		wg.Add(1)
 
-	for nextToken != nil {
-		describeResp, err := svc.DescribeImages(&ecr.DescribeImagesInput{
-			RepositoryName: &repoName,
-			NextToken:      nextToken,
-		})
+		go func(start, end int) {
+			defer wg.Done()
 
-		if err != nil {
-			return nil, err
-		}
+			describeResp, err := svc.DescribeImages(&ecr.DescribeImagesInput{
+				RepositoryName: &repoName,
+				ImageIds:       imageIDs[start:end],
+			})
+
+			if err != nil {
+				return
+			}
 
-		nextToken = describeResp.NextToken
-		imageDetails = append(imageDetails, describeResp.ImageDetails...)
+			mu.Lock()
+			imageDetails = append(imageDetails, describeResp.ImageDetails...)
+			mu.Unlock()
+		}(start, end)
 	}
 
+	wg.Wait()
+
 	res := make([]*ptypes.Image, 0)
+	imageInfoMap := make(map[string]*ptypes.Image)
 
 	for _, img := range imageDetails {
 		for _, tag := range img.ImageTags {
-			res = append(res, &ptypes.Image{
+			newImage := &ptypes.Image{
 				Digest:         *img.ImageDigest,
 				Tag:            *tag,
 				RepositoryName: repoName,
 				PushedAt:       img.ImagePushedAt,
-			})
+			}
+
+			if _, ok := imageInfoMap[*tag]; !ok {
+				imageInfoMap[*tag] = newImage
+			}
 		}
 	}
 
+	for _, v := range imageInfoMap {
+		res = append(res, v)
+	}
+
 	return res, nil
 }
 

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

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

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

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

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

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

+ 1 - 0
internal/repository/repository.go

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

+ 19 - 0
internal/repository/stack.go

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

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

@@ -45,6 +45,7 @@ type TestRepository struct {
 	apiToken                  repository.APITokenRepository
 	policy                    repository.PolicyRepository
 	tag                       repository.TagRepository
+	stack                     repository.StackRepository
 }
 
 func (t *TestRepository) User() repository.UserRepository {
@@ -207,6 +208,10 @@ func (t *TestRepository) Tag() repository.TagRepository {
 	return t.tag
 }
 
+func (t *TestRepository) Stack() repository.StackRepository {
+	return t.stack
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(canQuery bool, failingMethods ...string) repository.Repository {
@@ -251,5 +256,6 @@ func NewRepository(canQuery bool, failingMethods ...string) repository.Repositor
 		apiToken:                  NewAPITokenRepository(canQuery),
 		policy:                    NewPolicyRepository(canQuery),
 		tag:                       NewTagRepository(),
+		stack:                     NewStackRepository(),
 	}
 }

+ 56 - 0
internal/repository/test/stack.go

@@ -0,0 +1,56 @@
+package test
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+type StackRepository struct{}
+
+func NewStackRepository() repository.StackRepository {
+	return &StackRepository{}
+}
+
+// CreateStack creates a new stack
+func (repo *StackRepository) CreateStack(stack *models.Stack) (*models.Stack, error) {
+	panic("unimplemented")
+}
+
+// ReadStack gets a stack specified by its string id
+func (repo *StackRepository) ListStacks(projectID, clusterID uint, namespace string) ([]*models.Stack, error) {
+	panic("unimplemented")
+}
+
+// ReadStack gets a stack specified by its string id
+func (repo *StackRepository) ReadStackByStringID(projectID uint, stackID string) (*models.Stack, error) {
+	panic("unimplemented")
+}
+
+// DeleteStack creates a new stack
+func (repo *StackRepository) DeleteStack(stack *models.Stack) (*models.Stack, error) {
+	panic("unimplemented")
+}
+
+func (repo *StackRepository) UpdateStackRevision(revision *models.StackRevision) (*models.StackRevision, error) {
+	panic("unimplemented")
+}
+
+func (repo *StackRepository) ReadStackRevision(stackRevisionID uint) (*models.StackRevision, error) {
+	panic("unimplemented")
+}
+
+func (repo *StackRepository) ReadStackRevisionByNumber(stackID uint, revisionNumber uint) (*models.StackRevision, error) {
+	panic("unimplemented")
+}
+
+func (repo *StackRepository) AppendNewRevision(revision *models.StackRevision) (*models.StackRevision, error) {
+	panic("unimplemented")
+}
+
+func (repo *StackRepository) ReadStackResource(resourceID uint) (*models.StackResource, error) {
+	panic("unimplemented")
+}
+
+func (repo *StackRepository) UpdateStackResource(resource *models.StackResource) (*models.StackResource, error) {
+	panic("unimplemented")
+}

+ 78 - 0
internal/stacks/helpers.go

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

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini