Просмотр исходного кода

initial create,get,delete stacks endpoints

Alexander Belanger 3 лет назад
Родитель
Сommit
5669100bea

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

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

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

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

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

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

@@ -0,0 +1,167 @@
+package stack
+
+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/types"
+	"github.com/porter-dev/porter/internal/encryption"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type StackCreateHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewStackCreateHandler(
+	config *config.Config,
+	reader shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StackCreateHandler {
+	return &StackCreateHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, reader, writer),
+	}
+}
+
+func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	namespace, _ := r.Context().Value(types.NamespaceScope).(string)
+
+	req := &types.CreateStackRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	uid, err := encryption.GenerateRandomBytes(16)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	sourceConfigs, err := getSourceConfigModels(req.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	resources, err := getResourceModels(req.AppResources, sourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// write stack to the database with creating status
+	stack := &models.Stack{
+		ProjectID: proj.ID,
+		ClusterID: cluster.ID,
+		Namespace: namespace,
+		Name:      req.Name,
+		UID:       uid,
+		Revisions: []models.StackRevision{
+			{
+				RevisionNumber: 1,
+				Status:         string(types.StackRevisionStatusDeploying),
+				SourceConfigs:  sourceConfigs,
+				Resources:      resources,
+			},
+		},
+	}
+
+	stack, err = p.Repo().Stack().CreateStack(stack)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// update stack revision status
+	revision := &stack.Revisions[0]
+	revision.Status = string(types.StackRevisionStatusDeployed)
+
+	revision, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// read the stack again to get the latest revision info
+	stack, err = p.Repo().Stack().ReadStackByStringID(proj.ID, stack.UID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	w.WriteHeader(http.StatusCreated)
+	p.WriteResult(w, r, stack.ToStackType())
+}
+
+func getSourceConfigModels(sourceConfigs []*types.CreateStackSourceConfigRequest) ([]models.StackSourceConfig, error) {
+	res := make([]models.StackSourceConfig, 0)
+
+	// for now, only write source configs which are deployed as a docker image
+	// TODO: add parsing/writes for git-based sources
+	for _, sourceConfig := range sourceConfigs {
+		if sourceConfig.StackSourceConfigBuild == nil {
+			uid, err := encryption.GenerateRandomBytes(16)
+
+			if err != nil {
+				return nil, err
+			}
+
+			res = append(res, models.StackSourceConfig{
+				UID:          uid,
+				Name:         sourceConfig.Name,
+				ImageRepoURI: sourceConfig.ImageRepoURI,
+				ImageTag:     sourceConfig.ImageTag,
+			})
+		}
+	}
+
+	return res, nil
+}
+
+func getResourceModels(appResources []*types.CreateStackAppResourceRequest, sourceConfigs []models.StackSourceConfig) ([]models.StackResource, error) {
+	res := make([]models.StackResource, 0)
+
+	for _, appResource := range appResources {
+		uid, err := encryption.GenerateRandomBytes(16)
+
+		if err != nil {
+			return nil, err
+		}
+
+		var linkedSourceConfigUID string
+
+		for _, sourceConfig := range sourceConfigs {
+			if sourceConfig.Name == appResource.SourceConfigName {
+				linkedSourceConfigUID = sourceConfig.UID
+			}
+		}
+
+		if linkedSourceConfigUID == "" {
+			return nil, fmt.Errorf("source config %s does not exist in source config list", appResource.SourceConfigName)
+		}
+
+		res = append(res, models.StackResource{
+			Name:                 appResource.Name,
+			UID:                  uid,
+			StackSourceConfigUID: linkedSourceConfigUID,
+			TemplateRepoURL:      appResource.TemplateRepoURL,
+			TemplateName:         appResource.TemplateName,
+			TemplateVersion:      appResource.TemplateVersion,
+		})
+	}
+
+	return res, nil
+}

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

@@ -0,0 +1,36 @@
+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 StackDeleteHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewStackDeleteHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *StackDeleteHandler {
+	return &StackDeleteHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *StackDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+
+	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())
+}

+ 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())
 
@@ -252,6 +257,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)
 			}
 		}
 

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

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

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

@@ -0,0 +1,240 @@
+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
+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"`
+}
+
+func NewV1StackScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
+		GetRoutes: GetV1StackScopedRoutes,
+		Children:  children,
+	}
+}
+
+func GetV1StackScopedRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+	children ...*router.Registerer,
+) []*router.Route {
+	routes, projPath := getV1StackRoutes(r, config, basePath, factory)
+
+	if len(children) > 0 {
+		r.Route(projPath.RelativePath, func(r chi.Router) {
+			for _, child := range children {
+				childRoutes := child.GetRoutes(r, config, basePath, factory, child.Children...)
+
+				routes = append(routes, childRoutes...)
+			}
+		})
+	}
+
+	return routes
+}
+
+func getV1StackRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+) ([]*router.Route, *types.Path) {
+	relPath := "/stacks"
+
+	newPath := &types.Path{
+		Parent:       basePath,
+		RelativePath: relPath,
+	}
+
+	var routes []*router.Route
+
+	// POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks -> stack.NewStackCreateHandler
+	// swagger:operation POST /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks createStack
+	//
+	// Creates a stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Create a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - in: body
+	//     name: CreateStackRequest
+	//     description: The stack to create
+	//     schema:
+	//       $ref: '#/definitions/CreateStackRequest'
+	// responses:
+	//   '201':
+	//     description: Successfully created the stack
+	//     schema:
+	//       $ref: '#/definitions/Stack'
+	//   '403':
+	//     description: Forbidden
+	createEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	createHandler := stack.NewStackCreateHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: createEndpoint,
+		Handler:  createHandler,
+		Router:   r,
+	})
+
+	// GET /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_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,
+	})
+
+	// 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
+}

+ 5 - 3
api/types/cluster.go

@@ -195,11 +195,13 @@ type CreateNamespaceRequest struct {
 	Name string `json:"name" form:"required"`
 }
 
+type CreateNamespaceResponseMeta struct {
+	Name string `json:"name,omitempty"`
+}
+
 // swagger:model
 type CreateNamespaceResponse struct {
-	Metadata struct {
-		Name string `json:"name,omitempty"`
-	} `json:"metadata,omitempty"`
+	Metadata CreateNamespaceResponseMeta `json:"metadata,omitempty"`
 }
 
 // swagger:model

+ 2 - 0
api/types/policy.go

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

+ 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 = "*"
 )

+ 226 - 0
api/types/stacks.go

@@ -0,0 +1,226 @@
+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"`
+}
+
+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 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"`
+}
+
+type StackResource struct {
+	// The time that this resource was initially created
+	CreatedAt time.Time `json:"created_at"`
+
+	// The time that this resource was last updated
+	UpdatedAt time.Time `json:"updated_at"`
+
+	// The stack ID that this resource belongs to
+	StackID string `json:"stack_id"`
+
+	// The numerical revision id that this resource belongs to
+	StackRevisionID uint `json:"stack_revision_id"`
+
+	// The name of the resource
+	Name string `json:"name"`
+
+	// The id for this resource
+	ID string `json:"id"`
+
+	// If this is an app resource, app-specific information for the resource
+	StackAppData *StackResourceAppData `json:"stack_app_data,omitempty"`
+
+	// The source configuration for this stack
+	StackSourceConfig *StackSourceConfig `json:"stack_source_config,omitempty"`
+}
+
+type StackResourceAppData struct {
+	// The URL of the Helm registry to pull the template from
+	TemplateRepoURL string `json:"template_repo_url"`
+
+	// The name of the template in the Helm registry, such as `web`
+	TemplateName string `json:"template_name"`
+
+	// The version of the template in the Helm registry, such as `v0.50.0`
+	TemplateVersion string `json:"template_version"`
+}
+
+type StackRevisionStatus string
+
+const (
+	StackRevisionStatusDeploying StackRevisionStatus = "deploying"
+	StackRevisionStatusFailed    StackRevisionStatus = "failed"
+	StackRevisionStatusDeployed  StackRevisionStatus = "deployed"
+)
+
+type StackRevisionMeta struct {
+	// The time that this revision was created
+	CreatedAt time.Time `json:"created_at"`
+
+	// The id of the revision
+	ID uint `json:"id"`
+
+	// The status of the revision
+	Status StackRevisionStatus `json:"status"`
+
+	// The stack ID that this source config belongs to
+	StackID string `json:"stack_id"`
+}
+
+type StackRevision struct {
+	*StackRevisionMeta
+
+	// The list of resources deployed in this revision
+	Resources []StackResource `json:"resources"`
+
+	SourceConfigs []StackSourceConfig `json:"source_configs"`
+}
+
+type StackSourceConfig struct {
+	// The time that the source configuration was initially created
+	CreatedAt time.Time `json:"created_at"`
+
+	// The time that the source configuration was last updated
+	UpdatedAt time.Time `json:"updated_at"`
+
+	// The stack ID that this source config belongs to
+	StackID string `json:"stack_id"`
+
+	// The numerical revision id that this source config belongs to
+	StackRevisionID uint `json:"stack_revision_id"`
+
+	// The display name of the stack source
+	Name string `json:"name"`
+
+	// The unique id of the stack source config
+	ID string `json:"id"`
+
+	// The complete image repo uri used by the source
+	ImageRepoURI string `json:"image_repo_uri"`
+
+	// The current image tag used by the application
+	ImageTag string `json:"image_tag"`
+
+	// If this field is empty, the resource is deployed directly from the image repo uri
+	StackSourceConfigBuild *StackSourceConfigBuild `json:"build,omitempty"`
+}
+
+// swagger:model
+type CreateStackSourceConfigRequest struct {
+	// required: true
+	Name string `json:"name"`
+
+	// required: true
+	ImageRepoURI string `json:"image_repo_uri"`
+
+	// required: true
+	ImageTag string `json:"image_tag"`
+
+	// If this field is empty, the resource is deployed directly from the image repo uri
+	StackSourceConfigBuild *StackSourceConfigBuild `json:"build,omitempty"`
+}
+
+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"`
+}

+ 166 - 0
internal/models/stack.go

@@ -0,0 +1,166 @@
+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))
+	}
+
+	return &types.Stack{
+		CreatedAt:      s.CreatedAt,
+		UpdatedAt:      s.UpdatedAt,
+		Name:           s.Name,
+		ID:             s.UID,
+		LatestRevision: s.Revisions[0].ToStackRevisionType(s.UID),
+		Revisions:      revisions,
+	}
+}
+
+// StackRevision represents the revision information for the stack
+type StackRevision struct {
+	gorm.Model
+
+	RevisionNumber uint
+
+	StackID uint
+
+	Status string
+
+	Resources []StackResource
+
+	SourceConfigs []StackSourceConfig
+}
+
+func (s StackRevision) ToStackRevisionMetaType(stackID string) types.StackRevisionMeta {
+	return types.StackRevisionMeta{
+		CreatedAt: s.CreatedAt,
+		ID:        s.RevisionNumber,
+		Status:    types.StackRevisionStatus(s.Status),
+		StackID:   stackID,
+	}
+}
+
+func (s StackRevision) ToStackRevisionType(stackID string) *types.StackRevision {
+	metaType := s.ToStackRevisionMetaType(stackID)
+
+	sourceConfigs := make([]types.StackSourceConfig, 0)
+
+	for _, sourceConfig := range s.SourceConfigs {
+		sourceConfigs = append(sourceConfigs, *sourceConfig.ToStackSourceConfigType(stackID, s.RevisionNumber))
+	}
+
+	resources := make([]types.StackResource, 0)
+
+	for _, stackResource := range s.Resources {
+		resources = append(resources, *stackResource.ToStackResource(stackID, s.RevisionNumber, s.SourceConfigs))
+	}
+
+	return &types.StackRevision{
+		StackRevisionMeta: &metaType,
+		SourceConfigs:     sourceConfigs,
+		Resources:         resources,
+	}
+}
+
+type StackResource struct {
+	gorm.Model
+
+	Name string
+
+	UID string
+
+	StackRevisionID uint
+
+	StackSourceConfigUID string
+
+	HelmRevisionID uint
+
+	Values []byte
+
+	TemplateRepoURL string
+
+	TemplateName string
+
+	TemplateVersion string
+}
+
+func (s StackResource) ToStackResource(stackID string, stackRevisionID uint, sourceConfigs []StackSourceConfig) *types.StackResource {
+	// find the relevant source config
+	var linkedSourceConfig StackSourceConfig
+
+	for _, sourceConfig := range sourceConfigs {
+		if sourceConfig.UID == s.StackSourceConfigUID {
+			linkedSourceConfig = sourceConfig
+			break
+		}
+	}
+
+	return &types.StackResource{
+		CreatedAt:         s.CreatedAt,
+		UpdatedAt:         s.UpdatedAt,
+		Name:              s.Name,
+		ID:                s.UID,
+		StackSourceConfig: linkedSourceConfig.ToStackSourceConfigType(stackID, stackRevisionID),
+		StackID:           stackID,
+		// Note that `StackRevisionID` on the API refers to the numerical auto-incremented revision ID, not
+		// the stack_revision_id in the database.
+		StackRevisionID: stackRevisionID,
+		StackAppData: &types.StackResourceAppData{
+			TemplateRepoURL: s.TemplateRepoURL,
+			TemplateName:    s.TemplateName,
+			TemplateVersion: s.TemplateVersion,
+		},
+	}
+}
+
+type StackSourceConfig struct {
+	gorm.Model
+
+	StackRevisionID uint
+
+	Name string
+
+	UID string
+
+	ImageRepoURI string
+
+	ImageTag string
+
+	// TODO: add git-specific information
+}
+
+func (s StackSourceConfig) ToStackSourceConfigType(stackID string, stackRevisionID uint) *types.StackSourceConfig {
+	return &types.StackSourceConfig{
+		CreatedAt:       s.CreatedAt,
+		UpdatedAt:       s.UpdatedAt,
+		StackID:         stackID,
+		StackRevisionID: stackRevisionID,
+		Name:            s.Name,
+		ID:              s.UID,
+		ImageRepoURI:    s.ImageRepoURI,
+		ImageTag:        s.ImageTag,
+	}
+}

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

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

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

@@ -45,6 +45,7 @@ type GormRepository struct {
 	apiToken                  repository.APITokenRepository
 	policy                    repository.PolicyRepository
 	tag                       repository.TagRepository
+	stack                     repository.StackRepository
 }
 
 func (t *GormRepository) User() repository.UserRepository {
@@ -199,6 +200,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 {
@@ -241,5 +246,6 @@ func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.Creden
 		apiToken:                  NewAPITokenRepository(db),
 		policy:                    NewPolicyRepository(db),
 		tag:                       NewTagRepository(db),
+		stack:                     NewStackRepository(db),
 	}
 }

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

@@ -0,0 +1,55 @@
+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) 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 {
+		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
+}

+ 1 - 0
internal/repository/repository.go

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

+ 11 - 0
internal/repository/stack.go

@@ -0,0 +1,11 @@
+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)
+	DeleteStack(stack *models.Stack) (*models.Stack, error)
+	UpdateStackRevision(revision *models.StackRevision) (*models.StackRevision, error)
+}