2
0
Эх сурвалжийг харах

add crud endpoints for stacks

Alexander Belanger 3 жил өмнө
parent
commit
15ff5df9f3

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

@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"net/http"
 
+	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
@@ -15,6 +16,7 @@ import (
 
 type StackCreateHandler struct {
 	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
 }
 
 func NewStackCreateHandler(
@@ -24,6 +26,7 @@ func NewStackCreateHandler(
 ) *StackCreateHandler {
 	return &StackCreateHandler{
 		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, reader, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
 	}
 }
 
@@ -83,6 +86,39 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	// apply all app resources
+	registries, err := p.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	helmAgent, err := p.GetHelmAgent(r, cluster, "")
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	for _, appResource := range req.AppResources {
+		err = applyAppResource(&applyAppResourceOpts{
+			config:     p.Config(),
+			projectID:  proj.ID,
+			namespace:  namespace,
+			cluster:    cluster,
+			registries: registries,
+			helmAgent:  helmAgent,
+			request:    appResource,
+		})
+
+		if err != nil {
+			// TODO: mark stack with error
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
 	// update stack revision status
 	revision := &stack.Revisions[0]
 	revision.Status = string(types.StackRevisionStatusDeployed)
@@ -160,6 +196,7 @@ func getResourceModels(appResources []*types.CreateStackAppResourceRequest, sour
 			TemplateRepoURL:      appResource.TemplateRepoURL,
 			TemplateName:         appResource.TemplateName,
 			TemplateVersion:      appResource.TemplateVersion,
+			HelmRevisionID:       1,
 		})
 	}
 

+ 29 - 2
api/server/handlers/stack/delete.go

@@ -3,6 +3,7 @@ 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"
@@ -13,6 +14,7 @@ import (
 
 type StackDeleteHandler struct {
 	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
 }
 
 func NewStackDeleteHandler(
@@ -20,14 +22,39 @@ func NewStackDeleteHandler(
 	writer shared.ResultWriter,
 ) *StackDeleteHandler {
 	return &StackDeleteHandler{
-		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+		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)
 
-	stack, err := p.Repo().Stack().DeleteStack(stack)
+	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))

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

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

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

@@ -0,0 +1,181 @@
+package stack
+
+import (
+	"fmt"
+
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/encryption"
+	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/helm/loader"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type applyAppResourceOpts struct {
+	config     *config.Config
+	projectID  uint
+	namespace  string
+	cluster    *models.Cluster
+	helmAgent  *helm.Agent
+	request    *types.CreateStackAppResourceRequest
+	registries []*models.Registry
+}
+
+func applyAppResource(opts *applyAppResourceOpts) error {
+	if opts.request.TemplateVersion == "latest" {
+		opts.request.TemplateVersion = ""
+	}
+
+	chart, err := loader.LoadChartPublic(opts.request.TemplateRepoURL, opts.request.TemplateName, opts.request.TemplateVersion)
+
+	if err != nil {
+		return err
+	}
+
+	conf := &helm.InstallChartConfig{
+		Chart:      chart,
+		Name:       opts.request.Name,
+		Namespace:  opts.namespace,
+		Values:     opts.request.Values,
+		Cluster:    opts.cluster,
+		Repo:       opts.config.Repo,
+		Registries: opts.registries,
+	}
+
+	_, err = opts.helmAgent.InstallChart(conf, opts.config.DOConf)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+type rollbackAppResourceOpts struct {
+	helmAgent      *helm.Agent
+	helmRevisionID uint
+	name           string
+}
+
+func rollbackAppResource(opts *rollbackAppResourceOpts) error {
+	return opts.helmAgent.RollbackRelease(opts.name, int(opts.helmRevisionID))
+}
+
+type updateAppResourceTagOpts struct {
+	helmAgent  *helm.Agent
+	name, tag  string
+	config     *config.Config
+	projectID  uint
+	namespace  string
+	cluster    *models.Cluster
+	registries []*models.Registry
+}
+
+func updateAppResourceTag(opts *updateAppResourceTagOpts) error {
+	// read the current release to get the current values
+	rel, err := opts.helmAgent.GetRelease(opts.name, 0, true)
+
+	if err != nil {
+		return err
+	}
+
+	imagePre := rel.Config["image"]
+	image := imagePre.(map[string]interface{})
+	image["tag"] = opts.tag
+	rel.Config["image"] = image
+
+	conf := &helm.UpgradeReleaseConfig{
+		Name:       opts.name,
+		Cluster:    opts.cluster,
+		Repo:       opts.config.Repo,
+		Registries: opts.registries,
+		Values:     rel.Config,
+	}
+
+	_, err = opts.helmAgent.UpgradeReleaseByValues(conf, opts.config.DOConf)
+
+	return err
+}
+
+type deleteAppResourceOpts struct {
+	helmAgent *helm.Agent
+	name      string
+}
+
+func deleteAppResource(opts *deleteAppResourceOpts) error {
+	_, err := opts.helmAgent.UninstallChart(opts.name)
+
+	return err
+}
+
+func cloneSourceConfigs(sourceConfigs []models.StackSourceConfig) ([]models.StackSourceConfig, error) {
+	res := make([]models.StackSourceConfig, 0)
+
+	// for now, only write source configs which are deployed as a docker image
+	// TODO: add parsing/writes for git-based sources
+	for _, sourceConfig := range sourceConfigs {
+		uid, err := encryption.GenerateRandomBytes(16)
+
+		if err != nil {
+			return nil, err
+		}
+
+		res = append(res, models.StackSourceConfig{
+			UID:          uid,
+			Name:         sourceConfig.Name,
+			ImageRepoURI: sourceConfig.ImageRepoURI,
+			ImageTag:     sourceConfig.ImageTag,
+		})
+	}
+
+	return res, nil
+}
+
+func cloneAppResources(
+	appResources []models.StackResource,
+	prevSourceConfigs []models.StackSourceConfig,
+	newSourceConfigs []models.StackSourceConfig,
+) ([]models.StackResource, error) {
+	res := make([]models.StackResource, 0)
+
+	// for now, only write source configs which are deployed as a docker image
+	// TODO: add parsing/writes for git-based sources
+	for _, appResource := range appResources {
+		uid, err := encryption.GenerateRandomBytes(16)
+
+		if err != nil {
+			return nil, err
+		}
+
+		var linkedSourceConfigUID string
+
+		for _, prevSourceConfig := range prevSourceConfigs {
+			if prevSourceConfig.UID == appResource.StackSourceConfigUID {
+				// find the corresponding new source config
+				for _, newSourceConfig := range newSourceConfigs {
+					if newSourceConfig.Name == prevSourceConfig.Name {
+						linkedSourceConfigUID = newSourceConfig.UID
+					}
+				}
+			}
+		}
+
+		if linkedSourceConfigUID == "" {
+			return nil, fmt.Errorf("source config does not exist in source config list")
+		}
+
+		res = append(res, models.StackResource{
+			Name:                 appResource.Name,
+			UID:                  uid,
+			StackSourceConfigUID: linkedSourceConfigUID,
+			TemplateRepoURL:      appResource.TemplateRepoURL,
+			TemplateName:         appResource.TemplateName,
+			TemplateVersion:      appResource.TemplateVersion,
+			HelmRevisionID:       appResource.HelmRevisionID,
+		})
+	}
+
+	return res, nil
+}
+
+// func setValuesWithSourceConfig(values map[string]interface{}, sourceConfig )

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

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

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

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

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

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

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

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

+ 251 - 1
api/server/router/v1/stack.go

@@ -9,7 +9,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 )
 
-// swagger:parameters getStack deleteStack
+// swagger:parameters getStack deleteStack putStackSource rollbackStack
 type stackPathParams struct {
 	// The project id
 	// in: path
@@ -34,6 +34,37 @@ type stackPathParams struct {
 	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,
@@ -134,6 +165,56 @@ func getV1StackRoutes(
 		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
 	//
@@ -186,6 +267,175 @@ func getV1StackRoutes(
 		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
 	//

+ 35 - 4
api/types/stacks.go

@@ -19,10 +19,26 @@ type CreateStackRequest struct {
 	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
-	// required: true
-	TemplateRepoURL string `json:"template_repo_url" form:"required"`
+	// 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
@@ -62,9 +78,12 @@ type Stack struct {
 	LatestRevision *StackRevision `json:"latest_revision,omitempty"`
 
 	// The list of revisions deployed for this stack
-	Revisions []StackRevisionMeta `json:"revisions"`
+	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"`
@@ -177,6 +196,18 @@ type CreateStackSourceConfigRequest struct {
 	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

+ 7 - 1
internal/models/stack.go

@@ -29,12 +29,18 @@ func (s *Stack) ToStackType() *types.Stack {
 		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: s.Revisions[0].ToStackRevisionType(s.UID),
+		LatestRevision: latestRevision,
 		Revisions:      revisions,
 	}
 }

+ 54 - 1
internal/repository/gorm/stack.go

@@ -26,11 +26,34 @@ func (repo *StackRepository) CreateStack(stack *models.Stack) (*models.Stack, er
 	return stack, nil
 }
 
+// ReadStack gets a stack specified by its string id
+func (repo *StackRepository) ListStacks(projectID, clusterID uint, namespace string) ([]*models.Stack, error) {
+	stacks := make([]*models.Stack, 0)
+
+	if err := repo.db.Debug().
+		Preload("Revisions", func(db *gorm.DB) *gorm.DB {
+			return db.Debug().Order("stack_revisions.revision_number DESC").Limit(1)
+		}).
+		Preload("Revisions.Resources").
+		Preload("Revisions.SourceConfigs").
+		Where("stacks.project_id = ? AND stacks.cluster_id = ? AND stacks.namespace = ?", projectID, clusterID, namespace).Find(&stacks).Error; err != nil {
+		return nil, err
+	}
+
+	return stacks, nil
+}
+
 // ReadStack gets a stack specified by its string id
 func (repo *StackRepository) ReadStackByStringID(projectID uint, stackID string) (*models.Stack, error) {
 	stack := &models.Stack{}
 
-	if err := repo.db.Preload("Revisions").Preload("Revisions.Resources").Preload("Revisions.SourceConfigs").Where("stacks.project_id = ? AND stacks.uid = ?", projectID, stackID).First(&stack).Error; err != nil {
+	if err := repo.db.
+		Preload("Revisions", func(db *gorm.DB) *gorm.DB {
+			return db.Order("stack_revisions.revision_number DESC")
+		}).
+		Preload("Revisions.Resources").
+		Preload("Revisions.SourceConfigs").
+		Where("stacks.project_id = ? AND stacks.uid = ?", projectID, stackID).First(&stack).Error; err != nil {
 		return nil, err
 	}
 
@@ -53,3 +76,33 @@ func (repo *StackRepository) UpdateStackRevision(revision *models.StackRevision)
 
 	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
+}

+ 4 - 0
internal/repository/stack.go

@@ -6,6 +6,10 @@ import "github.com/porter-dev/porter/internal/models"
 type StackRepository interface {
 	CreateStack(stack *models.Stack) (*models.Stack, error)
 	ReadStackByStringID(projectID uint, stackID string) (*models.Stack, error)
+	ListStacks(projectID uint, clusterID uint, namespace string) ([]*models.Stack, error)
 	DeleteStack(stack *models.Stack) (*models.Stack, error)
 	UpdateStackRevision(revision *models.StackRevision) (*models.StackRevision, error)
+
+	ReadStackRevisionByNumber(stackID uint, revisionNumber uint) (*models.StackRevision, error)
+	AppendNewRevision(revision *models.StackRevision) (*models.StackRevision, error)
 }