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

add handler for getting release

Alexander Belanger 4 лет назад
Родитель
Сommit
c6d5069ed5

+ 1 - 5
api/client/k8s.go

@@ -20,13 +20,9 @@ func (c *Client) GetK8sNamespaces(
 	projectID uint,
 	clusterID uint,
 ) (*GetK8sNamespacesResponse, error) {
-	cl := fmt.Sprintf("%d", clusterID)
-
 	req, err := http.NewRequest(
 		"GET",
-		fmt.Sprintf("%s/projects/%d/k8s/namespaces?"+url.Values{
-			"cluster_id": []string{cl},
-		}.Encode(), c.BaseURL, projectID),
+		fmt.Sprintf("%s/projects/%d/clusters/%d/namespaces", c.BaseURL, projectID, clusterID),
 		nil,
 	)
 

+ 70 - 5
api/server/authz/cluster.go

@@ -9,11 +9,15 @@ import (
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+	"k8s.io/client-go/dynamic"
 )
 
 const KubernetesAgentCtxKey string = "k8s-agent"
+const KubernetesDynamicClientCtxKey string = "k8s-dyn-client"
 const HelmAgentCtxKey string = "helm-agent"
 
 type ClusterScopedFactory struct {
@@ -36,15 +40,23 @@ type ClusterScopedMiddleware struct {
 }
 
 func (p *ClusterScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	// get the project id from the URL param context
-	reqScopes, _ := r.Context().Value(RequestScopeCtxKey).(map[types.PermissionScope]*policy.RequestAction)
+	// read the project to check scopes
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 
+	// get the cluster id from the URL param context
+	reqScopes, _ := r.Context().Value(RequestScopeCtxKey).(map[types.PermissionScope]*policy.RequestAction)
 	clusterID := reqScopes[types.ClusterScope].Resource.UInt
-
-	cluster, err := p.config.Repo.Cluster().ReadCluster(clusterID)
+	cluster, err := p.config.Repo.Cluster().ReadCluster(proj.ID, clusterID)
 
 	if err != nil {
-		apierrors.HandleAPIError(w, p.config.Logger, apierrors.NewErrInternal(err))
+		if err == gorm.ErrRecordNotFound {
+			apierrors.HandleAPIError(w, p.config.Logger, apierrors.NewErrForbidden(
+				fmt.Errorf("cluster with id %d not found in project %d", clusterID, proj.ID),
+			))
+		} else {
+			apierrors.HandleAPIError(w, p.config.Logger, apierrors.NewErrInternal(err))
+		}
+
 		return
 	}
 
@@ -59,7 +71,9 @@ func NewClusterContext(ctx context.Context, cluster *models.Cluster) context.Con
 
 type KubernetesAgentGetter interface {
 	GetOutOfClusterConfig(cluster *models.Cluster) *kubernetes.OutOfClusterConfig
+	GetDynamicClient(r *http.Request, cluster *models.Cluster) (dynamic.Interface, error)
 	GetAgent(r *http.Request, cluster *models.Cluster) (*kubernetes.Agent, error)
+	GetHelmAgent(r *http.Request, cluster *models.Cluster) (*helm.Agent, error)
 }
 
 type OutOfClusterAgentGetter struct {
@@ -103,3 +117,54 @@ func (d *OutOfClusterAgentGetter) GetAgent(r *http.Request, cluster *models.Clus
 
 	return agent, nil
 }
+
+func (d *OutOfClusterAgentGetter) GetHelmAgent(r *http.Request, cluster *models.Cluster) (*helm.Agent, error) {
+	// look for the agent in context
+	ctxAgentVal := r.Context().Value(HelmAgentCtxKey)
+
+	if ctxAgentVal != nil {
+		if agent, ok := ctxAgentVal.(*helm.Agent); ok {
+			return agent, nil
+		}
+	}
+
+	// if helm agent not found in context, construct it from k8s agent
+	k8sAgent, err := d.GetAgent(r, cluster)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// look for namespace in context, otherwise go with default
+	reqScopes, _ := r.Context().Value(RequestScopeCtxKey).(map[types.PermissionScope]*policy.RequestAction)
+	namespace := "default"
+
+	if nsPolicy, ok := reqScopes[types.NamespaceScope]; ok && nsPolicy.Resource.Name != "" {
+		namespace = nsPolicy.Resource.Name
+	}
+
+	helmAgent, err := helm.GetAgentFromK8sAgent("secret", namespace, d.config.Logger, k8sAgent)
+
+	if err != nil {
+		return nil, fmt.Errorf("failed to get Helm agent: %s", err.Error())
+	}
+
+	newCtx := context.WithValue(r.Context(), HelmAgentCtxKey, helmAgent)
+
+	r = r.WithContext(newCtx)
+
+	return helmAgent, nil
+}
+
+func (d *OutOfClusterAgentGetter) GetDynamicClient(r *http.Request, cluster *models.Cluster) (dynamic.Interface, error) {
+	// look for the agent in context
+	ctxDynClientVal := r.Context().Value(KubernetesDynamicClientCtxKey)
+
+	if ctxDynClientVal != nil {
+		if dynClient, ok := ctxDynClientVal.(dynamic.Interface); ok {
+			return dynClient, nil
+		}
+	}
+
+	return kubernetes.GetDynamicClientOutOfClusterConfig(d.GetOutOfClusterConfig(cluster))
+}

+ 1 - 1
api/server/authz/policy.go

@@ -101,7 +101,7 @@ func getRequestActionForEndpoint(
 		case types.NamespaceScope:
 			resource.Name, reqErr = GetURLParamString(r, string(types.URLParamNamespace))
 		case types.ReleaseScope:
-			resource.Name, reqErr = GetURLParamString(r, string(types.URLParamApplication))
+			resource.Name, reqErr = GetURLParamString(r, string(types.URLParamReleaseName))
 		}
 
 		if reqErr != nil {

+ 7 - 15
api/server/authz/release.go

@@ -8,7 +8,6 @@ import (
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/types"
-	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/models"
 	"helm.sh/helm/v3/pkg/release"
 )
@@ -34,30 +33,23 @@ type ReleaseScopedMiddleware struct {
 }
 
 func (p *ReleaseScopedMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	// get the project id from the URL param context
-	reqScopes, _ := r.Context().Value(RequestScopeCtxKey).(map[types.PermissionScope]*policy.RequestAction)
-
-	// get the name and the namespace of the application
-	namespace := reqScopes[types.NamespaceScope].Resource.Name
-	name := reqScopes[types.ReleaseScope].Resource.Name
-
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
-	k8sAgent, err := p.agentGetter.GetAgent(r, cluster)
+	helmAgent, err := p.agentGetter.GetHelmAgent(r, cluster)
 
 	if err != nil {
 		apierrors.HandleAPIError(w, p.config.Logger, apierrors.NewErrInternal(err))
 		return
 	}
 
-	helmAgent, err := helm.GetAgentFromK8sAgent("secret", namespace, p.config.Logger, k8sAgent)
+	// get the name of the application
+	reqScopes, _ := r.Context().Value(RequestScopeCtxKey).(map[types.PermissionScope]*policy.RequestAction)
+	name := reqScopes[types.ReleaseScope].Resource.Name
 
-	if err != nil {
-		apierrors.HandleAPIError(w, p.config.Logger, apierrors.NewErrInternal(err))
-		return
-	}
+	// get the version for the application
+	version, _ := GetURLParamUint(r, string(types.URLParamReleaseVersion))
 
-	release, err := helmAgent.GetRelease(name, 0)
+	release, err := helmAgent.GetRelease(name, int(version))
 
 	if err != nil {
 		apierrors.HandleAPIError(w, p.config.Logger, apierrors.NewErrInternal(err))

+ 73 - 0
api/server/handlers/release/get.go

@@ -0,0 +1,73 @@
+package release
+
+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/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/templater/parser"
+	"helm.sh/helm/v3/pkg/release"
+)
+
+type ReleaseGetHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewReleaseGetHandler(
+	config *shared.Config,
+	writer shared.ResultWriter,
+) *ReleaseGetHandler {
+	return &ReleaseGetHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *ReleaseGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	helmRelease, _ := r.Context().Value(types.ReleaseScope).(*release.Release)
+
+	res := &types.Release{
+		HelmRelease: helmRelease,
+	}
+
+	// look up the release in the database; if not found, do not populate Porter fields
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	release, err := c.Repo().Release().ReadRelease(cluster.ID, helmRelease.Name, helmRelease.Namespace)
+
+	if err == nil {
+		res.ID = release.ID
+		res.WebhookToken = release.WebhookToken
+
+		if release.GitActionConfig != nil {
+			res.GitActionConfig = release.GitActionConfig.ToGitActionConfigType()
+		}
+	}
+
+	// look for the form using the dynamic client
+	dynClient, err := c.GetDynamicClient(r, cluster)
+
+	if err != nil {
+		c.HandleAPIError(w, apierrors.NewErrInternal(err))
+	}
+
+	parserDef := &parser.ClientConfigDefault{
+		DynamicClient: dynClient,
+		HelmChart:     helmRelease.Chart,
+		HelmRelease:   helmRelease,
+	}
+
+	form, err := parser.GetFormFromRelease(parserDef, helmRelease)
+
+	if err != nil {
+		// TODO: log non-fatal parsing error
+	} else {
+		res.Form = form
+	}
+
+	c.WriteResult(w, res)
+}

+ 84 - 0
api/server/router/release.go

@@ -0,0 +1,84 @@
+package router
+
+import (
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/handlers/release"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/types"
+)
+
+func NewReleaseScopedRegisterer(children ...*Registerer) *Registerer {
+	return &Registerer{
+		GetRoutes: GetReleaseScopedRoutes,
+		Children:  children,
+	}
+}
+
+func GetReleaseScopedRoutes(
+	r chi.Router,
+	config *shared.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+	children ...*Registerer,
+) []*Route {
+	routes, projPath := getReleaseRoutes(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 getReleaseRoutes(
+	r chi.Router,
+	config *shared.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+) ([]*Route, *types.Path) {
+	relPath := "/releases/{namespace}/{name}/{version}"
+
+	newPath := &types.Path{
+		Parent:       basePath,
+		RelativePath: relPath,
+	}
+
+	routes := make([]*Route, 0)
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/releases/{namespace}/{name}/{version} -> release.NewReleaseGetHandler
+	getEndpoint := 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.ReleaseScope,
+			},
+		},
+	)
+
+	getHandler := release.NewReleaseGetHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getEndpoint,
+		Handler:  getHandler,
+		Router:   r,
+	})
+
+	return routes, newPath
+}

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

@@ -19,7 +19,9 @@ func NewAPIRouter(config *shared.Config) *chi.Mux {
 
 	endpointFactory := shared.NewAPIObjectEndpointFactory(config)
 	baseRegisterer := NewBaseRegisterer()
-	clusterRegisterer := NewClusterScopedRegisterer()
+
+	releaseRegisterer := NewReleaseScopedRegisterer()
+	clusterRegisterer := NewClusterScopedRegisterer(releaseRegisterer)
 	projRegisterer := NewProjectScopedRegisterer(clusterRegisterer)
 	userRegisterer := NewUserScopedRegisterer(projRegisterer)
 
@@ -82,6 +84,10 @@ func registerRoutes(config *shared.Config, routes []*Route) {
 	// after authorization. Each subsequent http.Handler can lookup the cluster in context.
 	clusterFactory := authz.NewClusterScopedFactory(config)
 
+	// Create a new "release-scoped" factory which will create a new release-scoped request
+	// after authorization. Each subsequent http.Handler can lookup the release in context.
+	releaseFactory := authz.NewReleaseScopedFactory(config)
+
 	// Policy doc loader loads the policy documents for a specific project.
 	policyDocLoader := policy.NewBasicPolicyDocumentLoader(config.Repo.Project())
 
@@ -104,6 +110,8 @@ func registerRoutes(config *shared.Config, routes []*Route) {
 				atomicGroup.Use(projFactory.Middleware)
 			case types.ClusterScope:
 				atomicGroup.Use(clusterFactory.Middleware)
+			case types.ReleaseScope:
+				atomicGroup.Use(releaseFactory.Middleware)
 			}
 		}
 

+ 58 - 0
api/types/form.go

@@ -0,0 +1,58 @@
+package types
+
+// FormContext is the target context
+type FormContext struct {
+	Type   string            `yaml:"type" json:"type"`
+	Config map[string]string `yaml:"config" json:"config"`
+}
+
+// FormTab is a tab rendered in a form
+type FormTab struct {
+	Context  *FormContext   `yaml:"context" json:"context"`
+	Name     string         `yaml:"name" json:"name"`
+	Label    string         `yaml:"label" json:"label"`
+	Sections []*FormSection `yaml:"sections" json:"sections,omitempty"`
+	Settings struct {
+		OmitFromLaunch bool `yaml:"omitFromLaunch,omitempty" json:"omitFromLaunch,omitempty"`
+	} `yaml:"settings,omitempty" json:"settings,omitempty"`
+}
+
+// FormSection is a section of a form
+type FormSection struct {
+	Context  *FormContext   `yaml:"context" json:"context"`
+	Name     string         `yaml:"name" json:"name"`
+	ShowIf   interface{}    `yaml:"show_if" json:"show_if"`
+	Contents []*FormContent `yaml:"contents" json:"contents,omitempty"`
+}
+
+// FormContent is a form's atomic unit
+type FormContent struct {
+	Context     *FormContext `yaml:"context" json:"context"`
+	Type        string       `yaml:"type" json:"type"`
+	Label       string       `yaml:"label" json:"label"`
+	Required    bool         `json:"required"`
+	Name        string       `yaml:"name,omitempty" json:"name,omitempty"`
+	Variable    string       `yaml:"variable,omitempty" json:"variable,omitempty"`
+	Placeholder string       `yaml:"placeholder,omitempty" json:"placeholder,omitempty"`
+	Value       interface{}  `yaml:"value,omitempty" json:"value,omitempty"`
+	Settings    struct {
+		Docs               string      `yaml:"docs,omitempty" json:"docs,omitempty"`
+		Default            interface{} `yaml:"default,omitempty" json:"default,omitempty"`
+		Unit               interface{} `yaml:"unit,omitempty" json:"unit,omitempty"`
+		OmitUnitFromValue  bool        `yaml:"omitUnitFromValue,omitempty" json:"omitUnitFromValue,omitempty"`
+		DisableAfterLaunch bool        `yaml:"disableAfterLaunch,omitempty" json:"disableAfterLaunch,omitempty"`
+		Options            interface{} `yaml:"options,omitempty" json:"options,omitempty"`
+		Placeholder        string      `yaml:"placeholder,omitempty" json:"placeholder,omitempty"`
+	} `yaml:"settings,omitempty" json:"settings,omitempty"`
+}
+
+// FormYAML represents a chart's values.yaml form abstraction
+type FormYAML struct {
+	Name                string     `yaml:"name" json:"name"`
+	Icon                string     `yaml:"icon" json:"icon"`
+	HasSource           string     `yaml:"hasSource" json:"hasSource"`
+	IncludeHiddenFields string     `yaml:"includeHiddenFields,omitempty" json:"includeHiddenFields,omitempty"`
+	Description         string     `yaml:"description" json:"description"`
+	Tags                []string   `yaml:"tags" json:"tags"`
+	Tabs                []*FormTab `yaml:"tabs" json:"tabs,omitempty"`
+}

+ 22 - 0
api/types/git_action_config.go

@@ -0,0 +1,22 @@
+package types
+
+// GitActionConfig
+type GitActionConfig struct {
+	// The git repo in ${owner}/${repo} form
+	GitRepo string `json:"git_repo"`
+
+	// The git branch to use
+	GitBranch string `json:"git_branch"`
+
+	// The complete image repository uri to pull from
+	ImageRepoURI string `json:"image_repo_uri"`
+
+	// The git integration id
+	GitRepoID uint `json:"git_repo_id"`
+
+	// The path to the dockerfile in the git repo
+	DockerfilePath string `json:"dockerfile_path" form:"required"`
+
+	// The build context
+	FolderPath string `json:"folder_path"`
+}

+ 15 - 0
api/types/release.go

@@ -0,0 +1,15 @@
+package types
+
+import "helm.sh/helm/v3/pkg/release"
+
+// Release is a helm release with a form attached
+type Release struct {
+	HelmRelease *release.Release `json:"helm_release"`
+
+	ID              uint             `json:"id"`
+	WebhookToken    string           `json:"webhook_token"`
+	GitActionConfig *GitActionConfig `json:"git_action_config,omitempty"`
+	Form            *FormYAML        `json:"form,omitempty"`
+}
+
+type GetReleaseResponse Release

+ 5 - 4
api/types/request.go

@@ -33,10 +33,11 @@ const (
 type URLParam string
 
 const (
-	URLParamProjectID   URLParam = "project_id"
-	URLParamClusterID   URLParam = "cluster_id"
-	URLParamNamespace   URLParam = "namespace"
-	URLParamApplication URLParam = "application"
+	URLParamProjectID      URLParam = "project_id"
+	URLParamClusterID      URLParam = "cluster_id"
+	URLParamNamespace      URLParam = "namespace"
+	URLParamReleaseName    URLParam = "name"
+	URLParamReleaseVersion URLParam = "version"
 )
 
 type Path struct {

+ 13 - 0
internal/models/gitrepo.go

@@ -1,6 +1,7 @@
 package models
 
 import (
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models/integrations"
 	"gorm.io/gorm"
 )
@@ -111,3 +112,15 @@ func (r *GitActionConfig) Externalize() *GitActionConfigExternal {
 		FolderPath:     r.FolderPath,
 	}
 }
+
+// ToGitActionConfigType generates an external GitActionConfig to be shared over REST
+func (r *GitActionConfig) ToGitActionConfigType() *types.GitActionConfig {
+	return &types.GitActionConfig{
+		GitRepo:        r.GitRepo,
+		GitBranch:      r.GitBranch,
+		ImageRepoURI:   r.ImageRepoURI,
+		GitRepoID:      r.GithubInstallationID,
+		DockerfilePath: r.DockerfilePath,
+		FolderPath:     r.FolderPath,
+	}
+}

+ 1 - 1
internal/models/release.go

@@ -20,7 +20,7 @@ type Release struct {
 	// but this should be used for the source of truth going forward.
 	ImageRepoURI string `json:"image_repo_uri,omitempty"`
 
-	GitActionConfig    GitActionConfig `json:"git_action_config"`
+	GitActionConfig    *GitActionConfig `json:"git_action_config"`
 	NotificationConfig uint
 }
 

+ 2 - 2
internal/repository/cluster.go

@@ -9,12 +9,12 @@ import (
 // Cluster model
 type ClusterRepository interface {
 	CreateClusterCandidate(cc *models.ClusterCandidate) (*models.ClusterCandidate, error)
-	ReadClusterCandidate(id uint) (*models.ClusterCandidate, error)
+	ReadClusterCandidate(projectID, ccID uint) (*models.ClusterCandidate, error)
 	ListClusterCandidatesByProjectID(projectID uint) ([]*models.ClusterCandidate, error)
 	UpdateClusterCandidateCreatedClusterID(id uint, createdClusterID uint) (*models.ClusterCandidate, error)
 
 	CreateCluster(cluster *models.Cluster) (*models.Cluster, error)
-	ReadCluster(id uint) (*models.Cluster, error)
+	ReadCluster(projectID, clusterID uint) (*models.Cluster, error)
 	ListClustersByProjectID(projectID uint) ([]*models.Cluster, error)
 	UpdateCluster(cluster *models.Cluster) (*models.Cluster, error)
 	UpdateClusterTokenCache(tokenCache *ints.ClusterTokenCache) (*models.Cluster, error)

+ 4 - 4
internal/repository/gorm/cluster.go

@@ -61,11 +61,11 @@ func (repo *ClusterRepository) CreateClusterCandidate(
 
 // ReadClusterCandidate finds a cluster candidate by id
 func (repo *ClusterRepository) ReadClusterCandidate(
-	id uint,
+	projectID, ccID uint,
 ) (*models.ClusterCandidate, error) {
 	cc := &models.ClusterCandidate{}
 
-	if err := repo.db.Preload("Resolvers").Where("id = ?", id).First(&cc).Error; err != nil {
+	if err := repo.db.Preload("Resolvers").Where("project_id = ? AND id = ?", projectID, ccID).First(&cc).Error; err != nil {
 		return nil, err
 	}
 
@@ -165,14 +165,14 @@ func (repo *ClusterRepository) CreateCluster(
 
 // ReadCluster finds a cluster by id
 func (repo *ClusterRepository) ReadCluster(
-	id uint,
+	projectID, clusterID uint,
 ) (*models.Cluster, error) {
 	ctxDB := repo.db.WithContext(context.Background())
 
 	cluster := &models.Cluster{}
 
 	// preload Clusters association
-	if err := ctxDB.Preload("TokenCache").Where("id = ?", id).First(&cluster).Error; err != nil {
+	if err := ctxDB.Preload("TokenCache").Where("project_id = ? AND id = ?", projectID, clusterID).First(&cluster).Error; err != nil {
 		return nil, err
 	}
 

+ 8 - 8
internal/repository/gorm/cluster_test.go

@@ -39,7 +39,7 @@ func TestCreateClusterCandidate(t *testing.T) {
 		t.Fatalf("%v\n", err)
 	}
 
-	cc, err = tester.repo.Cluster().ReadClusterCandidate(cc.Model.ID)
+	cc, err = tester.repo.Cluster().ReadClusterCandidate(tester.initProjects[0].ID, cc.Model.ID)
 
 	if err != nil {
 		t.Fatalf("%v\n", err)
@@ -93,7 +93,7 @@ func TestCreateClusterCandidateWithResolvers(t *testing.T) {
 		t.Fatalf("%v\n", err)
 	}
 
-	cc, err = tester.repo.Cluster().ReadClusterCandidate(cc.Model.ID)
+	cc, err = tester.repo.Cluster().ReadClusterCandidate(tester.initProjects[0].ID, cc.Model.ID)
 
 	if err != nil {
 		t.Fatalf("%v\n", err)
@@ -237,7 +237,7 @@ func TestCreateCluster(t *testing.T) {
 		t.Fatalf("%v\n", err)
 	}
 
-	cluster, err = tester.repo.Cluster().ReadCluster(cluster.Model.ID)
+	cluster, err = tester.repo.Cluster().ReadCluster(tester.initProjects[0].ID, cluster.Model.ID)
 
 	if err != nil {
 		t.Fatalf("%v\n", err)
@@ -321,7 +321,7 @@ func TestUpdateCluster(t *testing.T) {
 		t.Fatalf("%v\n", err)
 	}
 
-	cluster, err = tester.repo.Cluster().ReadCluster(tester.initClusters[0].ID)
+	cluster, err = tester.repo.Cluster().ReadCluster(tester.initProjects[0].ID, tester.initClusters[0].ID)
 
 	// make sure data is correct
 	expCluster := models.Cluster{
@@ -371,7 +371,7 @@ func TestUpdateClusterToken(t *testing.T) {
 		t.Fatalf("%v\n", err)
 	}
 
-	cluster, err = tester.repo.Cluster().ReadCluster(cluster.Model.ID)
+	cluster, err = tester.repo.Cluster().ReadCluster(tester.initProjects[0].ID, cluster.Model.ID)
 
 	if err != nil {
 		t.Fatalf("%v\n", err)
@@ -399,7 +399,7 @@ func TestUpdateClusterToken(t *testing.T) {
 	if err != nil {
 		t.Fatalf("%v\n", err)
 	}
-	cluster, err = tester.repo.Cluster().ReadCluster(cluster.Model.ID)
+	cluster, err = tester.repo.Cluster().ReadCluster(tester.initProjects[0].ID, cluster.Model.ID)
 	if err != nil {
 		t.Fatalf("%v\n", err)
 	}
@@ -433,7 +433,7 @@ func TestDeleteCluster(t *testing.T) {
 	initCluster(tester, t)
 	defer cleanup(tester, t)
 
-	cluster, err := tester.repo.Cluster().ReadCluster(tester.initClusters[0].Model.ID)
+	cluster, err := tester.repo.Cluster().ReadCluster(tester.initProjects[0].ID, tester.initClusters[0].Model.ID)
 
 	if err != nil {
 		t.Fatalf("%v\n", err)
@@ -445,7 +445,7 @@ func TestDeleteCluster(t *testing.T) {
 		t.Fatalf("%v\n", err)
 	}
 
-	_, err = tester.repo.Cluster().ReadCluster(tester.initClusters[0].Model.ID)
+	_, err = tester.repo.Cluster().ReadCluster(tester.initProjects[0].ID, tester.initClusters[0].Model.ID)
 
 	if err != orm.ErrRecordNotFound {
 		t.Fatalf("incorrect error: expected %v, got %v\n", orm.ErrRecordNotFound, err)

+ 2 - 2
internal/repository/test/cluster.go

@@ -41,7 +41,7 @@ func (repo *ClusterRepository) CreateClusterCandidate(
 }
 
 // ReadClusterCandidate finds a service account candidate by id
-func (repo *ClusterRepository) ReadClusterCandidate(id uint) (*models.ClusterCandidate, error) {
+func (repo *ClusterRepository) ReadClusterCandidate(projectID, id uint) (*models.ClusterCandidate, error) {
 	if !repo.canQuery {
 		return nil, errors.New("Cannot read from database")
 	}
@@ -110,7 +110,7 @@ func (repo *ClusterRepository) CreateCluster(
 
 // ReadCluster finds a service account by id
 func (repo *ClusterRepository) ReadCluster(
-	id uint,
+	projectID, id uint,
 ) (*models.Cluster, error) {
 	if !repo.canQuery {
 		return nil, errors.New("Cannot read from database")

+ 22 - 8
internal/templater/parser/parser.go

@@ -2,9 +2,10 @@ package parser
 
 import (
 	"fmt"
+	"strings"
 
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/helm"
-	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/templater"
 	"github.com/porter-dev/porter/internal/templater/utils"
 	"helm.sh/helm/v3/pkg/chart"
@@ -35,13 +36,26 @@ type ContextConfig struct {
 	TemplateWriter templater.TemplateWriter
 }
 
+// GetFormFromRelease returns the form by parsing a release's files. Returns nil if
+// the form is not found, throws an error if the form was found but there was a parsing
+// error.
+func GetFormFromRelease(def *ClientConfigDefault, rel *release.Release) (*types.FormYAML, error) {
+	for _, file := range rel.Chart.Files {
+		if strings.Contains(file.Name, "form.yaml") {
+			return FormYAMLFromBytes(def, file.Data, "")
+		}
+	}
+
+	return nil, nil
+}
+
 // FormYAMLFromBytes generates a usable form yaml from raw form config and a
 // set of default clients.
 //
 // stateType refers to the types of state that should be read. The two state types
 // are "live" and "declared" -- if stateType is "", this will read both live and
 // declared states.
-func FormYAMLFromBytes(def *ClientConfigDefault, bytes []byte, stateType string) (*models.FormYAML, error) {
+func FormYAMLFromBytes(def *ClientConfigDefault, bytes []byte, stateType string) (*types.FormYAML, error) {
 	form, err := unqueriedFormYAMLFromBytes(bytes)
 
 	if err != nil {
@@ -83,9 +97,9 @@ func FormYAMLFromBytes(def *ClientConfigDefault, bytes []byte, stateType string)
 }
 
 // unqueriedFormYAMLFromBytes returns a FormYAML without values queries populated
-func unqueriedFormYAMLFromBytes(bytes []byte) (*models.FormYAML, error) {
+func unqueriedFormYAMLFromBytes(bytes []byte) (*types.FormYAML, error) {
 	// parse bytes into object
-	form := &models.FormYAML{}
+	form := &types.FormYAML{}
 
 	err := yaml.Unmarshal(bytes, form)
 
@@ -94,7 +108,7 @@ func unqueriedFormYAMLFromBytes(bytes []byte) (*models.FormYAML, error) {
 	}
 
 	// populate all context fields, with default set to helm/values with no config
-	parent := &models.FormContext{
+	parent := &types.FormContext{
 		Type: "helm/values",
 	}
 
@@ -121,8 +135,8 @@ func unqueriedFormYAMLFromBytes(bytes []byte) (*models.FormYAML, error) {
 
 // create map[*FormContext]*ContextConfig
 // assumes all contexts populated
-func formToLookupTable(def *ClientConfigDefault, form *models.FormYAML, stateType string) map[*models.FormContext]*ContextConfig {
-	lookup := make(map[*models.FormContext]*ContextConfig)
+func formToLookupTable(def *ClientConfigDefault, form *types.FormYAML, stateType string) map[*types.FormContext]*ContextConfig {
+	lookup := make(map[*types.FormContext]*ContextConfig)
 
 	for i, tab := range form.Tabs {
 		for j, section := range tab.Sections {
@@ -180,7 +194,7 @@ func formToLookupTable(def *ClientConfigDefault, form *models.FormYAML, stateTyp
 // TODO -- this needs to be able to construct new context configs based on
 // configuration for each context, but right now just uses the default config
 // for everything
-func formContextToContextConfig(def *ClientConfigDefault, context *models.FormContext, stateType string) *ContextConfig {
+func formContextToContextConfig(def *ClientConfigDefault, context *types.FormContext, stateType string) *ContextConfig {
 	res := &ContextConfig{}
 
 	if context.Type == "helm/values" && (stateType == "" || stateType == "declared") {