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

Merge branch 'master' of github.com:porter-dev/porter into nico/expanded-chart-overhaul

jnfrati 4 лет назад
Родитель
Сommit
e969ebe22b
44 измененных файлов с 1581 добавлено и 280 удалено
  1. 1 1
      .github/workflows/prerelease.yaml
  2. 20 0
      api/client/registry.go
  3. 4 3
      api/server/authz/cluster.go
  4. 76 0
      api/server/handlers/helmrepo/create.go
  5. 81 0
      api/server/handlers/helmrepo/get_chart.go
  6. 44 0
      api/server/handlers/helmrepo/list.go
  7. 63 0
      api/server/handlers/helmrepo/list_charts.go
  8. 50 1
      api/server/handlers/release/create_addon.go
  9. 11 6
      api/server/handlers/release/ugprade.go
  10. 1 1
      api/server/handlers/template/list.go
  11. 38 0
      api/server/handlers/user/create.go
  12. 6 0
      api/server/handlers/user/github_callback.go
  13. 6 0
      api/server/handlers/user/google_callback.go
  14. 58 0
      api/server/router/helm_repo.go
  15. 56 0
      api/server/router/project.go
  16. 1 0
      api/server/shared/config/env/envconfs.go
  17. 6 0
      api/types/helm_repo.go
  18. 2 0
      api/types/release.go
  19. 2 0
      api/types/template.go
  20. 185 31
      cli/cmd/config.go
  21. 26 0
      cli/cmd/connect.go
  22. 94 0
      cli/cmd/connect/helmrepo.go
  23. 48 2
      cli/cmd/get.go
  24. 63 0
      cmd/app/main.go
  25. 1 0
      dashboard/src/index.html
  26. 52 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ConnectToLogsInstructionModal.tsx
  27. 85 19
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  28. 67 141
      dashboard/src/main/home/launch/Launch.tsx
  29. 236 0
      dashboard/src/main/home/launch/TemplateList.tsx
  30. 56 23
      dashboard/src/main/home/launch/expanded-template/ExpandedTemplate.tsx
  31. 3 4
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  32. 44 11
      dashboard/src/main/home/modals/Modal.tsx
  33. 29 0
      dashboard/src/shared/api.tsx
  34. 1 0
      dashboard/src/shared/types.tsx
  35. 1 0
      go.mod
  36. 2 0
      go.sum
  37. 11 9
      internal/helm/config.go
  38. 2 1
      internal/helm/loader/loader.go
  39. 11 5
      internal/helm/postrenderer.go
  40. 1 1
      internal/helm/repo/repo.go
  41. 16 4
      internal/kubernetes/config.go
  42. 9 8
      internal/models/cluster.go
  43. 8 6
      internal/redis_stream/global_stream.go
  44. 4 3
      internal/usage/usage.go

+ 1 - 1
.github/workflows/prerelease.yaml

@@ -505,7 +505,7 @@ jobs:
 
           git add .
 
-          git commit -m "Update to Porter GHA version ${{steps.tag_name.outputs.tag}}"
+          git diff --quiet --exit-code || git commit -m "Update to Porter GHA version ${{steps.tag_name.outputs.tag}}"
 
           git push -f
   run-new-release-tests-workflows:

+ 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 {

+ 185 - 31
cli/cmd/config.go

@@ -1,14 +1,20 @@
 package cmd
 
 import (
+	"context"
 	"fmt"
 	"io/ioutil"
 	"os"
 	"path/filepath"
 	"strconv"
 	"strings"
+	"time"
 
+	"github.com/briandowns/spinner"
 	"github.com/fatih/color"
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
 
@@ -278,63 +284,87 @@ var configCmd = &cobra.Command{
 
 var configSetProjectCmd = &cobra.Command{
 	Use:   "set-project [id]",
-	Args:  cobra.ExactArgs(1),
+	Args:  cobra.MaximumNArgs(1),
 	Short: "Saves the project id in the default configuration",
 	Run: func(cmd *cobra.Command, args []string) {
-		projID, err := strconv.ParseUint(args[0], 10, 64)
+		if len(args) == 0 {
+			err := checkLoginAndRun(args, listAndSetProject)
 
-		if err != nil {
-			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
-			os.Exit(1)
-		}
+			if err != nil {
+				os.Exit(1)
+			}
+		} else {
+			projID, err := strconv.ParseUint(args[0], 10, 64)
 
-		err = config.SetProject(uint(projID))
+			if err != nil {
+				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				os.Exit(1)
+			}
 
-		if err != nil {
-			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
-			os.Exit(1)
+			err = config.SetProject(uint(projID))
+
+			if err != nil {
+				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				os.Exit(1)
+			}
 		}
 	},
 }
 
 var configSetClusterCmd = &cobra.Command{
 	Use:   "set-cluster [id]",
-	Args:  cobra.ExactArgs(1),
+	Args:  cobra.MaximumNArgs(1),
 	Short: "Saves the cluster id in the default configuration",
 	Run: func(cmd *cobra.Command, args []string) {
-		clusterID, err := strconv.ParseUint(args[0], 10, 64)
+		if len(args) == 0 {
+			err := checkLoginAndRun(args, listAndSetCluster)
 
-		if err != nil {
-			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
-			os.Exit(1)
-		}
+			if err != nil {
+				os.Exit(1)
+			}
+		} else {
+			clusterID, err := strconv.ParseUint(args[0], 10, 64)
+
+			if err != nil {
+				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				os.Exit(1)
+			}
 
-		err = config.SetCluster(uint(clusterID))
+			err = config.SetCluster(uint(clusterID))
 
-		if err != nil {
-			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
-			os.Exit(1)
+			if err != nil {
+				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				os.Exit(1)
+			}
 		}
 	},
 }
 
 var configSetRegistryCmd = &cobra.Command{
 	Use:   "set-registry [id]",
-	Args:  cobra.ExactArgs(1),
+	Args:  cobra.MaximumNArgs(1),
 	Short: "Saves the registry id in the default configuration",
 	Run: func(cmd *cobra.Command, args []string) {
-		registryID, err := strconv.ParseUint(args[0], 10, 64)
+		if len(args) == 0 {
+			err := checkLoginAndRun(args, listAndSetRegistry)
 
-		if err != nil {
-			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
-			os.Exit(1)
-		}
+			if err != nil {
+				os.Exit(1)
+			}
+		} else {
+			registryID, err := strconv.ParseUint(args[0], 10, 64)
 
-		err = config.SetRegistry(uint(registryID))
+			if err != nil {
+				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				os.Exit(1)
+			}
 
-		if err != nil {
-			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
-			os.Exit(1)
+			err = config.SetRegistry(uint(registryID))
+
+			if err != nil {
+				color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+				os.Exit(1)
+			}
 		}
 	},
 }
@@ -391,7 +421,131 @@ func printConfig() error {
 		return err
 	}
 
-	fmt.Printf(string(config))
+	fmt.Println(string(config))
+
+	return nil
+}
+
+func listAndSetProject(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	s := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
+	s.Color("cyan")
+	s.Suffix = " Loading list of projects"
+	s.Start()
+
+	resp, err := client.ListUserProjects(context.Background())
+
+	s.Stop()
+
+	if err != nil {
+		return err
+	}
+
+	var projID uint64
+
+	if len(*resp) > 1 {
+		// only give the option to select when more than one option exists
+		projName, err := utils.PromptSelect("Select a project with ID", func() []string {
+			var names []string
+
+			for _, proj := range *resp {
+				names = append(names, fmt.Sprintf("%s - %d", proj.Name, proj.ID))
+			}
+
+			return names
+		}())
+
+		if err != nil {
+			return err
+		}
+
+		projID, _ = strconv.ParseUint(strings.Split(projName, " - ")[1], 10, 64)
+	} else {
+		projID = uint64((*resp)[0].ID)
+	}
+
+	config.SetProject(uint(projID))
+
+	return nil
+}
+
+func listAndSetCluster(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	s := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
+	s.Color("cyan")
+	s.Suffix = " Loading list of clusters"
+	s.Start()
+
+	resp, err := client.ListProjectClusters(context.Background(), config.Project)
+
+	s.Stop()
+
+	if err != nil {
+		return err
+	}
+
+	var clusterID uint64
+
+	if len(*resp) > 1 {
+		clusterName, err := utils.PromptSelect("Select a cluster with ID", func() []string {
+			var names []string
+
+			for _, cluster := range *resp {
+				names = append(names, fmt.Sprintf("%s - %d", cluster.Name, cluster.ID))
+			}
+
+			return names
+		}())
+
+		if err != nil {
+			return err
+		}
+
+		clusterID, _ = strconv.ParseUint(strings.Split(clusterName, " - ")[1], 10, 64)
+	} else {
+		clusterID = uint64((*resp)[0].ID)
+	}
+
+	config.SetCluster(uint(clusterID))
+
+	return nil
+}
+
+func listAndSetRegistry(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	s := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
+	s.Color("cyan")
+	s.Suffix = " Loading list of registries"
+	s.Start()
+
+	resp, err := client.ListRegistries(context.Background(), config.Project)
+
+	s.Stop()
+
+	if err != nil {
+		return err
+	}
+
+	var regID uint64
+
+	if len(*resp) > 1 {
+		regName, err := utils.PromptSelect("Select a registry with ID", func() []string {
+			var names []string
+
+			for _, cluster := range *resp {
+				names = append(names, fmt.Sprintf("%s - %d", cluster.Name, cluster.ID))
+			}
+
+			return names
+		}())
+
+		if err != nil {
+			return err
+		}
+
+		regID, _ = strconv.ParseUint(strings.Split(regName, " - ")[1], 10, 64)
+	} else {
+		regID = uint64((*resp)[0].ID)
+	}
+
+	config.SetRegistry(uint(regID))
 
 	return nil
 }

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

+ 48 - 2
cli/cmd/get.go

@@ -28,11 +28,23 @@ var getCmd = &cobra.Command{
 	},
 }
 
+// getValuesCmd represents the "porter get values" command
+var getValuesCmd = &cobra.Command{
+	Use:   "values [release]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Fetches the Helm values for a release.",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, getValues)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
 var output string
 
 func init() {
-	rootCmd.AddCommand(getCmd)
-
 	getCmd.PersistentFlags().StringVar(
 		&namespace,
 		"namespace",
@@ -46,6 +58,10 @@ func init() {
 		"",
 		"the output format to use (\"yaml\" or \"json\")",
 	)
+
+	getCmd.AddCommand(getValuesCmd)
+
+	rootCmd.AddCommand(getCmd)
 }
 
 type getReleaseInfo struct {
@@ -94,3 +110,33 @@ func get(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []strin
 
 	return nil
 }
+
+func getValues(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	rel, err := client.GetRelease(context.Background(), config.Project, config.Cluster, namespace, args[0])
+
+	if err != nil {
+		return err
+	}
+
+	values := rel.Config
+
+	if output == "json" {
+		bytes, err := json.Marshal(values)
+
+		if err != nil {
+			return err
+		}
+
+		fmt.Println(string(bytes))
+	} else { // yaml is the default
+		bytes, err := yaml.Marshal(values)
+
+		if err != nil {
+			return err
+		}
+
+		fmt.Println(string(bytes))
+	}
+
+	return 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
+}

+ 1 - 0
dashboard/src/index.html

@@ -181,5 +181,6 @@
   </head>
   <body>
     <div id="output"></div>
+    <div id="modal-root"></div>
   </body>
 </html>

+ 52 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ConnectToLogsInstructionModal.tsx

@@ -0,0 +1,52 @@
+import Modal from "main/home/modals/Modal";
+import React from "react";
+import styled from "styled-components";
+
+const ConnectToLogsInstructionModal: React.FC<{
+  show: boolean;
+  onClose: () => void;
+  chartName: string;
+  namespace: string;
+}> = ({ show, chartName, namespace, onClose }) => {
+  if (!show) {
+    return null;
+  }
+
+  return (
+    <Modal
+      onRequestClose={() => onClose()}
+      width="700px"
+      height="300px"
+      title="Shell Access Instructions"
+    >
+      To get shell live logs for this pod, make sure you have the Porter CLI
+      installed (installation instructions&nbsp;
+      <a href={"https://docs.porter.run/cli/installation"} target="_blank">
+        here
+      </a>
+      ).
+      <br />
+      <br />
+      Run the following line of code:
+      <Code>
+        porter logs {chartName || "[APP-NAME]"} --follow --namespace{" "}
+        {namespace || "[NAMESPACE]"}
+      </Code>
+    </Modal>
+  );
+};
+
+export default ConnectToLogsInstructionModal;
+
+const Code = styled.div`
+  background: #181b21;
+  padding: 10px 15px;
+  border: 1px solid #ffffff44;
+  border-radius: 5px;
+  margin: 10px 0px 15px;
+  color: #ffffff;
+  font-size: 13px;
+  user-select: text;
+  line-height: 1em;
+  font-family: monospace;
+`;

+ 85 - 19
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -11,8 +11,10 @@ import { Context } from "shared/Context";
 import * as Anser from "anser";
 import api from "shared/api";
 import { NewWebsocketOptions, useWebsockets } from "shared/hooks/useWebsockets";
+import CommandLineIcon from "assets/command-line-icon";
+import ConnectToLogsInstructionModal from "./ConnectToLogsInstructionModal";
 
-const MAX_LOGS = 1000;
+const MAX_LOGS = 250;
 
 type SelectedPodType = {
   spec: {
@@ -25,6 +27,9 @@ type SelectedPodType = {
   metadata: {
     name: string;
     namespace: string;
+    labels: {
+      [key: string]: string;
+    };
   };
   status: {
     phase: string;
@@ -36,23 +41,15 @@ const LogsFC: React.FC<{
   podError: string;
   rawText?: boolean;
 }> = ({ selectedPod, podError, rawText }) => {
-  const {
-    logs,
-    previousLogs,
-    containers,
-    currentContainer,
-    setCurrentContainer,
-    refresh,
-  } = useLogs(selectedPod);
-
-  const [showPreviousLogs, setShowPreviousLogs] = useState<boolean>(false);
-
   const [isScrollToBottomEnabled, setIsScrollToBottomEnabled] = useState(true);
 
+  const [showConnectionModal, setShowConnectionModal] = useState(false);
+
+  const shouldScroll = useRef<boolean>(true);
   const wrapperRef = useRef<HTMLDivElement>();
 
   const scrollToBottom = (smooth: boolean) => {
-    if (!wrapperRef.current) {
+    if (!wrapperRef.current || !shouldScroll.current) {
       return;
     }
 
@@ -71,11 +68,20 @@ const LogsFC: React.FC<{
     }
   };
 
+  const {
+    logs,
+    previousLogs,
+    containers,
+    currentContainer,
+    setCurrentContainer,
+    refresh,
+  } = useLogs(selectedPod, scrollToBottom);
+
+  const [showPreviousLogs, setShowPreviousLogs] = useState<boolean>(false);
+
   useEffect(() => {
-    if (isScrollToBottomEnabled) {
-      scrollToBottom(true);
-    }
-  }, [isScrollToBottomEnabled, logs]);
+    shouldScroll.current = isScrollToBottomEnabled;
+  }, [isScrollToBottomEnabled]);
 
   const renderLogs = () => {
     if (podError && podError != "") {
@@ -151,6 +157,21 @@ const LogsFC: React.FC<{
 
   const renderContent = () => (
     <>
+      {/* <ConnectToLogsInstructionModal
+        show={showConnectionModal}
+        onClose={() => setShowConnectionModal(false)}
+        chartName={selectedPod?.metadata?.labels["app.kubernetes.io/instance"]}
+        namespace={selectedPod?.metadata?.namespace}
+      />
+      <CLIModalIconWrapper
+        onClick={(e) => {
+          e.preventDefault();
+          setShowConnectionModal(true);
+        }}
+      >
+        <CLIModalIcon />
+        CLI Logs Instructions
+      </CLIModalIconWrapper> */}
       <Wrapper ref={wrapperRef}>{renderLogs()}</Wrapper>
       <LogTabs>
         {containers.map((containerName, _i, arr) => {
@@ -232,7 +253,10 @@ const LogsFC: React.FC<{
 
 export default LogsFC;
 
-const useLogs = (currentPod: SelectedPodType) => {
+const useLogs = (
+  currentPod: SelectedPodType,
+  scroll?: (smooth: boolean) => void
+) => {
   const currentPodName = useRef<string>();
 
   const { currentCluster, currentProject } = useContext(Context);
@@ -338,7 +362,9 @@ const useLogs = (currentPod: SelectedPodType) => {
           if (containerLogs.length > MAX_LOGS) {
             containerLogs.shift();
           }
-
+          if (typeof scroll === "function") {
+            scroll(true);
+          }
           return {
             ...logs,
             [containerName]: containerLogs,
@@ -572,3 +598,43 @@ const LogSpan = styled.span`
   background-color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
     props.ansi?.bg ? `rgb(${props.ansi?.bg})` : "transparent"};
 `;
+
+const CLIModalIconWrapper = styled.div`
+  max-width: 200px;
+  height: 35px;
+  margin: 10px;
+  font-size: 13px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 6px 20px 6px 10px;
+  text-align: left;
+  border: 1px solid #ffffff55;
+  border-radius: 8px;
+  background: #ffffff11;
+  color: #ffffffdd;
+  cursor: pointer;
+  :hover {
+    cursor: pointer;
+    background: #ffffff22;
+    > path {
+      fill: #ffffff77;
+    }
+  }
+
+  > path {
+    fill: #ffffff99;
+  }
+`;
+
+const CLIModalIcon = styled(CommandLineIcon)`
+  width: 32px;
+  height: 32px;
+  padding: 8px;
+
+  > path {
+    fill: #ffffff99;
+  }
+`;

+ 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((_) => {

+ 44 - 11
dashboard/src/main/home/modals/Modal.tsx

@@ -1,5 +1,6 @@
 import React, { Component } from "react";
 import styled from "styled-components";
+import ReactDOM from "react-dom";
 
 type PropsType = {
   onRequestClose?: () => void;
@@ -10,6 +11,8 @@ type PropsType = {
 
 type StateType = {};
 
+const modalRoot = document.getElementById("modal-root");
+
 export default class Modal extends Component<PropsType, StateType> {
   wrapperRef: any = React.createRef();
 
@@ -38,21 +41,51 @@ export default class Modal extends Component<PropsType, StateType> {
   render() {
     let { width, height } = this.props;
     return (
-      <Overlay>
-        <StyledModal ref={this.wrapperRef} width={width} height={height}>
-          {this.props.onRequestClose && (
-            <CloseButton onClick={this.props.onRequestClose}>
-              <i className="material-icons">close</i>
-            </CloseButton>
-          )}
-          {this.props.title && <ModalTitle>{this.props.title}</ModalTitle>}
-          {this.props.children}
-        </StyledModal>
-      </Overlay>
+      <PortalModal>
+        <Overlay>
+          <StyledModal ref={this.wrapperRef} width={width} height={height}>
+            {this.props.onRequestClose && (
+              <CloseButton onClick={this.props.onRequestClose}>
+                <i className="material-icons">close</i>
+              </CloseButton>
+            )}
+            {this.props.title && <ModalTitle>{this.props.title}</ModalTitle>}
+            {this.props.children}
+          </StyledModal>
+        </Overlay>
+      </PortalModal>
     );
   }
 }
 
+export class PortalModal extends Component {
+  el: Element;
+  constructor(props: any) {
+    super(props);
+    this.el = document.createElement("div");
+  }
+
+  componentDidMount() {
+    // The portal element is inserted in the DOM tree after
+    // the Modal's children are mounted, meaning that children
+    // will be mounted on a detached DOM node. If a child
+    // component requires to be attached to the DOM tree
+    // immediately when mounted, for example to measure a
+    // DOM node, or uses 'autoFocus' in a descendant, add
+    // state to Modal and only render the children when Modal
+    // is inserted in the DOM tree.
+    modalRoot.appendChild(this.el);
+  }
+
+  componentWillUnmount() {
+    modalRoot.removeChild(this.el);
+  }
+
+  render() {
+    return ReactDOM.createPortal(this.props.children, this.el);
+  }
+}
+
 const ModalTitle = styled.div`
   font-size: 18px;
   font-weight: 500;

+ 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

+ 1 - 0
go.mod

@@ -85,6 +85,7 @@ require (
 	github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/bits-and-blooms/bitset v1.2.0 // indirect
+	github.com/briandowns/spinner v1.18.1 // indirect
 	github.com/buildpacks/imgutil v0.0.0-20210510154637-009f91f52918 // indirect
 	github.com/buildpacks/lifecycle v0.11.3 // indirect
 	github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect

+ 2 - 0
go.sum

@@ -217,6 +217,8 @@ github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dR
 github.com/bradleyfalzon/ghinstallation/v2 v2.0.3 h1:ywF/8q+GVpvlsEuvRb1SGSDQDUxntW1d4kFu/9q/YAE=
 github.com/bradleyfalzon/ghinstallation/v2 v2.0.3/go.mod h1:tlgi+JWCXnKFx/Y4WtnDbZEINo31N5bcvnCoqieefmk=
 github.com/briandowns/spinner v1.11.1/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX3FScO+3/ZPQ=
+github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY=
+github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU=
 github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
 github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70=
 github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=

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

+ 11 - 5
internal/helm/postrenderer.go

@@ -12,7 +12,7 @@ import (
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"golang.org/x/oauth2"
-	"gopkg.in/yaml.v2"
+	"gopkg.in/yaml.v3"
 	"helm.sh/helm/v3/pkg/postrender"
 
 	"github.com/docker/distribution/reference"
@@ -237,10 +237,14 @@ func (d *DockerSecretsPostRenderer) Run(
 	defer encoder.Close()
 
 	for _, resource := range d.resources {
-		err = encoder.Encode(resource)
+		// if the resource is empty, we skip encoding it to prevent errors. Helm/k8s expects empty resources to take the form "{}",
+		// while this library writes an empty string, causing problems during installation.
+		if len(resource) != 0 {
+			err = encoder.Encode(resource)
 
-		if err != nil {
-			return nil, err
+			if err != nil {
+				return nil, err
+			}
 		}
 	}
 
@@ -314,7 +318,9 @@ func decodeRenderedManifests(
 			return resArr, err
 		}
 
-		resArr = append(resArr, res)
+		if len(res) != 0 {
+			resArr = append(resArr, res)
+		}
 	}
 
 	return resArr, nil

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