Pārlūkot izejas kodu

Merge pull request #1734 from porter-dev/belanger/add-in-cluster-option

Add more support for self-hosting inside an existing Kubernetes cluster
abelanger5 4 gadi atpakaļ
vecāks
revīzija
e45ac64629
34 mainītis faili ar 1151 papildinājumiem un 211 dzēšanām
  1. 20 0
      api/client/registry.go
  2. 4 3
      api/server/authz/cluster.go
  3. 76 0
      api/server/handlers/helmrepo/create.go
  4. 81 0
      api/server/handlers/helmrepo/get_chart.go
  5. 44 0
      api/server/handlers/helmrepo/list.go
  6. 63 0
      api/server/handlers/helmrepo/list_charts.go
  7. 50 1
      api/server/handlers/release/create_addon.go
  8. 11 6
      api/server/handlers/release/ugprade.go
  9. 1 1
      api/server/handlers/template/list.go
  10. 38 0
      api/server/handlers/user/create.go
  11. 6 0
      api/server/handlers/user/github_callback.go
  12. 6 0
      api/server/handlers/user/google_callback.go
  13. 58 0
      api/server/router/helm_repo.go
  14. 56 0
      api/server/router/project.go
  15. 1 0
      api/server/shared/config/env/envconfs.go
  16. 6 0
      api/types/helm_repo.go
  17. 2 0
      api/types/release.go
  18. 2 0
      api/types/template.go
  19. 26 0
      cli/cmd/connect.go
  20. 94 0
      cli/cmd/connect/helmrepo.go
  21. 63 0
      cmd/app/main.go
  22. 67 141
      dashboard/src/main/home/launch/Launch.tsx
  23. 236 0
      dashboard/src/main/home/launch/TemplateList.tsx
  24. 56 23
      dashboard/src/main/home/launch/expanded-template/ExpandedTemplate.tsx
  25. 3 4
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  26. 29 0
      dashboard/src/shared/api.tsx
  27. 1 0
      dashboard/src/shared/types.tsx
  28. 11 9
      internal/helm/config.go
  29. 2 1
      internal/helm/loader/loader.go
  30. 1 1
      internal/helm/repo/repo.go
  31. 16 4
      internal/kubernetes/config.go
  32. 9 8
      internal/models/cluster.go
  33. 8 6
      internal/redis_stream/global_stream.go
  34. 4 3
      internal/usage/usage.go

+ 20 - 0
api/client/registry.go

@@ -27,6 +27,26 @@ func (c *Client) CreateRegistry(
 	return resp, err
 }
 
+// CreateRegistry creates a new registry integration
+func (c *Client) CreateHelmRepo(
+	ctx context.Context,
+	projectID uint,
+	req *types.CreateHelmRepoRequest,
+) (*types.HelmRepo, error) {
+	resp := &types.HelmRepo{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/helmrepos",
+			projectID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
 // ListRegistries returns a list of registries for a project
 func (c *Client) ListRegistries(
 	ctx context.Context,

+ 4 - 3
api/server/authz/cluster.go

@@ -86,9 +86,10 @@ func NewOutOfClusterAgentGetter(config *config.Config) KubernetesAgentGetter {
 
 func (d *OutOfClusterAgentGetter) GetOutOfClusterConfig(cluster *models.Cluster) *kubernetes.OutOfClusterConfig {
 	return &kubernetes.OutOfClusterConfig{
-		Repo:              d.config.Repo,
-		DigitalOceanOAuth: d.config.DOConf,
-		Cluster:           cluster,
+		Repo:                      d.config.Repo,
+		DigitalOceanOAuth:         d.config.DOConf,
+		Cluster:                   cluster,
+		AllowInClusterConnections: d.config.ServerConf.InitInCluster,
 	}
 }
 

+ 76 - 0
api/server/handlers/helmrepo/create.go

@@ -0,0 +1,76 @@
+package helmrepo
+
+import (
+	"errors"
+	"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/models"
+	"gorm.io/gorm"
+)
+
+type HelmRepoCreateHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewHelmRepoCreateHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *HelmRepoCreateHandler {
+	return &HelmRepoCreateHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *HelmRepoCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	request := &types.CreateHelmRepoRequest{}
+
+	ok := p.DecodeAndValidate(w, r, request)
+
+	if !ok {
+		return
+	}
+
+	// if a basic integration is specified, verify that it exists in the project
+	if request.BasicIntegrationID != 0 {
+		_, err := p.Repo().BasicIntegration().ReadBasicIntegration(proj.ID, request.BasicIntegrationID)
+
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				p.HandleAPIError(w, r, apierrors.NewErrForbidden(
+					fmt.Errorf("basic integration with id %d not found in project %d", request.BasicIntegrationID, proj.ID),
+				))
+
+				return
+			}
+
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	hr := &models.HelmRepo{
+		Name:                   request.Name,
+		ProjectID:              proj.ID,
+		RepoURL:                request.URL,
+		BasicAuthIntegrationID: request.BasicIntegrationID,
+	}
+
+	// handle write to the database
+	hr, err := p.Repo().HelmRepo().CreateHelmRepo(hr)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	p.WriteResult(w, r, hr.ToHelmRepoType())
+}

+ 81 - 0
api/server/handlers/helmrepo/get_chart.go

@@ -0,0 +1,81 @@
+package helmrepo
+
+import (
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/handlers/release"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/templater/parser"
+)
+
+type ChartGetHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewChartGetHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ChartGetHandler {
+	return &ChartGetHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (t *ChartGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	helmRepo, _ := r.Context().Value(types.HelmRepoScope).(*models.HelmRepo)
+
+	name, _ := requestutils.GetURLParamString(r, types.URLParamTemplateName)
+	version, _ := requestutils.GetURLParamString(r, types.URLParamTemplateVersion)
+
+	// if version passed as latest, pass empty string to loader to get latest
+	if version == "latest" {
+		version = ""
+	}
+
+	chart, err := release.LoadChart(t.Config(), &release.LoadAddonChartOpts{
+		ProjectID:       proj.ID,
+		RepoURL:         helmRepo.RepoURL,
+		TemplateName:    name,
+		TemplateVersion: version,
+	})
+
+	if err != nil {
+		t.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	parserDef := &parser.ClientConfigDefault{
+		HelmChart: chart,
+	}
+
+	res := &types.GetTemplateResponse{
+		RepoURL: helmRepo.RepoURL,
+	}
+	res.Metadata = chart.Metadata
+	res.Values = chart.Values
+
+	for _, file := range chart.Files {
+		if strings.Contains(file.Name, "form.yaml") {
+			formYAML, err := parser.FormYAMLFromBytes(parserDef, file.Data, "declared")
+
+			if err != nil {
+				break
+			}
+
+			res.Form = formYAML
+		} else if strings.Contains(file.Name, "README.md") {
+			res.Markdown = string(file.Data)
+		}
+	}
+
+	t.WriteResult(w, r, res)
+}

+ 44 - 0
api/server/handlers/helmrepo/list.go

@@ -0,0 +1,44 @@
+package helmrepo
+
+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 HelmRepoListHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewHelmRepoListHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *HelmRepoListHandler {
+	return &HelmRepoListHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *HelmRepoListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	hrs, err := c.Repo().HelmRepo().ListHelmReposByProjectID(proj.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := make([]*types.HelmRepo, 0)
+
+	for _, hr := range hrs {
+		res = append(res, hr.ToHelmRepoType())
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 63 - 0
api/server/handlers/helmrepo/list_charts.go

@@ -0,0 +1,63 @@
+package helmrepo
+
+import (
+	"net/http"
+
+	"k8s.io/helm/pkg/repo"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/helm/loader"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ChartListHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewChartListHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ChartListHandler {
+	return &ChartListHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (t *ChartListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	helmRepo, _ := r.Context().Value(types.HelmRepoScope).(*models.HelmRepo)
+
+	var repoIndex *repo.IndexFile
+	var err error
+
+	if helmRepo.BasicAuthIntegrationID != 0 {
+		// read the basic integration id
+		basic, err := t.Repo().BasicIntegration().ReadBasicIntegration(proj.ID, helmRepo.BasicAuthIntegrationID)
+
+		if err != nil {
+			t.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+
+		repoIndex, err = loader.LoadRepoIndex(&loader.BasicAuthClient{
+			Username: string(basic.Username),
+			Password: string(basic.Password),
+		}, helmRepo.RepoURL)
+	} else {
+		repoIndex, err = loader.LoadRepoIndexPublic(helmRepo.RepoURL)
+	}
+
+	if err != nil {
+		t.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	charts := loader.RepoIndexToPorterChartList(repoIndex, helmRepo.RepoURL)
+
+	t.WriteResult(w, r, charts)
+}

+ 50 - 1
api/server/handlers/release/create_addon.go

@@ -15,6 +15,7 @@ import (
 	"github.com/porter-dev/porter/internal/helm/loader"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/oauth"
+	"helm.sh/helm/v3/pkg/chart"
 )
 
 type CreateAddonHandler struct {
@@ -35,6 +36,7 @@ func NewCreateAddonHandler(
 
 func (c *CreateAddonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 	namespace := r.Context().Value(types.NamespaceScope).(string)
 	operationID := oauth.CreateRandomState()
@@ -63,7 +65,12 @@ func (c *CreateAddonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		request.TemplateVersion = ""
 	}
 
-	chart, err := loader.LoadChartPublic(request.RepoURL, request.TemplateName, request.TemplateVersion)
+	chart, err := LoadChart(c.Config(), &LoadAddonChartOpts{
+		ProjectID:       proj.ID,
+		RepoURL:         request.RepoURL,
+		TemplateName:    request.TemplateName,
+		TemplateVersion: request.TemplateVersion,
+	})
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -112,3 +119,45 @@ func (c *CreateAddonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		},
 	))
 }
+
+type LoadAddonChartOpts struct {
+	ProjectID                              uint
+	RepoURL, TemplateName, TemplateVersion string
+}
+
+func LoadChart(config *config.Config, opts *LoadAddonChartOpts) (*chart.Chart, error) {
+	// if the chart repo url is one of the specified application/addon charts, just load public
+	if opts.RepoURL == config.ServerConf.DefaultAddonHelmRepoURL || opts.RepoURL == config.ServerConf.DefaultApplicationHelmRepoURL {
+		return loader.LoadChartPublic(opts.RepoURL, opts.TemplateName, opts.TemplateVersion)
+	} else {
+		// load the helm repos in the project
+		hrs, err := config.Repo.HelmRepo().ListHelmReposByProjectID(opts.ProjectID)
+
+		if err != nil {
+			return nil, err
+		}
+
+		for _, hr := range hrs {
+			if hr.RepoURL == opts.RepoURL {
+				if hr.BasicAuthIntegrationID != 0 {
+					// read the basic integration id
+					basic, err := config.Repo.BasicIntegration().ReadBasicIntegration(opts.ProjectID, hr.BasicAuthIntegrationID)
+
+					if err != nil {
+
+						return nil, err
+					}
+
+					return loader.LoadChart(&loader.BasicAuthClient{
+						Username: string(basic.Username),
+						Password: string(basic.Password),
+					}, hr.RepoURL, opts.TemplateName, opts.TemplateVersion)
+				} else {
+					return loader.LoadChartPublic(hr.RepoURL, opts.TemplateName, opts.TemplateVersion)
+				}
+			}
+		}
+	}
+
+	return nil, fmt.Errorf("chart repo not found")
+}

+ 11 - 6
api/server/handlers/release/ugprade.go

@@ -14,7 +14,6 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/helm"
-	"github.com/porter-dev/porter/internal/helm/loader"
 	"github.com/porter-dev/porter/internal/integrations/slack"
 	"github.com/porter-dev/porter/internal/models"
 	"helm.sh/helm/v3/pkg/release"
@@ -94,11 +93,17 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 			}
 		}
 
-		chart, err := loader.LoadChartPublic(
-			chartRepoURL,
-			helmRelease.Chart.Metadata.Name,
-			request.ChartVersion,
-		)
+		chart, err := LoadChart(c.Config(), &LoadAddonChartOpts{
+			ProjectID:       cluster.ProjectID,
+			RepoURL:         chartRepoURL,
+			TemplateName:    helmRelease.Chart.Metadata.Name,
+			TemplateVersion: request.ChartVersion,
+		})
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
 
 		if err != nil {
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(

+ 1 - 1
api/server/handlers/template/list.go

@@ -47,7 +47,7 @@ func (t *TemplateListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	porterCharts := loader.RepoIndexToPorterChartList(repoIndex)
+	porterCharts := loader.RepoIndexToPorterChartList(repoIndex, repoURL)
 
 	t.WriteResult(w, r, porterCharts)
 }

+ 38 - 0
api/server/handlers/user/create.go

@@ -76,6 +76,13 @@ func (u *UserCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	err = addUserToDefaultProject(u.Config(), user)
+
+	if err != nil {
+		u.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	// save the user as authenticated in the session
 	redirect, err := authn.SaveUserAuthenticated(w, r, u.Config(), user)
 
@@ -113,3 +120,34 @@ func doesUserExist(userRepo repository.UserRepository, user *models.User) bool {
 
 	return user != nil && err == nil
 }
+
+// addUserToDefaultProject adds the created user to any default projects if required by
+// config variables.
+func addUserToDefaultProject(config *config.Config, user *models.User) error {
+	if config.ServerConf.InitInCluster {
+		// if this is the first user, add the user to the default project
+		if user.ID == 1 {
+			// read the default project
+			project, err := config.Repo.Project().ReadProject(1)
+
+			if err != nil {
+				return err
+			}
+
+			// create a new Role with the user as the admin
+			_, err = config.Repo.Project().CreateProjectRole(project, &models.Role{
+				Role: types.Role{
+					UserID:    user.ID,
+					ProjectID: project.ID,
+					Kind:      types.RoleAdmin,
+				},
+			})
+
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}

+ 6 - 0
api/server/handlers/user/github_callback.go

@@ -158,6 +158,12 @@ func upsertUserFromToken(config *config.Config, tok *oauth2.Token) (*models.User
 				return nil, err
 			}
 
+			err = addUserToDefaultProject(config, user)
+
+			if err != nil {
+				return nil, err
+			}
+
 			config.AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{
 				UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
 				Email:               user.Email,

+ 6 - 0
api/server/handlers/user/google_callback.go

@@ -143,6 +143,12 @@ func upsertGoogleUserFromToken(config *config.Config, tok *oauth2.Token) (*model
 				return nil, err
 			}
 
+			err = addUserToDefaultProject(config, user)
+
+			if err != nil {
+				return nil, err
+			}
+
 			config.AnalyticsClient.Track(analytics.UserCreateTrack(&analytics.UserCreateTrackOpts{
 				UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
 				Email:               user.Email,

+ 58 - 0
api/server/router/helm_repo.go

@@ -80,5 +80,63 @@ func getHelmRepoRoutes(
 		Router:   r,
 	})
 
+	//  GET /api/projects/{project_id}/helmrepos/{helm_repo_id}/charts -> helmrepo.NewChartListHandler
+	hrListEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/charts",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.HelmRepoScope,
+			},
+		},
+	)
+
+	hrListHandler := helmrepo.NewChartListHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: hrListEndpoint,
+		Handler:  hrListHandler,
+		Router:   r,
+	})
+
+	//  GET /api/projects/{project_id}/helmrepos/{helm_repo_id}/charts/{name}/{version} -> helmrepo.NewChartGetHandler
+	chartGetEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/charts/{name}/{version}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.HelmRepoScope,
+			},
+		},
+	)
+
+	chartGetHandler := helmrepo.NewChartGetHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: chartGetEndpoint,
+		Handler:  chartGetHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 56 - 0
api/server/router/project.go

@@ -5,6 +5,7 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers/billing"
 	"github.com/porter-dev/porter/api/server/handlers/cluster"
 	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
+	"github.com/porter-dev/porter/api/server/handlers/helmrepo"
 	"github.com/porter-dev/porter/api/server/handlers/project"
 	"github.com/porter-dev/porter/api/server/handlers/provision"
 	"github.com/porter-dev/porter/api/server/handlers/registry"
@@ -806,5 +807,60 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	//  POST /api/projects/{project_id}/helmrepos -> helmrepo.NewHelmRepoCreateHandler
+	hrCreateEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/helmrepos",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	hrCreateHandler := helmrepo.NewHelmRepoCreateHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: hrCreateEndpoint,
+		Handler:  hrCreateHandler,
+		Router:   r,
+	})
+
+	//  GET /api/projects/{project_id}/helmrepos -> helmrepo.NewHelmRepoListHandler
+	hrListEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/helmrepos",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	hrListHandler := helmrepo.NewHelmRepoListHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: hrListEndpoint,
+		Handler:  hrListHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

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

@@ -89,6 +89,7 @@ type ServerConf struct {
 	ProvisionerCluster string `env:"PROVISIONER_CLUSTER"`
 	IngressCluster     string `env:"INGRESS_CLUSTER"`
 	SelfKubeconfig     string `env:"SELF_KUBECONFIG"`
+	InitInCluster      bool   `env:"INIT_IN_CLUSTER,default=false"`
 
 	WelcomeFormWebhook string `env:"WELCOME_FORM_WEBHOOK"`
 

+ 6 - 0
api/types/helm_repo.go

@@ -13,3 +13,9 @@ type HelmRepo struct {
 }
 
 type GetHelmRepoResponse HelmRepo
+
+type CreateHelmRepoRequest struct {
+	URL                string `json:"url"`
+	Name               string `json:"name" form:"required"`
+	BasicIntegrationID uint   `json:"basic_integration_id"`
+}

+ 2 - 0
api/types/release.go

@@ -51,6 +51,8 @@ type CreateReleaseRequest struct {
 
 type CreateAddonRequest struct {
 	*CreateReleaseBaseRequest
+
+	HelmRepoID uint `json:"helm_repo_id"`
 }
 
 type RollbackReleaseRequest struct {

+ 2 - 0
api/types/template.go

@@ -23,6 +23,7 @@ type PorterTemplateSimple struct {
 	Versions    []string `json:"versions"`
 	Description string   `json:"description"`
 	Icon        string   `json:"icon"`
+	RepoURL     string   `json:"repo_url,omitempty"`
 }
 
 // ListTemplatesResponse is how a chart gets displayed when listed
@@ -38,6 +39,7 @@ type GetTemplateResponse struct {
 	Metadata *chart.Metadata        `json:"metadata"`
 	Values   map[string]interface{} `json:"values"`
 	Form     *FormYAML              `json:"form"`
+	RepoURL  string                 `json:"repo_url,omitempty"`
 }
 
 type GetTemplateUpgradeNotesRequest struct {

+ 26 - 0
cli/cmd/connect.go

@@ -68,6 +68,18 @@ var connectRegistryCmd = &cobra.Command{
 	},
 }
 
+var connectHelmRepoCmd = &cobra.Command{
+	Use:   "helm",
+	Short: "Adds a custom Helm registry to a project",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, runConnectHelmRepo)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
 var connectGCRCmd = &cobra.Command{
 	Use:   "gcr",
 	Short: "Adds a GCR instance to a project",
@@ -116,6 +128,7 @@ func init() {
 	connectCmd.AddCommand(connectDockerhubCmd)
 	connectCmd.AddCommand(connectGCRCmd)
 	connectCmd.AddCommand(connectDOCRCmd)
+	connectCmd.AddCommand(connectHelmRepoCmd)
 }
 
 func runConnectKubeconfig(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
@@ -204,3 +217,16 @@ func runConnectRegistry(_ *types.GetAuthenticatedUserResponse, client *api.Clien
 
 	return config.SetRegistry(regID)
 }
+
+func runConnectHelmRepo(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
+	hrID, err := connect.HelmRepo(
+		client,
+		config.Project,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	return config.SetHelmRepo(hrID)
+}

+ 94 - 0
cli/cmd/connect/helmrepo.go

@@ -0,0 +1,94 @@
+package connect
+
+import (
+	"context"
+	"fmt"
+	"net/url"
+
+	"github.com/porter-dev/porter/api/types"
+
+	"github.com/fatih/color"
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+)
+
+func HelmRepo(
+	client *api.Client,
+	projectID uint,
+) (uint, error) {
+	// if project ID is 0, ask the user to set the project ID or create a project
+	if projectID == 0 {
+		return 0, fmt.Errorf("no project set, please run porter project set [id]")
+	}
+
+	repoName, err := utils.PromptPlaintext(fmt.Sprintf(`Provide the name that you would like to give this Helm registry. 
+Name: `))
+
+	if err != nil {
+		return 0, err
+	}
+
+	repoURL, err := utils.PromptPlaintext(fmt.Sprintf(`Provide the Helm registry URL, make sure to include the protocol. For example, https://charts.bitnami.com/bitnami.
+Registry URL: `))
+
+	if err != nil {
+		return 0, err
+	}
+
+	if _, err := url.Parse(repoURL); err != nil {
+		return 0, fmt.Errorf("not a valid url: %s", err)
+	}
+
+	username, err := utils.PromptPlaintext(fmt.Sprintf(`Helm repo username (press enter for a public registry):`))
+
+	if err != nil {
+		return 0, err
+	}
+
+	password, err := utils.PromptPassword(`Helm registry password (press enter for a public registry).
+Password:`)
+
+	if err != nil {
+		return 0, err
+	}
+
+	var basicIntegrationID uint = 0
+
+	if username != "" && password != "" {
+		// create the basic auth integration
+		integration, err := client.CreateBasicAuthIntegration(
+			context.Background(),
+			projectID,
+			&types.CreateBasicRequest{
+				Username: username,
+				Password: password,
+			},
+		)
+
+		if err != nil {
+			return 0, err
+		}
+
+		color.New(color.FgGreen).Printf("created basic auth integration with id %d\n", integration.ID)
+
+		basicIntegrationID = integration.ID
+	}
+
+	reg, err := client.CreateHelmRepo(
+		context.Background(),
+		projectID,
+		&types.CreateHelmRepoRequest{
+			URL:                repoURL,
+			Name:               repoName,
+			BasicIntegrationID: basicIntegrationID,
+		},
+	)
+
+	if err != nil {
+		return 0, err
+	}
+
+	color.New(color.FgGreen).Printf("created helm registry integration with id %d and name %s\n", reg.ID, reg.Name)
+
+	return reg.ID, nil
+}

+ 63 - 0
cmd/app/main.go

@@ -1,6 +1,7 @@
 package main
 
 import (
+	"errors"
 	"flag"
 	"fmt"
 	"log"
@@ -8,9 +9,12 @@ import (
 	"os"
 
 	"github.com/porter-dev/porter/api/server/router"
+	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config/loader"
 	"github.com/porter-dev/porter/internal/adapter"
+	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/redis_stream"
+	"gorm.io/gorm"
 )
 
 // Version will be linked by an ldflag during build
@@ -35,6 +39,12 @@ func main() {
 		log.Fatal("Config loading failed: ", err)
 	}
 
+	err = initData(config)
+
+	if err != nil {
+		log.Fatal("Data initialization failed: ", err)
+	}
+
 	if config.RedisConf.Enabled {
 		redis, err := adapter.NewRedisClient(config.RedisConf)
 
@@ -68,3 +78,56 @@ func main() {
 		config.Logger.Fatal().Err(err).Msg("Server startup failed")
 	}
 }
+
+const defaultProjectName = "default"
+const defaultClusterName = "cluster-1"
+
+func initData(conf *config.Config) error {
+	// if the config specifies in-cluster connections are permitted, create a new project with a
+	// cluster that uses the in-cluster config. this will be the default project for this instance.
+	if conf.ServerConf.InitInCluster {
+		l := conf.Logger
+		l.Debug().Msg("in-cluster config variable set: checking for default project and cluster")
+
+		// look for a project with id 1 with name of defaultProjectName
+		_, err := conf.Repo.Project().ReadProject(1)
+
+		if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
+			l.Debug().Msg("default project not found: attempting creation")
+
+			_, err = conf.Repo.Project().CreateProject(&models.Project{
+				Name: defaultProjectName,
+			})
+
+			if err != nil {
+				return err
+			}
+
+			l.Debug().Msg("successfully created default project")
+		} else if err != nil {
+			return err
+		}
+
+		_, err = conf.Repo.Cluster().ReadCluster(1, 1)
+
+		if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
+			l.Debug().Msg("default cluster not found: attempting creation")
+
+			_, err = conf.Repo.Cluster().CreateCluster(&models.Cluster{
+				Name:          defaultClusterName,
+				AuthMechanism: models.InCluster,
+				ProjectID:     1,
+			})
+
+			if err != nil {
+				return err
+			}
+
+			l.Debug().Msg("successfully created default cluster")
+		} else if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}

+ 67 - 141
dashboard/src/main/home/launch/Launch.tsx

@@ -3,11 +3,7 @@ import styled from "styled-components";
 
 import { Context } from "shared/Context";
 import api from "shared/api";
-import {
-  ChartTypeWithExtendedConfig,
-  PorterTemplate,
-  StorageType,
-} from "shared/types";
+import { ChartTypeWithExtendedConfig, PorterTemplate } from "shared/types";
 
 import TabSelector from "components/TabSelector";
 import ExpandedTemplate from "./expanded-template/ExpandedTemplate";
@@ -16,16 +12,22 @@ import LaunchFlow from "./launch-flow/LaunchFlow";
 import NoClusterPlaceholder from "../NoClusterPlaceholder";
 import TitleSection from "components/TitleSection";
 
-import { hardcodedNames } from "shared/hardcodedNameDict";
 import semver from "semver";
 import { RouteComponentProps, withRouter } from "react-router";
 import { getQueryParam, getQueryParams } from "shared/routing";
+import TemplateList from "./TemplateList";
+import { capitalize } from "lodash";
 
-const tabOptions = [
+const initialTabOptions = [
   { label: "New Application", value: "porter" },
   { label: "Community Add-ons", value: "community" },
 ];
 
+type TabOption = {
+  label: string;
+  value: string;
+};
+
 const HIDDEN_CHARTS = ["porter-agent"];
 
 type PropsType = RouteComponentProps & {};
@@ -40,8 +42,8 @@ type StateType = {
   error: boolean;
   isOnLaunchFlow: boolean;
   clonedChart: ChartTypeWithExtendedConfig;
+  tabOptions: TabOption[];
 };
-
 class Templates extends Component<PropsType, StateType> {
   state = {
     currentTemplate: null as PorterTemplate | null,
@@ -53,6 +55,7 @@ class Templates extends Component<PropsType, StateType> {
     error: false,
     isOnLaunchFlow: false,
     clonedChart: null as ChartTypeWithExtendedConfig,
+    tabOptions: initialTabOptions,
   };
 
   async componentDidMount() {
@@ -163,6 +166,29 @@ class Templates extends Component<PropsType, StateType> {
     } catch (error) {
       this.setState({ loading: false, error: true });
     }
+
+    try {
+      const res = await api.getHelmRepos(
+        "<token>",
+        {},
+        {
+          project_id: this.context.currentProject.id,
+        }
+      );
+
+      let tabOptions = this.state.tabOptions.concat(
+        ...res.data.map((val: any) => {
+          return {
+            value: `${val.id}`,
+            label: capitalize(val.name),
+          };
+        })
+      );
+
+      this.setState({ tabOptions });
+    } catch (error) {
+      this.setState({ loading: false, error: true });
+    }
   }
 
   isTryingToClone = () => {
@@ -222,48 +248,37 @@ class Templates extends Component<PropsType, StateType> {
     );
   };
 
-  renderTemplateList = (templates: any) => {
-    let { loading, error } = this.state;
-
-    if (loading) {
-      return (
-        <LoadingWrapper>
-          <Loading />
-        </LoadingWrapper>
-      );
-    } else if (error) {
-      return (
-        <Placeholder>
-          <i className="material-icons">error</i> Error retrieving templates.
-        </Placeholder>
-      );
-    } else if (templates.length === 0) {
-      return (
-        <Placeholder>
-          <i className="material-icons">category</i> No templates found.
-        </Placeholder>
-      );
+  renderTemplateList = (templates?: any, helm_repo_id?: number) => {
+    if (!helm_repo_id && templates) {
+      if (this.state.loading) {
+        return (
+          <LoadingWrapper>
+            <Loading />
+          </LoadingWrapper>
+        );
+      } else if (this.state.error) {
+        return (
+          <Placeholder>
+            <i className="material-icons">error</i> Error retrieving templates.
+          </Placeholder>
+        );
+      } else if (templates.length === 0) {
+        return (
+          <Placeholder>
+            <i className="material-icons">category</i> No templates found.
+          </Placeholder>
+        );
+      }
     }
 
     return (
-      <TemplateList>
-        {templates.map((template: PorterTemplate, i: number) => {
-          let { name, icon, description } = template;
-          if (hardcodedNames[name]) {
-            name = hardcodedNames[name];
-          }
-          return (
-            <TemplateBlock
-              key={name}
-              onClick={() => this.setState({ currentTemplate: template })}
-            >
-              {this.renderIcon(icon)}
-              <TemplateTitle>{name}</TemplateTitle>
-              <TemplateDescription>{description}</TemplateDescription>
-            </TemplateBlock>
-          );
-        })}
-      </TemplateList>
+      <TemplateList
+        helm_repo_id={helm_repo_id}
+        templates={templates}
+        setCurrentTemplate={(template) =>
+          this.setState({ currentTemplate: template })
+        }
+      />
     );
   };
 
@@ -278,13 +293,16 @@ class Templates extends Component<PropsType, StateType> {
           setCurrentTemplate={(currentTemplate: PorterTemplate) => {
             this.setState({ currentTemplate });
           }}
+          helm_repo_id={parseInt(this.state.currentTab)}
         />
       );
     }
     if (this.state.currentTab === "porter") {
       return this.renderTemplateList(this.state.applicationTemplates);
-    } else {
+    } else if (this.state.currentTab == "community") {
       return this.renderTemplateList(this.state.addonTemplates);
+    } else {
+      return this.renderTemplateList(null, parseInt(this.state.currentTab));
     }
   };
 
@@ -293,7 +311,7 @@ class Templates extends Component<PropsType, StateType> {
       return (
         <>
           <TabSelector
-            options={tabOptions}
+            options={this.state.tabOptions}
             currentTab={this.state.currentTab}
             setCurrentTab={(value: string) =>
               this.setState({
@@ -389,31 +407,6 @@ const Banner = styled.div`
   }
 `;
 
-const Highlight = styled.div`
-  color: #8590ff;
-  cursor: pointer;
-  margin-left: 5px;
-  margin-right: 10px;
-`;
-
-const StyledStatusPlaceholder = styled.div`
-  width: 100%;
-  height: calc(100vh - 365px);
-  margin-top: 20px;
-  display: flex;
-  color: #aaaabb;
-  border-radius: 5px;
-  padding-bottom: 20px;
-  text-align: center;
-  font-size: 13px;
-  background: #ffffff09;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  font-family: "Work Sans", sans-serif;
-  user-select: text;
-`;
-
 const LoadingWrapper = styled.div`
   padding-top: 300px;
 `;
@@ -432,73 +425,6 @@ const Polymer = styled.div`
   }
 `;
 
-const TemplateDescription = styled.div`
-  margin-bottom: 26px;
-  color: #ffffff66;
-  text-align: center;
-  font-weight: default;
-  padding: 0px 25px;
-  height: 2.4em;
-  font-size: 12px;
-  display: -webkit-box;
-  overflow: hidden;
-  -webkit-line-clamp: 2;
-  -webkit-box-orient: vertical;
-`;
-
-const TemplateTitle = styled.div`
-  margin-bottom: 12px;
-  width: 80%;
-  text-align: center;
-  font-size: 14px;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-`;
-
-const TemplateBlock = styled.div`
-  border: 1px solid #ffffff00;
-  align-items: center;
-  user-select: none;
-  border-radius: 8px;
-  display: flex;
-  font-size: 13px;
-  font-weight: 500;
-  padding: 3px 0px 5px;
-  flex-direction: column;
-  align-item: center;
-  justify-content: space-between;
-  height: 200px;
-  cursor: pointer;
-  color: #ffffff;
-  position: relative;
-  background: #26282f;
-  box-shadow: 0 4px 15px 0px #00000044;
-  :hover {
-    background: #ffffff11;
-  }
-
-  animation: fadeIn 0.3s 0s;
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
-const TemplateList = styled.div`
-  overflow: visible;
-  margin-top: 35px;
-  padding-bottom: 150px;
-  display: grid;
-  grid-column-gap: 25px;
-  grid-row-gap: 25px;
-  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
-`;
-
 const TemplatesWrapper = styled.div`
   width: calc(85%);
   overflow: visible;

+ 236 - 0
dashboard/src/main/home/launch/TemplateList.tsx

@@ -0,0 +1,236 @@
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import styled from "styled-components";
+
+import Loading from "components/Loading";
+import { hardcodedNames } from "shared/hardcodedNameDict";
+import { PorterTemplate } from "shared/types";
+import semver from "semver";
+
+type Props = {
+  helm_repo_id?: number;
+  templates?: PorterTemplate[];
+  setCurrentTemplate: (template: PorterTemplate) => void;
+};
+
+const TemplateList: React.FC<Props> = ({
+  helm_repo_id,
+  templates,
+  setCurrentTemplate,
+}) => {
+  const [isLoading, setIsLoading] = useState(!!helm_repo_id);
+  const [hasError, setHasError] = useState(false);
+  const [templateList, setTemplateList] = useState<PorterTemplate[]>(null);
+  const { currentProject, setCurrentError } = useContext(Context);
+
+  useEffect(() => {
+    let isSubscribed = true;
+    if (!currentProject || !helm_repo_id) {
+      return () => {
+        isSubscribed = false;
+      };
+    }
+
+    api
+      .getChartsFromHelmRepo(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          helm_repo_id: helm_repo_id,
+        }
+      )
+      .then(({ data }) => {
+        if (!isSubscribed) {
+          return;
+        }
+        if (!Array.isArray(data)) {
+          throw Error("Data is not an array");
+        }
+
+        let sortedVersionData = data.map((template: any) => {
+          let versions = template.versions.reverse();
+
+          versions = template.versions.sort(semver.rcompare);
+
+          return {
+            ...template,
+            versions,
+            currentVersion: versions[0],
+          };
+        }).sort((a: any, b: any) =>
+          a.name > b.name ? 1 : -1
+        );
+
+        setTemplateList(sortedVersionData);
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        console.error(err);
+
+        setHasError(true);
+        setCurrentError(err.response?.data?.error);
+        setIsLoading(false);
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [currentProject, helm_repo_id]);
+
+  if (isLoading || (!templates && !templateList)) {
+    return (
+      <LoadingWrapper>
+        <Loading />
+      </LoadingWrapper>
+    );
+  } else if (hasError) {
+    return (
+      <Placeholder>
+        <i className="material-icons">error</i> Error retrieving templates.
+      </Placeholder>
+    );
+  } else if (templateList && templateList.length === 0) {
+    return (
+      <Placeholder>
+        <i className="material-icons">category</i> No templates found.
+      </Placeholder>
+    );
+  }
+
+  const renderIcon = (icon: string) => {
+    if (icon) {
+      return <Icon src={icon} />;
+    }
+
+    return (
+      <Polymer>
+        <i className="material-icons">layers</i>
+      </Polymer>
+    );
+  };
+
+  return (
+    <TemplateListWrapper>
+      {(templates || templateList)?.map((template: PorterTemplate) => {
+        let { name, icon, description } = template;
+        if (hardcodedNames[name]) {
+          name = hardcodedNames[name];
+        }
+        return (
+          <TemplateBlock
+            key={name}
+            onClick={() => setCurrentTemplate(template)}
+          >
+            {renderIcon(icon)}
+            <TemplateTitle>{name}</TemplateTitle>
+            <TemplateDescription>{description}</TemplateDescription>
+          </TemplateBlock>
+        );
+      })}
+    </TemplateListWrapper>
+  );
+};
+
+export default TemplateList;
+
+const Placeholder = styled.div`
+  padding-top: 200px;
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: #ffffff44;
+  font-size: 14px;
+
+  > i {
+    font-size: 18px;
+    margin-right: 12px;
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding-top: 300px;
+`;
+
+const Icon = styled.img`
+  height: 42px;
+  margin-top: 35px;
+  margin-bottom: 13px;
+`;
+
+const Polymer = styled.div`
+  > i {
+    font-size: 34px;
+    margin-top: 38px;
+    margin-bottom: 20px;
+  }
+`;
+
+const TemplateDescription = styled.div`
+  margin-bottom: 26px;
+  color: #ffffff66;
+  text-align: center;
+  font-weight: default;
+  padding: 0px 25px;
+  height: 2.4em;
+  font-size: 12px;
+  display: -webkit-box;
+  overflow: hidden;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+`;
+
+const TemplateTitle = styled.div`
+  margin-bottom: 12px;
+  width: 80%;
+  text-align: center;
+  font-size: 14px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const TemplateBlock = styled.div`
+  border: 1px solid #ffffff00;
+  align-items: center;
+  user-select: none;
+  border-radius: 8px;
+  display: flex;
+  font-size: 13px;
+  font-weight: 500;
+  padding: 3px 0px 5px;
+  flex-direction: column;
+  align-item: center;
+  justify-content: space-between;
+  height: 200px;
+  cursor: pointer;
+  color: #ffffff;
+  position: relative;
+  background: #26282f;
+  box-shadow: 0 4px 15px 0px #00000044;
+  :hover {
+    background: #ffffff11;
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const TemplateListWrapper = styled.div`
+  overflow: visible;
+  margin-top: 35px;
+  padding-bottom: 150px;
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+`;

+ 56 - 23
dashboard/src/main/home/launch/expanded-template/ExpandedTemplate.tsx

@@ -6,6 +6,7 @@ import api from "shared/api";
 
 import TemplateInfo from "./TemplateInfo";
 import Loading from "components/Loading";
+import { Context } from "shared/Context";
 
 type PropsType = {
   currentTemplate: PorterTemplate;
@@ -14,6 +15,8 @@ type PropsType = {
   skipDescription?: boolean;
   showLaunchFlow: () => void;
   setForm: (x: any) => void;
+  helm_repo_id?: number;
+  repo_url?: string;
 };
 
 type StateType = {
@@ -43,29 +46,57 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
 
   fetchTemplateInfo = () => {
     this.setState({ loading: true });
-    let params =
-      this.props.currentTab == "porter"
-        ? { repo_url: process.env.APPLICATION_CHART_REPO_URL }
-        : { repo_url: process.env.ADDON_CHART_REPO_URL };
-
-    api
-      .getTemplateInfo("<token>", params, {
-        name: this.props.currentTemplate.name.toLowerCase().trim(),
-        version: this.props.currentTemplate.currentVersion,
-      })
-      .then((res) => {
-        let { form, values, markdown, metadata } = res.data;
-        let keywords = metadata.keywords;
-        this.props.setForm(form);
-        this.setState({
-          values,
-          markdown,
-          keywords,
-          loading: false,
-          error: false,
-        });
-      })
-      .catch((err) => this.setState({ loading: false, error: true }));
+
+    if (this.props.helm_repo_id) {
+      api
+        .getChartInfoFromHelmRepo(
+          "<token>",
+          {},
+          {
+            project_id: this.context.currentProject.id,
+            helm_repo_id: this.props.helm_repo_id,
+            name: this.props.currentTemplate.name.toLowerCase().trim(),
+            version: this.props.currentTemplate.currentVersion,
+          }
+        )
+        .then((res) => {
+          let { form, values, markdown, metadata } = res.data;
+          let keywords = metadata.keywords;
+          this.props.setForm(form);
+          this.setState({
+            values,
+            markdown,
+            keywords,
+            loading: false,
+            error: false,
+          });
+        })
+        .catch((err) => this.setState({ loading: false, error: true }));
+    } else {
+      let params =
+        this.props.currentTab == "porter"
+          ? { repo_url: process.env.APPLICATION_CHART_REPO_URL }
+          : { repo_url: process.env.ADDON_CHART_REPO_URL };
+
+      api
+        .getTemplateInfo("<token>", params, {
+          name: this.props.currentTemplate.name.toLowerCase().trim(),
+          version: this.props.currentTemplate.currentVersion,
+        })
+        .then((res) => {
+          let { form, values, markdown, metadata } = res.data;
+          let keywords = metadata.keywords;
+          this.props.setForm(form);
+          this.setState({
+            values,
+            markdown,
+            keywords,
+            loading: false,
+            error: false,
+          });
+        })
+        .catch((err) => this.setState({ loading: false, error: true }));
+    }
   };
 
   componentDidUpdate = (prevProps: PropsType) => {
@@ -116,6 +147,8 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
   }
 }
 
+ExpandedTemplate.contextType = Context;
+
 const FadeWrapper = styled.div`
   animation: fadeIn 0.2s;
   @keyframes fadeIn {

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

@@ -6,11 +6,10 @@ import { RouteComponentProps, withRouter } from "react-router";
 
 import api from "shared/api";
 import { Context } from "shared/Context";
-import { getQueryParam, getQueryParams, pushFiltered } from "shared/routing";
+import { pushFiltered } from "shared/routing";
 
 import { hardcodedNames } from "shared/hardcodedNameDict";
 import SourcePage from "./SourcePage";
-import WorkflowPage from "./WorkflowPage";
 import SettingsPage from "./SettingsPage";
 import TitleSection from "components/TitleSection";
 
@@ -19,7 +18,6 @@ import {
   ChartTypeWithExtendedConfig,
   FullActionConfigType,
   PorterTemplate,
-  StorageType,
 } from "shared/types";
 
 type PropsType = RouteComponentProps & {
@@ -115,7 +113,8 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
           id: currentProject.id,
           cluster_id: currentCluster.id,
           namespace: selectedNamespace,
-          repo_url: process.env.ADDON_CHART_REPO_URL,
+          repo_url:
+            props.currentTemplate?.repo_url || process.env.ADDON_CHART_REPO_URL,
         }
       )
       .then((_) => {

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

@@ -931,6 +931,32 @@ const getTemplates = baseApi<
   {}
 >("GET", "/api/templates");
 
+const getHelmRepos = baseApi<
+  {},
+  {
+    project_id: number;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/helmrepos`;
+});
+
+const getChartsFromHelmRepo = baseApi<
+  {},
+  {
+    project_id: number;
+    helm_repo_id: number;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/helmrepos/${pathParams.helm_repo_id}/charts`;
+});
+
+const getChartInfoFromHelmRepo = baseApi<
+  {},
+  { project_id: number; helm_repo_id: number; name: string; version: string }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/helmrepos/${pathParams.helm_repo_id}/charts/${pathParams.name}/${pathParams.version}`;
+});
+
 const getMetadata = baseApi<{}, {}>("GET", () => {
   return `/api/metadata`;
 });
@@ -1563,6 +1589,9 @@ export default {
   getTemplateInfo,
   getTemplateUpgradeNotes,
   getTemplates,
+  getHelmRepos,
+  getChartsFromHelmRepo,
+  getChartInfoFromHelmRepo,
   linkGithubProject,
   getGithubAccounts,
   listConfigMaps,

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

@@ -152,6 +152,7 @@ export interface PorterTemplate {
   currentVersion: string;
   description: string;
   icon: string;
+  repo_url?: string;
 }
 
 // FormYAML represents a chart's values.yaml form abstraction

+ 11 - 9
internal/helm/config.go

@@ -20,11 +20,12 @@ import (
 // Form represents the options for connecting to a cluster and
 // creating a Helm agent
 type Form struct {
-	Cluster           *models.Cluster `form:"required"`
-	Repo              repository.Repository
-	DigitalOceanOAuth *oauth2.Config
-	Storage           string `json:"storage" form:"oneof=secret configmap memory" default:"secret"`
-	Namespace         string `json:"namespace"`
+	Cluster                   *models.Cluster `form:"required"`
+	Repo                      repository.Repository
+	DigitalOceanOAuth         *oauth2.Config
+	Storage                   string `json:"storage" form:"oneof=secret configmap memory" default:"secret"`
+	Namespace                 string `json:"namespace"`
+	AllowInClusterConnections bool
 }
 
 // GetAgentOutOfClusterConfig creates a new Agent from outside the cluster using
@@ -32,10 +33,11 @@ type Form struct {
 func GetAgentOutOfClusterConfig(form *Form, l *logger.Logger) (*Agent, error) {
 	// create a kubernetes agent
 	conf := &kubernetes.OutOfClusterConfig{
-		Cluster:           form.Cluster,
-		DefaultNamespace:  form.Namespace,
-		Repo:              form.Repo,
-		DigitalOceanOAuth: form.DigitalOceanOAuth,
+		Cluster:                   form.Cluster,
+		DefaultNamespace:          form.Namespace,
+		Repo:                      form.Repo,
+		DigitalOceanOAuth:         form.DigitalOceanOAuth,
+		AllowInClusterConnections: form.AllowInClusterConnections,
 	}
 
 	k8sAgent, err := kubernetes.GetAgentOutOfClusterConfig(conf)

+ 2 - 1
internal/helm/loader/loader.go

@@ -17,7 +17,7 @@ import (
 )
 
 // RepoIndexToPorterChartList converts an index file to a list of porter charts
-func RepoIndexToPorterChartList(index *repo.IndexFile) types.ListTemplatesResponse {
+func RepoIndexToPorterChartList(index *repo.IndexFile, repoURL string) types.ListTemplatesResponse {
 	// sort the entries before parsing
 	index.SortEntries()
 
@@ -36,6 +36,7 @@ func RepoIndexToPorterChartList(index *repo.IndexFile) types.ListTemplatesRespon
 			Description: indexChart.Description,
 			Icon:        indexChart.Icon,
 			Versions:    versions,
+			RepoURL:     repoURL,
 		}
 
 		porterCharts = append(porterCharts, porterChart)

+ 1 - 1
internal/helm/repo/repo.go

@@ -59,7 +59,7 @@ func (hr *HelmRepo) listChartsBasic(
 		return nil, err
 	}
 
-	return loader.RepoIndexToPorterChartList(repoIndex), nil
+	return loader.RepoIndexToPorterChartList(repoIndex, hr.RepoURL), nil
 }
 
 func (hr *HelmRepo) getChartBasic(

+ 16 - 4
internal/kubernetes/config.go

@@ -34,7 +34,14 @@ import (
 
 // GetDynamicClientOutOfClusterConfig creates a new dynamic client using the OutOfClusterConfig
 func GetDynamicClientOutOfClusterConfig(conf *OutOfClusterConfig) (dynamic.Interface, error) {
-	restConf, err := conf.ToRESTConfig()
+	var restConf *rest.Config
+	var err error
+
+	if conf.AllowInClusterConnections && conf.Cluster.AuthMechanism == models.InCluster {
+		restConf, err = rest.InClusterConfig()
+	} else {
+		restConf, err = conf.ToRESTConfig()
+	}
 
 	if err != nil {
 		return nil, err
@@ -51,6 +58,10 @@ func GetDynamicClientOutOfClusterConfig(conf *OutOfClusterConfig) (dynamic.Inter
 
 // GetAgentOutOfClusterConfig creates a new Agent using the OutOfClusterConfig
 func GetAgentOutOfClusterConfig(conf *OutOfClusterConfig) (*Agent, error) {
+	if conf.AllowInClusterConnections && conf.Cluster.AuthMechanism == models.InCluster {
+		return GetAgentInClusterConfig()
+	}
+
 	restConf, err := conf.ToRESTConfig()
 
 	if err != nil {
@@ -99,9 +110,10 @@ func GetAgentTesting(objects ...runtime.Object) *Agent {
 // OutOfClusterConfig is the set of parameters required for an out-of-cluster connection.
 // This implements RESTClientGetter
 type OutOfClusterConfig struct {
-	Cluster          *models.Cluster
-	Repo             repository.Repository
-	DefaultNamespace string // optional
+	Cluster                   *models.Cluster
+	Repo                      repository.Repository
+	DefaultNamespace          string // optional
+	AllowInClusterConnections bool
 
 	// Only required if using DigitalOcean OAuth as an auth mechanism
 	DigitalOceanOAuth *oauth2.Config

+ 9 - 8
internal/models/cluster.go

@@ -13,14 +13,15 @@ type ClusterAuth string
 
 // The support cluster candidate auth mechanisms
 const (
-	X509   ClusterAuth = "x509"
-	Basic  ClusterAuth = "basic"
-	Bearer ClusterAuth = "bearerToken"
-	OIDC   ClusterAuth = "oidc"
-	GCP    ClusterAuth = "gcp-sa"
-	AWS    ClusterAuth = "aws-sa"
-	DO     ClusterAuth = "do-oauth"
-	Local  ClusterAuth = "local"
+	X509      ClusterAuth = "x509"
+	Basic     ClusterAuth = "basic"
+	Bearer    ClusterAuth = "bearerToken"
+	OIDC      ClusterAuth = "oidc"
+	GCP       ClusterAuth = "gcp-sa"
+	AWS       ClusterAuth = "aws-sa"
+	DO        ClusterAuth = "do-oauth"
+	Local     ClusterAuth = "local"
+	InCluster ClusterAuth = "in-cluster"
 )
 
 // Cluster is an integration that can connect to a Kubernetes cluster via

+ 8 - 6
internal/redis_stream/global_stream.go

@@ -516,9 +516,10 @@ func createRDSEnvGroup(repo repository.Repository, config *config.Config, infra
 	}
 
 	ooc := &kubernetes.OutOfClusterConfig{
-		Repo:              config.Repo,
-		DigitalOceanOAuth: config.DOConf,
-		Cluster:           cluster,
+		Repo:                      config.Repo,
+		DigitalOceanOAuth:         config.DOConf,
+		Cluster:                   cluster,
+		AllowInClusterConnections: config.ServerConf.InitInCluster,
 	}
 
 	agent, err := kubernetes.GetAgentOutOfClusterConfig(ooc)
@@ -563,9 +564,10 @@ func deleteRDSEnvGroup(repo repository.Repository, config *config.Config, infra
 	}
 
 	ooc := &kubernetes.OutOfClusterConfig{
-		Repo:              config.Repo,
-		DigitalOceanOAuth: config.DOConf,
-		Cluster:           cluster,
+		Repo:                      config.Repo,
+		DigitalOceanOAuth:         config.DOConf,
+		Cluster:                   cluster,
+		AllowInClusterConnections: config.ServerConf.InitInCluster,
 	}
 
 	agent, err := kubernetes.GetAgentOutOfClusterConfig(ooc)

+ 4 - 3
internal/usage/usage.go

@@ -136,9 +136,10 @@ func getResourceUsage(opts *GetUsageOpts, clusters []*models.Cluster) (uint, uin
 
 	for _, cluster := range clusters {
 		ooc := &kubernetes.OutOfClusterConfig{
-			Cluster:           cluster,
-			Repo:              opts.Repo,
-			DigitalOceanOAuth: opts.DOConf,
+			Cluster:                   cluster,
+			Repo:                      opts.Repo,
+			DigitalOceanOAuth:         opts.DOConf,
+			AllowInClusterConnections: false,
 		}
 
 		agent, err := kubernetes.GetAgentOutOfClusterConfig(ooc)