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

Merge pull request #128 from porter-dev/form-cluster-integration

Form cluster integration
abelanger5 5 лет назад
Родитель
Сommit
69a82cefd8
76 измененных файлов с 2734 добавлено и 582 удалено
  1. 123 0
      cli/cmd/api/helm_repo.go
  2. 46 0
      cli/cmd/api/integration.go
  3. 37 0
      cli/cmd/config.go
  4. 27 0
      cli/cmd/connect.go
  5. 97 0
      cli/cmd/connect/helm.go
  6. 127 0
      cli/cmd/helm_repo.go
  7. 0 118
      cli/cmd/test.go
  8. 10 27
      cmd/app/main.go
  9. 4 0
      cmd/migrate/main.go
  10. 9 1
      docker-compose.dev.yaml
  11. 2 0
      go.mod
  12. 17 0
      go.sum
  13. 1 1
      internal/auth/sessionstore_test.go
  14. 3 0
      internal/config/config.go
  15. 29 0
      internal/forms/helm_repo.go
  16. 19 0
      internal/forms/integration.go
  17. 72 14
      internal/helm/loader/loader.go
  18. 82 0
      internal/helm/repo/repo.go
  19. 6 4
      internal/kubernetes/config.go
  20. 1 1
      internal/models/cluster.go
  21. 70 0
      internal/models/helm_repo.go
  22. 56 0
      internal/models/integrations/basic.go
  23. 31 9
      internal/models/integrations/integration.go
  24. 1 1
      internal/models/integrations/oauth.go
  25. 21 29
      internal/models/integrations/token_cache.go
  26. 4 0
      internal/models/project.go
  27. 6 5
      internal/registry/registry.go
  28. 1 1
      internal/repository/cluster.go
  29. 140 0
      internal/repository/gorm/auth.go
  30. 86 0
      internal/repository/gorm/auth_test.go
  31. 1 1
      internal/repository/gorm/cluster.go
  32. 5 3
      internal/repository/gorm/cluster_test.go
  33. 196 0
      internal/repository/gorm/helm_repo.go
  34. 250 0
      internal/repository/gorm/helm_repo_test.go
  35. 55 1
      internal/repository/gorm/helpers_test.go
  36. 4 2
      internal/repository/gorm/registry_test.go
  37. 2 0
      internal/repository/gorm/repository.go
  38. 16 0
      internal/repository/helm_repo.go
  39. 8 0
      internal/repository/integrations.go
  40. 64 0
      internal/repository/memory/auth.go
  41. 1 1
      internal/repository/memory/cluster.go
  42. 0 0
      internal/repository/memory/gitrepo.go
  43. 125 0
      internal/repository/memory/helm_repo.go
  44. 0 0
      internal/repository/memory/project.go
  45. 0 0
      internal/repository/memory/registry.go
  46. 2 0
      internal/repository/memory/repository.go
  47. 0 0
      internal/repository/memory/session.go
  48. 0 0
      internal/repository/memory/user.go
  49. 2 0
      internal/repository/repository.go
  50. 9 9
      internal/templater/dynamic/reader.go
  51. 12 9
      internal/templater/dynamic/writer.go
  52. 3 2
      internal/templater/form.go
  53. 6 6
      internal/templater/helm/manifests/reader.go
  54. 6 6
      internal/templater/helm/values/reader.go
  55. 5 5
      internal/templater/helm/values/writer.go
  56. 48 48
      internal/templater/parser/parser.go
  57. 15 17
      internal/templater/utils/query.go
  58. 0 37
      internal/templater/utils/query_test.go
  59. 134 54
      server/api/api.go
  60. 23 23
      server/api/cluster_handler.go
  61. 1 1
      server/api/cluster_handler_test.go
  62. 4 4
      server/api/deploy_handler.go
  63. 1 1
      server/api/errors.go
  64. 131 0
      server/api/helm_repo_handler.go
  65. 157 0
      server/api/helm_repo_handler_test.go
  66. 17 7
      server/api/helpers_test.go
  67. 83 7
      server/api/integration_handler.go
  68. 71 1
      server/api/integration_handler_test.go
  69. 15 15
      server/api/k8s_handler.go
  70. 8 8
      server/api/project_handler.go
  71. 11 11
      server/api/registry_handler.go
  72. 18 20
      server/api/release_handler.go
  73. 5 25
      server/api/template_handler.go
  74. 21 21
      server/api/template_handler_test.go
  75. 14 14
      server/api/user_handler.go
  76. 57 12
      server/router/router.go

+ 123 - 0
cli/cmd/api/helm_repo.go

@@ -0,0 +1,123 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// CreateHelmRepoRequest represents the accepted fields for creating
+// a Helm repository with basic authentication
+type CreateHelmRepoRequest struct {
+	Name               string `json:"name"`
+	RepoURL            string `json:"repo_url"`
+	BasicIntegrationID uint   `json:"basic_integration_id"`
+}
+
+// CreateHelmRepoResponse is the resulting helm repo after creation
+type CreateHelmRepoResponse models.HelmRepoExternal
+
+// CreateHelmRepo creates an Helm repository integration with basic authentication
+func (c *Client) CreateHelmRepo(
+	ctx context.Context,
+	projectID uint,
+	createHR *CreateHelmRepoRequest,
+) (*CreateHelmRepoResponse, error) {
+	data, err := json.Marshal(createHR)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/helmrepos", c.BaseURL, projectID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &CreateHelmRepoResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// ListHelmRepoResponse is the list of Helm repos for a project
+type ListHelmRepoResponse []models.HelmRepoExternal
+
+// ListHelmRepos returns a list of Helm repos for a project
+func (c *Client) ListHelmRepos(
+	ctx context.Context,
+	projectID uint,
+) (ListHelmRepoResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/helmrepos", c.BaseURL, projectID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &ListHelmRepoResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return *bodyResp, nil
+}
+
+// ListChartsResponse is the list of charts in a Helm repository
+type ListChartsResponse []models.PorterChartList
+
+// ListCharts lists the charts in a Helm repository
+func (c *Client) ListCharts(
+	ctx context.Context,
+	projectID uint,
+	helmID uint,
+) (ListChartsResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/helmrepos/%d/charts", c.BaseURL, projectID, helmID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &ListChartsResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return *bodyResp, nil
+}

+ 46 - 0
cli/cmd/api/integration.go

@@ -101,3 +101,49 @@ func (c *Client) CreateGCPIntegration(
 
 	return bodyResp, nil
 }
+
+// CreateBasicAuthIntegrationRequest represents the accepted fields for creating
+// a "basic auth" integration
+type CreateBasicAuthIntegrationRequest struct {
+	Username string `json:"username"`
+	Password string `json:"password"`
+}
+
+// CreateBasicAuthIntegrationResponse is the resulting integration after creation
+type CreateBasicAuthIntegrationResponse ints.BasicIntegrationExternal
+
+// CreateBasicAuthIntegration creates a "basic auth" integration
+func (c *Client) CreateBasicAuthIntegration(
+	ctx context.Context,
+	projectID uint,
+	createBasic *CreateBasicAuthIntegrationRequest,
+) (*CreateBasicAuthIntegrationResponse, error) {
+	data, err := json.Marshal(createBasic)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/integrations/basic", c.BaseURL, projectID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &CreateBasicAuthIntegrationResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}

+ 37 - 0
cli/cmd/config.go

@@ -19,6 +19,7 @@ var (
 	projectID  uint
 	registryID uint
 	clusterID  uint
+	helmRepoID uint
 )
 
 var configCmd = &cobra.Command{
@@ -95,6 +96,27 @@ var setRegistryCmd = &cobra.Command{
 	},
 }
 
+var setHelmRepoCmd = &cobra.Command{
+	Use:   "set-helmrepo [id]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Saves the helm repo id in the default configuration",
+	Run: func(cmd *cobra.Command, args []string) {
+		hrID, 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 = setHelmRepo(uint(hrID))
+
+		if err != nil {
+			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			os.Exit(1)
+		}
+	},
+}
+
 var setHostCmd = &cobra.Command{
 	Use:   "set-host [host]",
 	Args:  cobra.ExactArgs(1),
@@ -116,6 +138,7 @@ func init() {
 	configCmd.AddCommand(setClusterCmd)
 	configCmd.AddCommand(setHostCmd)
 	configCmd.AddCommand(setRegistryCmd)
+	configCmd.AddCommand(setHelmRepoCmd)
 }
 
 func setDriver(driver string) error {
@@ -167,6 +190,12 @@ func setRegistry(id uint) error {
 	return viper.WriteConfig()
 }
 
+func setHelmRepo(id uint) error {
+	viper.Set("helm_repo", id)
+	color.New(color.FgGreen).Printf("Set the current helm repo id as %d\n", id)
+	return viper.WriteConfig()
+}
+
 func setHost(host string) error {
 	viper.Set("host", host)
 	err := viper.WriteConfig()
@@ -198,6 +227,14 @@ func getRegistryID() uint {
 	return viper.GetUint("registry")
 }
 
+func getHelmRepoID() uint {
+	if helmRepoID != 0 {
+		return helmRepoID
+	}
+
+	return viper.GetUint("helm_repo")
+}
+
 func getProjectID() uint {
 	if projectID != 0 {
 		return projectID

+ 27 - 0
cli/cmd/connect.go

@@ -55,6 +55,19 @@ var connectGCRCmd = &cobra.Command{
 	},
 }
 
+var connectHRCmd = &cobra.Command{
+	Use:     "helmrepo",
+	Aliases: []string{"helm", "helmrepos"},
+	Short:   "Adds a Helm repository to a project",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, runConnectHelmRepoBasic)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
 func init() {
 	rootCmd.AddCommand(connectCmd)
 
@@ -90,6 +103,7 @@ func init() {
 
 	connectCmd.AddCommand(connectECRCmd)
 	connectCmd.AddCommand(connectGCRCmd)
+	connectCmd.AddCommand(connectHRCmd)
 }
 
 func runConnectKubeconfig(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
@@ -139,3 +153,16 @@ func runConnectGCR(_ *api.AuthCheckResponse, client *api.Client, _ []string) err
 
 	return setRegistry(regID)
 }
+
+func runConnectHelmRepoBasic(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
+	hrID, err := connect.Helm(
+		client,
+		getProjectID(),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	return setHelmRepo(hrID)
+}

+ 97 - 0
cli/cmd/connect/helm.go

@@ -0,0 +1,97 @@
+package connect
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+)
+
+// Helm connects a Helm repository using HTTP basic authentication
+func Helm(
+	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]")
+	}
+
+	// query for helm repo name
+	helmName, err := utils.PromptPlaintext(fmt.Sprintf(`Give this Helm repository a name: `))
+
+	if err != nil {
+		return 0, err
+	}
+
+	repoURL, err := utils.PromptPlaintext(fmt.Sprintf(`Provide the Helm repository URL: `))
+
+	if err != nil {
+		return 0, err
+	}
+
+	userResp, err := utils.PromptPlaintext(
+		fmt.Sprintf(`Does this endpoint require a username/password to authenticate? %s `,
+			color.New(color.FgCyan).Sprintf("[y/n]"),
+		),
+	)
+
+	if err != nil {
+		return 0, err
+	}
+
+	username := ""
+	password := ""
+
+	if userResp := strings.ToLower(userResp); userResp == "y" || userResp == "yes" {
+		username, err = utils.PromptPlaintext(fmt.Sprintf(`Username: `))
+
+		if err != nil {
+			return 0, err
+		}
+
+		password, err = utils.PromptPasswordWithConfirmation()
+
+		if err != nil {
+			return 0, err
+		}
+	}
+
+	// create the basic auth integration
+	integration, err := client.CreateBasicAuthIntegration(
+		context.Background(),
+		projectID,
+		&api.CreateBasicAuthIntegrationRequest{
+			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)
+
+	// create the helm repo
+	hr, err := client.CreateHelmRepo(
+		context.Background(),
+		projectID,
+		&api.CreateHelmRepoRequest{
+			Name:               helmName,
+			RepoURL:            repoURL,
+			BasicIntegrationID: integration.ID,
+		},
+	)
+
+	if err != nil {
+		return 0, err
+	}
+
+	color.New(color.FgGreen).Printf("created helm repo with id %d and name %s\n", hr.ID, hr.Name)
+
+	return hr.ID, nil
+}

+ 127 - 0
cli/cmd/helm_repo.go

@@ -0,0 +1,127 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"strings"
+	"text/tabwriter"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/spf13/cobra"
+)
+
+// helmRepoCmd represents the "porter helmrepo" base command when called
+// without any subcommands
+var helmRepoCmd = &cobra.Command{
+	Use:     "helmrepo",
+	Aliases: []string{"helm", "helmrepos"},
+	Short:   "Commands that read from a connected Helm repository",
+}
+
+var helmRepoListCmd = &cobra.Command{
+	Use:   "list",
+	Short: "Lists the Helm repositories linked to a project",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, listHelmRepos)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var helmRepoChartCmd = &cobra.Command{
+	Use:     "chart",
+	Aliases: []string{"charts"},
+	Short:   "Commands for interacting with Helm repository charts",
+}
+
+var helmRepoChartListCmd = &cobra.Command{
+	Use:   "list",
+	Short: "Lists charts in the default Helm repository",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, listHelmRepoCharts)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(helmRepoCmd)
+
+	helmRepoCmd.PersistentFlags().UintVar(
+		&helmRepoID,
+		"helmrepo-id",
+		0,
+		"id of the helm repo",
+	)
+
+	helmRepoCmd.AddCommand(helmRepoListCmd)
+	helmRepoCmd.AddCommand(helmRepoChartCmd)
+
+	helmRepoChartCmd.AddCommand(helmRepoChartListCmd)
+}
+
+func listHelmRepos(user *api.AuthCheckResponse, client *api.Client, args []string) error {
+	pID := getProjectID()
+
+	hrs, err := client.ListHelmRepos(
+		context.Background(),
+		pID,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	w := new(tabwriter.Writer)
+	w.Init(os.Stdout, 3, 8, 0, '\t', tabwriter.AlignRight)
+
+	fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", "ID", "NAME", "URL", "SERVICE")
+
+	currHelmID := getHelmRepoID()
+
+	for _, hr := range hrs {
+		if currHelmID == hr.ID {
+			color.New(color.FgGreen).Fprintf(w, "%d\t%s\t%s\t%s (current helm repo)\n", hr.ID, hr.Name, hr.RepoURL, hr.Service)
+		} else {
+			fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", hr.ID, hr.Name, hr.RepoURL, hr.Service)
+		}
+	}
+
+	w.Flush()
+
+	return nil
+}
+
+func listHelmRepoCharts(user *api.AuthCheckResponse, client *api.Client, args []string) error {
+	pID := getProjectID()
+	hrID := getHelmRepoID()
+
+	charts, err := client.ListCharts(
+		context.Background(),
+		pID,
+		hrID,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	w := new(tabwriter.Writer)
+	w.Init(os.Stdout, 3, 8, 0, '\t', tabwriter.AlignRight)
+
+	fmt.Fprintf(w, "%s\t%s\n", "NAME", "VERSION")
+
+	for _, chart := range charts {
+		fmt.Fprintf(w, "%s\t%s\n", strings.ToLower(chart.Name), chart.Version)
+	}
+
+	w.Flush()
+
+	return nil
+}

+ 0 - 118
cli/cmd/test.go

@@ -1,118 +0,0 @@
-package cmd
-
-import (
-	"encoding/json"
-	"fmt"
-	"os"
-
-	"github.com/fatih/color"
-	"github.com/porter-dev/porter/internal/models"
-	"github.com/spf13/cobra"
-)
-
-var testCmd = &cobra.Command{
-	Use:   "test",
-	Short: "Testing",
-	Run: func(cmd *cobra.Command, args []string) {
-		// chart, err := loader.LoadChart("https://porter-dev.github.io/chart-repo", "docker", "0.0.1")
-
-		// if err != nil {
-		// 	red := color.New(color.FgRed)
-		// 	red.Println("Error running test:", err.Error())
-		// 	os.Exit(1)
-		// }
-
-		// bytes, err := yaml.Marshal(chart)
-
-		// if err != nil {
-		// 	red := color.New(color.FgRed)
-		// 	red.Println("Error running test:", err.Error())
-		// 	os.Exit(1)
-		// }
-
-		// fmt.Println(string(bytes))
-
-		form := &models.FormYAML{
-			Tabs: []*models.FormTab{
-				&models.FormTab{
-					Context: &models.FormContext{
-						Type: "helm/values",
-					},
-					Name:  "main",
-					Label: "Main Settings",
-					Sections: []*models.FormSection{
-						&models.FormSection{
-							Name: "section_one",
-							Contents: []*models.FormContent{
-								&models.FormContent{
-									Type:  "number-input",
-									Value: "service.targetPort",
-									Label: "Target Port",
-									Settings: struct {
-										Default interface{} `yaml:"default,omitempty" json:"default,omitempty"`
-										Unit    interface{} `yaml:"unit,omitempty" json:"unit,omitempty"`
-									}{
-										Default: 8000,
-									},
-								},
-							},
-						},
-					},
-				},
-				&models.FormTab{
-					Context: &models.FormContext{
-						Type: "cluster",
-					},
-					Name:  "crd",
-					Label: "CRDs",
-					Sections: []*models.FormSection{
-						&models.FormSection{
-							Name: "section_one",
-							Contents: []*models.FormContent{
-								&models.FormContent{
-									Type:  "resourcelist",
-									Value: `[{"name": "resource_1"}]`,
-								},
-							},
-						},
-					},
-				},
-			},
-		}
-
-		bytes, err := json.Marshal(form)
-
-		if err != nil {
-			red := color.New(color.FgRed)
-			red.Println("Error running test:", err.Error())
-			os.Exit(1)
-		}
-
-		fmt.Println(string(bytes))
-	},
-}
-
-func init() {
-	rootCmd.AddCommand(testCmd)
-}
-
-// // FormSection is a section of a form
-// type FormSection struct {
-// 	Context  *FormContext   `yaml:"context" json:"context"`
-// 	Name     string         `yaml:"name" json:"name"`
-// 	ShowIf   string         `yaml:"show_if" json:"show_if"`
-// 	Contents []*FormContent `yaml:"contents" json:"contents,omitempty"`
-// }
-
-// // FormContent is a form's atomic unit
-// type FormContent struct {
-// 	Context  *FormContext `yaml:"context" json:"context"`
-// 	Type     string       `yaml:"type" json:"type"`
-// 	Label    string       `yaml:"label" json:"label"`
-// 	Name     string       `yaml:"name,omitempty" json:"name,omitempty"`
-// 	Value    interface{}  `yaml:"value,omitempty" json:"value,omitempty"`
-// 	Settings struct {
-// 		Default interface{} `yaml:"default,omitempty" json:"default,omitempty"`
-// 		Unit    interface{} `yaml:"unit,omitempty" json:"unit,omitempty"`
-// 	} `yaml:"settings,omitempty" json:"settings,omitempty"`
-// }

+ 10 - 27
cmd/app/main.go

@@ -5,18 +5,14 @@ import (
 	"log"
 	"net/http"
 
-	"github.com/gorilla/sessions"
 	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 
 	"github.com/porter-dev/porter/server/api"
 
 	"github.com/porter-dev/porter/internal/adapter"
-	sessionstore "github.com/porter-dev/porter/internal/auth"
 	"github.com/porter-dev/porter/internal/config"
 	lr "github.com/porter-dev/porter/internal/logger"
-	vr "github.com/porter-dev/porter/internal/validator"
 	"github.com/porter-dev/porter/server/router"
 
 	ints "github.com/porter-dev/porter/internal/models/integrations"
@@ -40,16 +36,20 @@ func main() {
 		&models.Session{},
 		&models.GitRepo{},
 		&models.Registry{},
+		&models.HelmRepo{},
 		&models.Cluster{},
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
 		&ints.KubeIntegration{},
+		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
 		&ints.OAuthIntegration{},
 		&ints.GCPIntegration{},
 		&ints.AWSIntegration{},
 		&ints.TokenCache{},
+		&ints.ClusterTokenCache{},
 		&ints.RegTokenCache{},
+		&ints.HelmRepoTokenCache{},
 	)
 
 	if err != nil {
@@ -65,30 +65,13 @@ func main() {
 
 	repo := gorm.NewRepository(db, &key)
 
-	// declare as Store interface (methods Get, New, Save)
-	var store sessions.Store
-	store, _ = sessionstore.NewStore(repo, appConf.Server)
-
-	validator := vr.New()
-
-	a := api.New(
-		logger,
-		nil,
-		repo,
-		validator,
-		store,
-		appConf.Server.CookieName,
-		false,
-		appConf.Server.IsLocal,
-		&oauth.Config{
-			ClientID:     appConf.Server.GithubClientID,
-			ClientSecret: appConf.Server.GithubClientSecret,
-			Scopes:       []string{"repo", "user", "read:user"},
-			BaseURL:      appConf.Server.ServerURL,
-		},
-	)
+	a, _ := api.New(&api.AppConfig{
+		Logger:     logger,
+		Repository: repo,
+		ServerConf: appConf.Server,
+	})
 
-	appRouter := router.New(a, store, appConf.Server.CookieName, appConf.Server.StaticFilePath, repo)
+	appRouter := router.New(a)
 
 	address := fmt.Sprintf(":%d", appConf.Server.Port)
 

+ 4 - 0
cmd/migrate/main.go

@@ -31,16 +31,20 @@ func main() {
 		&models.Session{},
 		&models.GitRepo{},
 		&models.Registry{},
+		&models.HelmRepo{},
 		&models.Cluster{},
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
 		&ints.KubeIntegration{},
+		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
 		&ints.OAuthIntegration{},
 		&ints.GCPIntegration{},
 		&ints.AWSIntegration{},
 		&ints.TokenCache{},
+		&ints.ClusterTokenCache{},
 		&ints.RegTokenCache{},
+		&ints.HelmRepoTokenCache{},
 	)
 
 	if err != nil {

+ 9 - 1
docker-compose.dev.yaml

@@ -33,6 +33,13 @@ services:
       - 5400:5432
     volumes:
       - database:/var/lib/postgresql/data
+  chartmuseum:
+    image: docker.io/bitnami/chartmuseum:0-debian-10
+    container_name: chartmuseum
+    ports:
+      - 5000:8080
+    volumes:
+      - chartmuseum:/bitnami/data
   nginx:
     image: nginx:mainline-alpine
     container_name: nginx
@@ -47,4 +54,5 @@ services:
 
 volumes:
   database:
-  metabase:
+  metabase:
+  chartmuseum:

+ 2 - 0
go.mod

@@ -33,6 +33,8 @@ require (
 	github.com/gorilla/websocket v1.4.2
 	github.com/hashicorp/consul/api v1.3.0
 	github.com/imdario/mergo v0.3.11 // indirect
+	github.com/itchyny/gojq v0.11.1
+	github.com/itchyny/timefmt-go v0.1.1 // indirect
 	github.com/jinzhu/gorm v1.9.16
 	github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd
 	github.com/json-iterator/go v1.1.10 // indirect

+ 17 - 0
go.sum

@@ -738,6 +738,7 @@ github.com/hashicorp/serf v0.8.2 h1:YZ7UKsJv+hKjqGVUUbtE3HNj79Eln2oQ75tniF6iPt0=
 github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
 github.com/heketi/heketi v9.0.1-0.20190917153846-c2e2a4ab7ab9+incompatible/go.mod h1:bB9ly3RchcQqsQ9CpyaQwvva7RS5ytVoSoholZQON6o=
 github.com/heketi/tests v0.0.0-20151005000721-f3775cbcefd6/go.mod h1:xGMAM8JLi7UkZt1i4FQeQy0R2T8GLUwQhOP5M1gBhy4=
+github.com/hokaccha/go-prettyjson v0.0.0-20190818114111-108c894c2c0e/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4=
 github.com/huandu/xstrings v1.3.1 h1:4jgBlKK6tLKFvO8u5pmYjG91cqytmDCDvGh7ECVFfFs=
@@ -755,6 +756,15 @@ github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NH
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
 github.com/instrumenta/kubeval v0.0.0-20190918223246-8d013ec9fc56/go.mod h1:bpiMYvNpVxWjdJsS0hDRu9TrobT5GfWCZwJseGUstxE=
+github.com/itchyny/astgen-go v0.0.0-20200815150004-12a293722290/go.mod h1:296z3W7Xsrp2mlIY88ruDKscuvrkL6zXCNRtaYVshzw=
+github.com/itchyny/go-flags v1.5.0/go.mod h1:lenkYuCobuxLBAd/HGFE4LRoW8D3B6iXRQfWYJ+MNbA=
+github.com/itchyny/gojq v0.11.1 h1:k54XkzWCGDfRJSZFRW4rXowTVzPlSjU2xUErkaFjfdo=
+github.com/itchyny/gojq v0.11.1/go.mod h1:8MKtgvJwkmRduSuzN25byPdNHfvv6y+/hmOVXei9e7k=
+github.com/itchyny/gojq v0.11.2 h1:lKhMKfH7fTKMWj2Zr8az/9TliCn0TTXVc/BXfQ8Jhfc=
+github.com/itchyny/gojq v0.11.2/go.mod h1:XtmtF1PxeDpwLC1jyz/xAmV78ANlP0S9LVEPsKweK0A=
+github.com/itchyny/timefmt-go v0.1.0/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A=
+github.com/itchyny/timefmt-go v0.1.1 h1:rLpnm9xxb39PEEVzO0n4IRp0q6/RmBc7Dy/rE4HrA0U=
+github.com/itchyny/timefmt-go v0.1.1/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A=
 github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
 github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
 github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
@@ -953,6 +963,8 @@ github.com/mattn/go-oci8 v0.0.7/go.mod h1:wjDx6Xm9q7dFtHJvIlrI99JytznLw5wQ4R+9mN
 github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
 github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y=
 github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
+github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
+github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
 github.com/mattn/go-shellwords v1.0.5/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
 github.com/mattn/go-shellwords v1.0.10/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
 github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
@@ -1625,6 +1637,7 @@ golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200821140526-fda516888d29/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6 h1:DvY3Zkh7KabQE/kfzMvYvKirSiguP9Q/veMtkYyf0o8=
 golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f h1:+Nyd8tzPX9R7BWHguqsrbFdRx3WQ/1ib8I44HXV5yTA=
@@ -1855,6 +1868,7 @@ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMy
 gopkg.in/gcfg.v1 v1.2.0/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
 gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
 gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
+gopkg.in/go-playground/validator.v9 v9.31.0 h1:bmXmP2RSNtFES+bn4uYuHT7iJFJv7Vj+an+ZQdDaD1M=
 gopkg.in/go-playground/validator.v9 v9.31.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
 gopkg.in/gorp.v1 v1.7.2 h1:j3DWlAyGVv8whO7AcIWznQ2Yj7yJkn34B8s63GViAAw=
 gopkg.in/gorp.v1 v1.7.2/go.mod h1:Wo3h+DBQZIxATwftsglhdD/62zRFPhGhTiu5jUJmCaw=
@@ -1881,6 +1895,8 @@ gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ=
+gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gorm.io/driver/postgres v1.0.2 h1:mB5JjD4QglbCTdMT1aZDxQzHr87XDK1qh0MKIU3P96g=
 gorm.io/driver/postgres v1.0.2/go.mod h1:FvRSYfBI9jEp6ZSjlpS9qNcSjxwYxFc03UOTrHdvvYA=
 gorm.io/driver/sqlite v1.1.3 h1:BYfdVuZB5He/u9dt4qDpZqiqDJ6KhPqs5QUqsr/Eeuc=
@@ -1916,6 +1932,7 @@ k8s.io/apimachinery v0.16.8/go.mod h1:Xk2vD2TRRpuWYLQNM6lT9R7DSFZUYG03SarNkbGrnK
 k8s.io/apimachinery v0.18.8 h1:jimPrycCqgx2QPearX3to1JePz7wSbVLq+7PdBTTwQ0=
 k8s.io/apimachinery v0.18.8/go.mod h1:6sQd+iHEqmOtALqOFjSWp2KZ9F0wlU/nWm0ZgsYWMig=
 k8s.io/apimachinery v0.19.4 h1:+ZoddM7nbzrDCp0T3SWnyxqf8cbWPT2fkZImoyvHUG0=
+k8s.io/apimachinery v0.20.0 h1:jjzbTJRXk0unNS71L7h3lxGDH/2HPxMPaQY+MjECKL8=
 k8s.io/apiserver v0.18.8/go.mod h1:12u5FuGql8Cc497ORNj79rhPdiXQC4bf53X/skR/1YM=
 k8s.io/cli-runtime v0.18.8 h1:ycmbN3hs7CfkJIYxJAOB10iW7BVPmXGXkfEyiV9NJ+k=
 k8s.io/cli-runtime v0.18.8/go.mod h1:7EzWiDbS9PFd0hamHHVoCY4GrokSTPSL32MA4rzIu0M=

+ 1 - 1
internal/auth/sessionstore_test.go

@@ -9,7 +9,7 @@ import (
 
 	"github.com/gorilla/securecookie"
 	"github.com/gorilla/sessions"
-	"github.com/porter-dev/porter/internal/repository/test"
+	test "github.com/porter-dev/porter/internal/repository/memory"
 
 	sessionstore "github.com/porter-dev/porter/internal/auth"
 )

+ 3 - 0
internal/config/config.go

@@ -26,6 +26,9 @@ type ServerConf struct {
 	TimeoutWrite   time.Duration `env:"SERVER_TIMEOUT_WRITE,default=10s"`
 	TimeoutIdle    time.Duration `env:"SERVER_TIMEOUT_IDLE,default=15s"`
 	IsLocal        bool          `env:"IS_LOCAL,default=false"`
+	IsTesting      bool          `env:"IS_TESTING,default=false"`
+
+	DefaultHelmRepoURL string `env:"HELM_REPO_URL,default=https://porter-dev.github.io/chart-repo/"`
 
 	GithubClientID     string `env:"GITHUB_CLIENT_ID"`
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`

+ 29 - 0
internal/forms/helm_repo.go

@@ -0,0 +1,29 @@
+package forms
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// CreateHelmRepo represents the accepted values for creating a
+// helm repo
+type CreateHelmRepo struct {
+	Name      string `json:"name" form:"required"`
+	RepoURL   string `json:"repo_url" form:"required"`
+	ProjectID uint   `json:"project_id" form:"required"`
+
+	BasicIntegrationID uint `json:"basic_integration_id"`
+	GCPIntegrationID   uint `json:"gcp_integration_id"`
+	AWSIntegrationID   uint `json:"aws_integration_id"`
+}
+
+// ToHelmRepo converts the form to a gorm helm repo model
+func (ch *CreateHelmRepo) ToHelmRepo() (*models.HelmRepo, error) {
+	return &models.HelmRepo{
+		Name:                   ch.Name,
+		RepoURL:                ch.RepoURL,
+		ProjectID:              ch.ProjectID,
+		BasicAuthIntegrationID: ch.BasicIntegrationID,
+		GCPIntegrationID:       ch.GCPIntegrationID,
+		AWSIntegrationID:       ch.AWSIntegrationID,
+	}, nil
+}

+ 19 - 0
internal/forms/integration.go

@@ -21,6 +21,25 @@ func (cgf *CreateGCPIntegrationForm) ToGCPIntegration() (*ints.GCPIntegration, e
 	}, nil
 }
 
+// CreateBasicAuthIntegrationForm represents the accepted values for creating a
+// basic auth integration
+type CreateBasicAuthIntegrationForm struct {
+	UserID    uint   `json:"user_id" form:"required"`
+	ProjectID uint   `json:"project_id" form:"required"`
+	Username  string `json:"username"`
+	Password  string `json:"password"`
+}
+
+// ToBasicIntegration converts the project to a gorm project model
+func (cbf *CreateBasicAuthIntegrationForm) ToBasicIntegration() (*ints.BasicIntegration, error) {
+	return &ints.BasicIntegration{
+		UserID:    cbf.UserID,
+		ProjectID: cbf.ProjectID,
+		Username:  []byte(cbf.Username),
+		Password:  []byte(cbf.Password),
+	}, nil
+}
+
 // CreateAWSIntegrationForm represents the accepted values for creating an
 // AWS Integration
 type CreateAWSIntegrationForm struct {

+ 72 - 14
internal/helm/loader/loader.go

@@ -7,6 +7,7 @@ import (
 	"net/http"
 	"strings"
 
+	"github.com/porter-dev/porter/internal/models"
 	"k8s.io/helm/pkg/repo"
 	"sigs.k8s.io/yaml"
 
@@ -14,9 +15,48 @@ import (
 	chartloader "helm.sh/helm/v3/pkg/chart/loader"
 )
 
-// LoadRepoIndex loads an index file from a remote Helm repo
-func LoadRepoIndex(indexURL string) (*repo.IndexFile, error) {
-	resp, err := http.Get(indexURL)
+// RepoIndexToPorterChartList converts an index file to a list of porter charts
+func RepoIndexToPorterChartList(index *repo.IndexFile) []*models.PorterChartList {
+	porterCharts := make([]*models.PorterChartList, 0)
+
+	for _, entry := range index.Entries {
+		indexChart := entry[0]
+
+		porterChart := &models.PorterChartList{
+			Name:        indexChart.Name,
+			Version:     indexChart.Version,
+			Description: indexChart.Description,
+			Icon:        indexChart.Icon,
+		}
+
+		porterCharts = append(porterCharts, porterChart)
+	}
+
+	return porterCharts
+}
+
+// BasicAuthClient is just a username/password to set on requests
+type BasicAuthClient struct {
+	Username string
+	Password string
+}
+
+// LoadRepoIndex uses an http request to get the index file and loads it
+func LoadRepoIndex(client *BasicAuthClient, repoURL string) (*repo.IndexFile, error) {
+	trimmedRepoURL := strings.TrimSuffix(strings.TrimSpace(repoURL), "/")
+	indexURL := trimmedRepoURL + "/index.yaml"
+
+	req, err := http.NewRequest("GET", indexURL, nil)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if client.Username != "" {
+		req.SetBasicAuth(client.Username, client.Password)
+	}
+
+	resp, err := http.DefaultClient.Do(req)
 
 	if err != nil {
 		return nil, err
@@ -43,14 +83,14 @@ func LoadRepoIndex(indexURL string) (*repo.IndexFile, error) {
 	return index, nil
 }
 
-// LoadChart returns a Helm3 (v2) chart from a remote repo. If chartVersion is an
-// empty string, the most stable latest version is found.
-//
-// TODO: this is an expensive operation, so after retrieving the digest from the
-// repo index, this should check the digest in the cache
-func LoadChart(repoURL, chartName, chartVersion string) (*chart.Chart, error) {
-	trimmedRepoURL := strings.TrimSuffix(strings.TrimSpace(repoURL), "/")
-	repoIndex, err := LoadRepoIndex(trimmedRepoURL + "/index.yaml")
+// LoadRepoIndexPublic loads an index file from a remote public Helm repo
+func LoadRepoIndexPublic(repoURL string) (*repo.IndexFile, error) {
+	return LoadRepoIndex(&BasicAuthClient{}, repoURL)
+}
+
+// LoadChart uses an http request to fetch a chart from a remote Helm repo
+func LoadChart(client *BasicAuthClient, repoURL, chartName, chartVersion string) (*chart.Chart, error) {
+	repoIndex, err := LoadRepoIndex(client, repoURL)
 
 	if err != nil {
 		return nil, err
@@ -64,12 +104,21 @@ func LoadChart(repoURL, chartName, chartVersion string) (*chart.Chart, error) {
 		return nil, fmt.Errorf("%s:%s no valid download urls", chartName, chartVersion)
 	}
 
+	trimmedRepoURL := strings.TrimSuffix(strings.TrimSpace(repoURL), "/")
 	chartURL := trimmedRepoURL + "/" + strings.TrimPrefix(cv.URLs[0], "/")
 
-	fmt.Println(chartURL)
-
 	// download tgz
-	resp, err := http.Get(chartURL)
+	req, err := http.NewRequest("GET", chartURL, nil)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if client.Username != "" {
+		req.SetBasicAuth(client.Username, client.Password)
+	}
+
+	resp, err := http.DefaultClient.Do(req)
 
 	if err != nil {
 		return nil, err
@@ -85,3 +134,12 @@ func LoadChart(repoURL, chartName, chartVersion string) (*chart.Chart, error) {
 
 	return chartloader.LoadArchive(bytes.NewReader(data))
 }
+
+// LoadChartPublic returns a Helm3 (v2) chart from a remote public repo.
+// If chartVersion is an empty string, the most stable latest version is found.
+//
+// TODO: this is an expensive operation, so after retrieving the digest from the
+// repo index, this should check the digest in the cache
+func LoadChartPublic(repoURL, chartName, chartVersion string) (*chart.Chart, error) {
+	return LoadChart(&BasicAuthClient{}, repoURL, chartName, chartVersion)
+}

+ 82 - 0
internal/helm/repo/repo.go

@@ -0,0 +1,82 @@
+package repo
+
+import (
+	"fmt"
+
+	"github.com/porter-dev/porter/internal/helm/loader"
+	"github.com/porter-dev/porter/internal/models"
+	"helm.sh/helm/v3/pkg/chart"
+
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+// HelmRepo wraps the gorm HelmRepo model
+type HelmRepo models.HelmRepo
+
+// ListCharts lists Porter charts for a given helm repo
+func (hr *HelmRepo) ListCharts(repo repository.Repository) ([]*models.PorterChartList, error) {
+	if hr.BasicAuthIntegrationID != 0 {
+		return hr.listChartsBasic(repo)
+	}
+
+	return nil, fmt.Errorf("error listing charts")
+}
+
+// GetChart retrieves a Porter chart for a given helm repo
+func (hr *HelmRepo) GetChart(
+	repo repository.Repository,
+	chartName, chartVersion string,
+) (*chart.Chart, error) {
+	if hr.BasicAuthIntegrationID != 0 {
+		return hr.getChartBasic(repo, chartName, chartVersion)
+	}
+
+	return nil, fmt.Errorf("error listing charts")
+}
+
+func (hr *HelmRepo) listChartsBasic(
+	repo repository.Repository,
+) ([]*models.PorterChartList, error) {
+	// get the basic auth integration
+	basic, err := repo.BasicIntegration.ReadBasicIntegration(
+		hr.BasicAuthIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	client := &loader.BasicAuthClient{
+		Username: string(basic.Username),
+		Password: string(basic.Password),
+	}
+
+	repoIndex, err := loader.LoadRepoIndex(client, hr.RepoURL)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return loader.RepoIndexToPorterChartList(repoIndex), nil
+}
+
+func (hr *HelmRepo) getChartBasic(
+	repo repository.Repository,
+	chartName, chartVersion string,
+) (*chart.Chart, error) {
+	// get the basic auth integration
+	basic, err := repo.BasicIntegration.ReadBasicIntegration(
+		hr.BasicAuthIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	client := &loader.BasicAuthClient{
+		Username: string(basic.Username),
+		Password: string(basic.Password),
+	}
+
+	return loader.LoadChart(client, hr.RepoURL, chartName, chartVersion)
+}

+ 6 - 4
internal/kubernetes/config.go

@@ -315,15 +315,17 @@ func (conf *OutOfClusterConfig) createRawConfigFromCluster() (*api.Config, error
 }
 
 func (conf *OutOfClusterConfig) getTokenCache() (tok *ints.TokenCache, err error) {
-	return &conf.Cluster.TokenCache, nil
+	return &conf.Cluster.TokenCache.TokenCache, nil
 }
 
 func (conf *OutOfClusterConfig) setTokenCache(token string, expiry time.Time) error {
 	_, err := conf.Repo.Cluster.UpdateClusterTokenCache(
-		&ints.TokenCache{
+		&ints.ClusterTokenCache{
 			ClusterID: conf.Cluster.ID,
-			Token:     []byte(token),
-			Expiry:    expiry,
+			TokenCache: ints.TokenCache{
+				Token:  []byte(token),
+				Expiry: expiry,
+			},
 		},
 	)
 

+ 1 - 1
internal/models/cluster.go

@@ -58,7 +58,7 @@ type Cluster struct {
 	AWSIntegrationID  uint
 
 	// A token cache that can be used by an auth mechanism, if desired
-	TokenCache integrations.TokenCache `json:"token_cache"`
+	TokenCache integrations.ClusterTokenCache `json:"token_cache"`
 
 	// CertificateAuthorityData for the cluster, encrypted at rest
 	CertificateAuthorityData []byte `json:"certificate-authority-data,omitempty"`

+ 70 - 0
internal/models/helm_repo.go

@@ -0,0 +1,70 @@
+package models
+
+import (
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"gorm.io/gorm"
+)
+
+// HelmRepo is an integration that can connect to a Helm repository via a
+// set of auth mechanisms
+type HelmRepo struct {
+	gorm.Model
+
+	// Name given to the Helm repository
+	Name string `json:"name"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	// RepoURL is the URL to the helm repo. This varies based on the integration
+	// type. For example, for AWS S3 this may be prefixed with s3://, or for
+	// GCS it may be gs://
+	RepoURL string `json:"repo_url"`
+
+	// ------------------------------------------------------------------
+	// All fields below this line are encrypted before storage
+	// ------------------------------------------------------------------
+	BasicAuthIntegrationID uint
+	GCPIntegrationID       uint
+	AWSIntegrationID       uint
+
+	// A token cache that can be used by an auth mechanism (integration), if desired
+	TokenCache integrations.HelmRepoTokenCache
+}
+
+// HelmRepoExternal is an external HelmRepo to be shared over REST
+type HelmRepoExternal struct {
+	ID uint `json:"id"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	// Name of the repo
+	Name string `json:"name"`
+
+	RepoURL string `json:"repo_name"`
+
+	// The integration service for this registry
+	Service integrations.IntegrationService `json:"service"`
+}
+
+// Externalize generates an external Registry to be shared over REST
+func (hr *HelmRepo) Externalize() *HelmRepoExternal {
+	var serv integrations.IntegrationService
+
+	if hr.BasicAuthIntegrationID != 0 {
+		serv = integrations.HelmRepo
+	} else if hr.AWSIntegrationID != 0 {
+		serv = integrations.S3
+	} else if hr.GCPIntegrationID != 0 {
+		serv = integrations.GCS
+	}
+
+	return &HelmRepoExternal{
+		ID:        hr.ID,
+		ProjectID: hr.ProjectID,
+		Name:      hr.Name,
+		RepoURL:   hr.RepoURL,
+		Service:   serv,
+	}
+}

+ 56 - 0
internal/models/integrations/basic.go

@@ -0,0 +1,56 @@
+package integrations
+
+import "gorm.io/gorm"
+
+// BasicIntegration represents a basic auth mechanism via username/password
+type BasicIntegration struct {
+	gorm.Model
+
+	// The id of the user that linked this auth mechanism
+	UserID uint `json:"user_id"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	// ------------------------------------------------------------------
+	// All fields encrypted before storage.
+	// ------------------------------------------------------------------
+
+	// Username/Password for basic authentication to a cluster
+	Username []byte `json:"username,omitempty"`
+	Password []byte `json:"password,omitempty"`
+}
+
+// BasicIntegrationExternal is a BasicIntegration to be shared over REST
+type BasicIntegrationExternal struct {
+	ID uint `json:"id"`
+
+	// The id of the user that linked this auth mechanism
+	UserID uint `json:"user_id"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+}
+
+// Externalize generates an external BasicIntegration to be shared over REST
+func (b *BasicIntegration) Externalize() *BasicIntegrationExternal {
+	return &BasicIntegrationExternal{
+		ID:        b.ID,
+		UserID:    b.UserID,
+		ProjectID: b.ProjectID,
+	}
+}
+
+// ToProjectIntegration converts an oauth integration to a project integration
+func (b *BasicIntegration) ToProjectIntegration(
+	category string,
+	service IntegrationService,
+) *ProjectIntegration {
+	return &ProjectIntegration{
+		ID:            b.ID,
+		ProjectID:     b.ProjectID,
+		AuthMechanism: "basic",
+		Category:      category,
+		Service:       service,
+	}
+}

+ 31 - 9
internal/models/integrations/integration.go

@@ -5,13 +5,16 @@ type IntegrationService string
 
 // The list of supported third-party services
 const (
-	GKE    IntegrationService = "gke"
-	EKS    IntegrationService = "eks"
-	Kube   IntegrationService = "kube"
-	GCR    IntegrationService = "gcr"
-	ECR    IntegrationService = "ecr"
-	Github IntegrationService = "github"
-	Docker IntegrationService = "docker"
+	GKE      IntegrationService = "gke"
+	GCS      IntegrationService = "gcs"
+	S3       IntegrationService = "s3"
+	HelmRepo IntegrationService = "helm"
+	EKS      IntegrationService = "eks"
+	Kube     IntegrationService = "kube"
+	GCR      IntegrationService = "gcr"
+	ECR      IntegrationService = "ecr"
+	Github   IntegrationService = "github"
+	Docker   IntegrationService = "docker"
 )
 
 // PorterIntegration is a supported integration service, specifying an auth
@@ -74,8 +77,27 @@ var PorterRegistryIntegrations = []PorterIntegration{
 	},
 }
 
-// PorterRepoIntegrations are the supported repo integrations
-var PorterRepoIntegrations = []PorterIntegration{
+// PorterHelmRepoIntegrations are the supported helm repo integrations
+var PorterHelmRepoIntegrations = []PorterIntegration{
+	PorterIntegration{
+		AuthMechanism: "basic",
+		Category:      "helm",
+		Service:       HelmRepo,
+	},
+	PorterIntegration{
+		AuthMechanism: "gcp",
+		Category:      "helm",
+		Service:       GCS,
+	},
+	PorterIntegration{
+		AuthMechanism: "aws",
+		Category:      "helm",
+		Service:       S3,
+	},
+}
+
+// PorterGitRepoIntegrations are the supported git repo integrations
+var PorterGitRepoIntegrations = []PorterIntegration{
 	PorterIntegration{
 		AuthMechanism: "oauth",
 		Category:      "repo",

+ 1 - 1
internal/models/integrations/oauth.go

@@ -62,7 +62,7 @@ func (o *OAuthIntegration) Externalize() *OAuthIntegrationExternal {
 	}
 }
 
-// ToProjectIntegration converts a gcp integration to a project integration
+// ToProjectIntegration converts an oauth integration to a project integration
 func (o *OAuthIntegration) ToProjectIntegration(
 	category string,
 	service IntegrationService,

+ 21 - 29
internal/models/integrations/token_cache.go

@@ -6,23 +6,12 @@ import (
 	"gorm.io/gorm"
 )
 
-// GetTokenCacheFunc is a function that retrieves the token and expiry
-// time from the db
-type GetTokenCacheFunc func() (tok *TokenCache, err error)
-
-// SetTokenCacheFunc is a function that updates the token cache
-// with a new token and expiry time
-type SetTokenCacheFunc func(token string, expiry time.Time) error
-
 // TokenCache stores a token and an expiration for the token for a
 // service account. This will never be shared over REST, so no need
 // to externalize.
 type TokenCache struct {
 	gorm.Model
 
-	ClusterID  uint `json:"cluster_id"`
-	RegistryID uint `json:"registry_id"`
-
 	Expiry time.Time `json:"expiry,omitempty"`
 
 	// ------------------------------------------------------------------
@@ -32,37 +21,40 @@ type TokenCache struct {
 	Token []byte `json:"access_token"`
 }
 
+// GetTokenCacheFunc is a function that retrieves the token and expiry
+// time from the db
+type GetTokenCacheFunc func() (tok *TokenCache, err error)
+
+// SetTokenCacheFunc is a function that updates the token cache
+// with a new token and expiry time
+type SetTokenCacheFunc func(token string, expiry time.Time) error
+
 // IsExpired returns true if a token is expired, false otherwise
 func (t *TokenCache) IsExpired() bool {
 	return time.Now().After(t.Expiry)
 }
 
-// GetRegTokenCacheFunc is a function that retrieves the token and expiry
-// time from the db
-type GetRegTokenCacheFunc func() (tok *TokenCache, err error)
+// ClusterTokenCache is a token cache that clusters can use; a foreign
+// key constraint between a Cluster and ClusterTokenCache is created
+type ClusterTokenCache struct {
+	TokenCache
 
-// SetRegTokenCacheFunc is a function that updates the token cache
-// with a new token and expiry time
-type SetRegTokenCacheFunc func(token string, expiry time.Time) error
+	ClusterID uint `json:"cluster_id"`
+}
 
 // RegTokenCache stores a token and an expiration for the JWT token for a
 // Docker registry. This will never be shared over REST, so no need
 // to externalize.
 type RegTokenCache struct {
-	gorm.Model
+	TokenCache
 
 	RegistryID uint `json:"registry_id"`
-
-	Expiry time.Time `json:"expiry,omitempty"`
-
-	// ------------------------------------------------------------------
-	// All fields below this line are encrypted before storage
-	// ------------------------------------------------------------------
-
-	Token []byte `json:"access_token"`
 }
 
-// IsExpired returns true if a token is expired, false otherwise
-func (r *RegTokenCache) IsExpired() bool {
-	return time.Now().After(r.Expiry)
+// HelmRepoTokenCache is a token cache that helm repos can use; a foreign
+// key constraint between a HelmRepo and HelmRepoTokenCache is created
+type HelmRepoTokenCache struct {
+	TokenCache
+
+	HelmRepoID uint `json:"helm_repo_id"`
 }

+ 4 - 0
internal/models/project.go

@@ -23,8 +23,12 @@ type Project struct {
 	Clusters          []Cluster          `json:"clusters"`
 	ClusterCandidates []ClusterCandidate `json:"cluster_candidates"`
 
+	// linked helm repos
+	HelmRepos []HelmRepo `json:"helm_repos"`
+
 	// auth mechanisms
 	KubeIntegrations  []ints.KubeIntegration  `json:"kube_integrations"`
+	BasicIntegrations []ints.BasicIntegration `json:"basic_integrations"`
 	OIDCIntegrations  []ints.OIDCIntegration  `json:"oidc_integrations"`
 	OAuthIntegrations []ints.OAuthIntegration `json:"oauth_integrations"`
 	AWSIntegrations   []ints.AWSIntegration   `json:"aws_integrations"`

+ 6 - 5
internal/registry/registry.go

@@ -216,9 +216,8 @@ func (r *Registry) listECRRepositories(repo repository.Repository) ([]*Repositor
 
 func (r *Registry) getTokenCache() (tok *ints.TokenCache, err error) {
 	return &ints.TokenCache{
-		RegistryID: r.TokenCache.RegistryID,
-		Token:      r.TokenCache.Token,
-		Expiry:     r.TokenCache.Expiry,
+		Token:  r.TokenCache.Token,
+		Expiry: r.TokenCache.Expiry,
 	}, nil
 }
 
@@ -228,9 +227,11 @@ func (r *Registry) setTokenCacheFunc(
 	return func(token string, expiry time.Time) error {
 		_, err := repo.Registry.UpdateRegistryTokenCache(
 			&ints.RegTokenCache{
+				TokenCache: ints.TokenCache{
+					Token:  []byte(token),
+					Expiry: expiry,
+				},
 				RegistryID: r.ID,
-				Token:      []byte(token),
-				Expiry:     expiry,
 			},
 		)
 

+ 1 - 1
internal/repository/cluster.go

@@ -17,6 +17,6 @@ type ClusterRepository interface {
 	ReadCluster(id uint) (*models.Cluster, error)
 	ListClustersByProjectID(projectID uint) ([]*models.Cluster, error)
 	UpdateCluster(cluster *models.Cluster) (*models.Cluster, error)
-	UpdateClusterTokenCache(tokenCache *ints.TokenCache) (*models.Cluster, error)
+	UpdateClusterTokenCache(tokenCache *ints.ClusterTokenCache) (*models.Cluster, error)
 	DeleteCluster(cluster *models.Cluster) error
 }

+ 140 - 0
internal/repository/gorm/auth.go

@@ -228,6 +228,146 @@ func (repo *KubeIntegrationRepository) DecryptKubeIntegrationData(
 	return nil
 }
 
+// BasicIntegrationRepository uses gorm.DB for querying the database
+type BasicIntegrationRepository struct {
+	db  *gorm.DB
+	key *[32]byte
+}
+
+// NewBasicIntegrationRepository returns a BasicIntegrationRepository which uses
+// gorm.DB for querying the database. It accepts an encryption key to encrypt
+// sensitive data
+func NewBasicIntegrationRepository(
+	db *gorm.DB,
+	key *[32]byte,
+) repository.BasicIntegrationRepository {
+	return &BasicIntegrationRepository{db, key}
+}
+
+// CreateBasicIntegration creates a new basic auth mechanism
+func (repo *BasicIntegrationRepository) CreateBasicIntegration(
+	am *ints.BasicIntegration,
+) (*ints.BasicIntegration, error) {
+	err := repo.EncryptBasicIntegrationData(am, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	project := &models.Project{}
+
+	if err := repo.db.Where("id = ?", am.ProjectID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&project).Association("BasicIntegrations")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(am); err != nil {
+		return nil, err
+	}
+
+	return am, nil
+}
+
+// ReadBasicIntegration finds a basic auth mechanism by id
+func (repo *BasicIntegrationRepository) ReadBasicIntegration(
+	id uint,
+) (*ints.BasicIntegration, error) {
+	basic := &ints.BasicIntegration{}
+
+	if err := repo.db.Where("id = ?", id).First(&basic).Error; err != nil {
+		return nil, err
+	}
+
+	err := repo.DecryptBasicIntegrationData(basic, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return basic, nil
+}
+
+// ListBasicIntegrationsByProjectID finds all basic auth mechanisms
+// for a given project id
+func (repo *BasicIntegrationRepository) ListBasicIntegrationsByProjectID(
+	projectID uint,
+) ([]*ints.BasicIntegration, error) {
+	basics := []*ints.BasicIntegration{}
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&basics).Error; err != nil {
+		return nil, err
+	}
+
+	for _, basic := range basics {
+		repo.DecryptBasicIntegrationData(basic, repo.key)
+	}
+
+	return basics, nil
+}
+
+// EncryptBasicIntegrationData will encrypt the basic integration data before
+// writing to the DB
+func (repo *BasicIntegrationRepository) EncryptBasicIntegrationData(
+	basic *ints.BasicIntegration,
+	key *[32]byte,
+) error {
+	if len(basic.Username) > 0 {
+		cipherData, err := repository.Encrypt(basic.Username, key)
+
+		if err != nil {
+			return err
+		}
+
+		basic.Username = cipherData
+	}
+
+	if len(basic.Password) > 0 {
+		cipherData, err := repository.Encrypt(basic.Password, key)
+
+		if err != nil {
+			return err
+		}
+
+		basic.Password = cipherData
+	}
+
+	return nil
+}
+
+// DecryptBasicIntegrationData will decrypt the basic integration data before
+// returning it from the DB
+func (repo *BasicIntegrationRepository) DecryptBasicIntegrationData(
+	basic *ints.BasicIntegration,
+	key *[32]byte,
+) error {
+	if len(basic.Username) > 0 {
+		plaintext, err := repository.Decrypt(basic.Username, key)
+
+		if err != nil {
+			return err
+		}
+
+		basic.Username = plaintext
+	}
+
+	if len(basic.Password) > 0 {
+		plaintext, err := repository.Decrypt(basic.Password, key)
+
+		if err != nil {
+			return err
+		}
+
+		basic.Password = plaintext
+	}
+
+	return nil
+}
+
 // OIDCIntegrationRepository uses gorm.DB for querying the database
 type OIDCIntegrationRepository struct {
 	db  *gorm.DB

+ 86 - 0
internal/repository/gorm/auth_test.go

@@ -94,6 +94,92 @@ func TestListKubeIntegrationsByProjectID(t *testing.T) {
 	}
 }
 
+func TestCreateBasicIntegration(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_basic.db",
+	}
+
+	setupTestEnv(tester, t)
+	initUser(tester, t)
+	initProject(tester, t)
+	defer cleanup(tester, t)
+
+	basic := &ints.BasicIntegration{
+		ProjectID: tester.initProjects[0].ID,
+		UserID:    tester.initUsers[0].ID,
+		Username:  []byte("username"),
+		Password:  []byte("password"),
+	}
+
+	expBasic := *basic
+
+	basic, err := tester.repo.BasicIntegration.CreateBasicIntegration(basic)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	basic, err = tester.repo.BasicIntegration.ReadBasicIntegration(basic.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1
+	if basic.Model.ID != 1 {
+		t.Errorf("incorrect basic integration ID: expected %d, got %d\n", 1, basic.Model.ID)
+	}
+
+	// reset fields for deep.Equal
+	basic.Model = orm.Model{}
+
+	if diff := deep.Equal(expBasic, *basic); diff != nil {
+		t.Errorf("incorrect basic integration")
+		t.Error(diff)
+	}
+}
+
+func TestListBasicIntegrationsByProjectID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_list_basics.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initBasicIntegration(tester, t)
+	defer cleanup(tester, t)
+
+	basics, err := tester.repo.BasicIntegration.ListBasicIntegrationsByProjectID(
+		tester.initProjects[0].Model.ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(basics) != 1 {
+		t.Fatalf("length of basic integrations incorrect: expected %d, got %d\n", 1, len(basics))
+	}
+
+	// make sure data is correct
+	expBasic := ints.BasicIntegration{
+		ProjectID: tester.initProjects[0].ID,
+		UserID:    tester.initUsers[0].ID,
+		Username:  []byte("username"),
+		Password:  []byte("password"),
+	}
+
+	basic := basics[0]
+
+	// reset fields for reflect.DeepEqual
+	basic.Model = orm.Model{}
+
+	if diff := deep.Equal(expBasic, *basic); diff != nil {
+		t.Errorf("incorrect basic integration")
+		t.Error(diff)
+	}
+}
+
 func TestCreateOIDCIntegration(t *testing.T) {
 	tester := &tester{
 		dbFileName: "./porter_create_oidc.db",

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

@@ -206,7 +206,7 @@ func (repo *ClusterRepository) UpdateCluster(
 
 // UpdateClusterTokenCache updates the token cache for a cluster
 func (repo *ClusterRepository) UpdateClusterTokenCache(
-	tokenCache *ints.TokenCache,
+	tokenCache *ints.ClusterTokenCache,
 ) (*models.Cluster, error) {
 	if tok := tokenCache.Token; len(tok) > 0 {
 		cipherData, err := repository.Encrypt(tok, repo.key)

+ 5 - 3
internal/repository/gorm/cluster_test.go

@@ -357,9 +357,11 @@ func TestUpdateClusterToken(t *testing.T) {
 		Server:                   "https://localhost",
 		KubeIntegrationID:        tester.initKIs[0].ID,
 		CertificateAuthorityData: []byte("-----BEGIN"),
-		TokenCache: ints.TokenCache{
-			Token:  []byte("token-1"),
-			Expiry: time.Now().Add(-1 * time.Hour),
+		TokenCache: ints.ClusterTokenCache{
+			TokenCache: ints.TokenCache{
+				Token:  []byte("token-1"),
+				Expiry: time.Now().Add(-1 * time.Hour),
+			},
 		},
 	}
 

+ 196 - 0
internal/repository/gorm/helm_repo.go

@@ -0,0 +1,196 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// HelmRepoRepository uses gorm.DB for querying the database
+type HelmRepoRepository struct {
+	db  *gorm.DB
+	key *[32]byte
+}
+
+// NewHelmRepoRepository returns a HelmRepoRepository which uses
+// gorm.DB for querying the database
+func NewHelmRepoRepository(db *gorm.DB, key *[32]byte) repository.HelmRepoRepository {
+	return &HelmRepoRepository{db, key}
+}
+
+// CreateHelmRepo creates a new helm repo
+func (repo *HelmRepoRepository) CreateHelmRepo(hr *models.HelmRepo) (*models.HelmRepo, error) {
+	err := repo.EncryptHelmRepoData(hr, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	project := &models.Project{}
+
+	if err := repo.db.Where("id = ?", hr.ProjectID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&project).Association("HelmRepos")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(hr); err != nil {
+		return nil, err
+	}
+
+	// create a token cache by default
+	assoc = repo.db.Model(hr).Association("TokenCache")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(&hr.TokenCache); err != nil {
+		return nil, err
+	}
+
+	err = repo.DecryptHelmRepoData(hr, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return hr, nil
+}
+
+// ReadHelmRepo gets a helm repo specified by a unique id
+func (repo *HelmRepoRepository) ReadHelmRepo(id uint) (*models.HelmRepo, error) {
+	hr := &models.HelmRepo{}
+
+	if err := repo.db.Preload("TokenCache").Where("id = ?", id).First(&hr).Error; err != nil {
+		return nil, err
+	}
+
+	repo.DecryptHelmRepoData(hr, repo.key)
+
+	return hr, nil
+}
+
+// ListHelmReposByProjectID finds all helm repos
+// for a given project id
+func (repo *HelmRepoRepository) ListHelmReposByProjectID(
+	projectID uint,
+) ([]*models.HelmRepo, error) {
+	hrs := []*models.HelmRepo{}
+
+	if err := repo.db.Preload("TokenCache").Where("project_id = ?", projectID).Find(&hrs).Error; err != nil {
+		return nil, err
+	}
+
+	for _, hr := range hrs {
+		repo.DecryptHelmRepoData(hr, repo.key)
+	}
+
+	return hrs, nil
+}
+
+// UpdateHelmRepo modifies an existing HelmRepo in the database
+func (repo *HelmRepoRepository) UpdateHelmRepo(
+	hr *models.HelmRepo,
+) (*models.HelmRepo, error) {
+	if err := repo.db.Save(hr).Error; err != nil {
+		return nil, err
+	}
+
+	return hr, nil
+}
+
+// UpdateHelmRepoTokenCache updates the helm repo for a registry
+func (repo *HelmRepoRepository) UpdateHelmRepoTokenCache(
+	tokenCache *ints.HelmRepoTokenCache,
+) (*models.HelmRepo, error) {
+	if tok := tokenCache.Token; len(tok) > 0 {
+		cipherData, err := repository.Encrypt(tok, repo.key)
+
+		if err != nil {
+			return nil, err
+		}
+
+		tokenCache.Token = cipherData
+	}
+
+	hr := &models.HelmRepo{}
+
+	if err := repo.db.Where("id = ?", tokenCache.HelmRepoID).First(&hr).Error; err != nil {
+		return nil, err
+	}
+
+	hr.TokenCache.Token = tokenCache.Token
+	hr.TokenCache.Expiry = tokenCache.Expiry
+
+	if err := repo.db.Save(hr).Error; err != nil {
+		return nil, err
+	}
+
+	return hr, nil
+}
+
+// DeleteHelmRepo removes a registry from the db
+func (repo *HelmRepoRepository) DeleteHelmRepo(
+	hr *models.HelmRepo,
+) error {
+	// clear TokenCache association
+	assoc := repo.db.Model(hr).Association("TokenCache")
+
+	if assoc.Error != nil {
+		return assoc.Error
+	}
+
+	if err := assoc.Clear(); err != nil {
+		return err
+	}
+
+	if err := repo.db.Where("id = ?", hr.ID).Delete(&models.HelmRepo{}).Error; err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// EncryptHelmRepoData will encrypt the user's helm repo data before writing
+// to the DB
+func (repo *HelmRepoRepository) EncryptHelmRepoData(
+	hr *models.HelmRepo,
+	key *[32]byte,
+) error {
+	if tok := hr.TokenCache.Token; len(tok) > 0 {
+		cipherData, err := repository.Encrypt(tok, key)
+
+		if err != nil {
+			return err
+		}
+
+		hr.TokenCache.Token = cipherData
+	}
+
+	return nil
+}
+
+// DecryptHelmRepoData will decrypt the user's helm repo data before returning it
+// from the DB
+func (repo *HelmRepoRepository) DecryptHelmRepoData(
+	hr *models.HelmRepo,
+	key *[32]byte,
+) error {
+	if tok := hr.TokenCache.Token; len(tok) > 0 {
+		plaintext, err := repository.Decrypt(tok, key)
+
+		if err != nil {
+			return err
+		}
+
+		hr.TokenCache.Token = plaintext
+	}
+
+	return nil
+}

+ 250 - 0
internal/repository/gorm/helm_repo_test.go

@@ -0,0 +1,250 @@
+package gorm_test
+
+import (
+	"testing"
+	"time"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"gorm.io/gorm"
+	orm "gorm.io/gorm"
+)
+
+func TestCreateHelmRepo(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_hr.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	defer cleanup(tester, t)
+
+	hr := &models.HelmRepo{
+		Name:      "helm-repo-test",
+		RepoURL:   "https://example-repo.com",
+		ProjectID: tester.initProjects[0].Model.ID,
+	}
+
+	hr, err := tester.repo.HelmRepo.CreateHelmRepo(hr)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	hr, err = tester.repo.HelmRepo.ReadHelmRepo(hr.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1 and name is "registry-test"
+	if hr.Model.ID != 1 {
+		t.Errorf("incorrect registry ID: expected %d, got %d\n", 1, hr.Model.ID)
+	}
+
+	if hr.Name != "helm-repo-test" {
+		t.Errorf("incorrect helm repo name: expected %s, got %s\n", "helm-repo-test", hr.Name)
+	}
+
+	if hr.RepoURL != "https://example-repo.com" {
+		t.Errorf("incorrect helm repo url: expected %s, got %s\n", "https://example-repo.com", hr.RepoURL)
+	}
+}
+
+func TestListHelmReposByProjectID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_list_hrs.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initHelmRepo(tester, t)
+	defer cleanup(tester, t)
+
+	hrs, err := tester.repo.HelmRepo.ListHelmReposByProjectID(
+		tester.initProjects[0].Model.ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(hrs) != 1 {
+		t.Fatalf("length of helm repos incorrect: expected %d, got %d\n", 1, len(hrs))
+	}
+
+	// make sure data is correct
+	expHelmRepo := models.HelmRepo{
+		Name:      "helm-repo-test",
+		RepoURL:   "https://example-repo.com",
+		ProjectID: tester.initProjects[0].Model.ID,
+	}
+
+	hr := hrs[0]
+
+	// reset fields for reflect.DeepEqual
+	hr.Model = gorm.Model{}
+
+	if diff := deep.Equal(expHelmRepo, *hr); diff != nil {
+		t.Errorf("incorrect helm repo")
+		t.Error(diff)
+	}
+}
+
+func TestUpdateHelmRepo(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_update_hr.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initHelmRepo(tester, t)
+	defer cleanup(tester, t)
+
+	hr := tester.initHRs[0]
+
+	hr.Name = "helm-repo-new-name"
+
+	hr, err := tester.repo.HelmRepo.UpdateHelmRepo(
+		hr,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	hr, err = tester.repo.HelmRepo.ReadHelmRepo(tester.initHRs[0].ID)
+
+	// make sure data is correct
+	expHelmRepo := models.HelmRepo{
+		Name:      "helm-repo-new-name",
+		RepoURL:   "https://example-repo.com",
+		ProjectID: tester.initProjects[0].Model.ID,
+	}
+
+	// reset fields for reflect.DeepEqual
+	hr.Model = orm.Model{}
+
+	if diff := deep.Equal(expHelmRepo, *hr); diff != nil {
+		t.Errorf("incorrect helm repo")
+		t.Error(diff)
+	}
+}
+
+func TestUpdateHelmRepoToken(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_test_update_hr_token.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	defer cleanup(tester, t)
+
+	hr := &models.HelmRepo{
+		Name:      "helm-repo-test",
+		RepoURL:   "https://example-repo.com",
+		ProjectID: tester.initProjects[0].Model.ID,
+		TokenCache: ints.HelmRepoTokenCache{
+			TokenCache: ints.TokenCache{
+				Token:  []byte("token-1"),
+				Expiry: time.Now().Add(-1 * time.Hour),
+			},
+		},
+	}
+
+	hr, err := tester.repo.HelmRepo.CreateHelmRepo(hr)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	hr, err = tester.repo.HelmRepo.ReadHelmRepo(hr.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure helm repo id of token is 1
+	if hr.TokenCache.HelmRepoID != 1 {
+		t.Fatalf("incorrect helm repo id in token cache: expected %d, got %d\n", 1, hr.TokenCache.HelmRepoID)
+	}
+
+	// make sure old token is expired
+	if isExpired := hr.TokenCache.IsExpired(); !isExpired {
+		t.Fatalf("token was not expired\n")
+	}
+
+	if string(hr.TokenCache.Token) != "token-1" {
+		t.Errorf("incorrect token in cache: expected %s, got %s\n", "token-1", hr.TokenCache.Token)
+	}
+
+	hr.TokenCache.Token = []byte("token-2")
+	hr.TokenCache.Expiry = time.Now().Add(24 * time.Hour)
+
+	hr, err = tester.repo.HelmRepo.UpdateHelmRepoTokenCache(&hr.TokenCache)
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+	hr, err = tester.repo.HelmRepo.ReadHelmRepo(hr.Model.ID)
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1
+	if hr.Model.ID != 1 {
+		t.Errorf("incorrect helm repo ID: expected %d, got %d\n", 1, hr.Model.ID)
+	}
+
+	// make sure new token is correct and not expired
+	if hr.TokenCache.HelmRepoID != 1 {
+		t.Fatalf("incorrect helm repo ID in token cache: expected %d, got %d\n", 1, hr.TokenCache.HelmRepoID)
+	}
+
+	if isExpired := hr.TokenCache.IsExpired(); isExpired {
+		t.Fatalf("token was expired\n")
+	}
+
+	if string(hr.TokenCache.Token) != "token-2" {
+		t.Errorf("incorrect token in cache: expected %s, got %s\n", "token-2", hr.TokenCache.Token)
+	}
+}
+
+// func TestDeleteRegistry(t *testing.T) {
+// 	tester := &tester{
+// 		dbFileName: "./porter_delete_registry.db",
+// 	}
+
+// 	setupTestEnv(tester, t)
+// 	initProject(tester, t)
+// 	initRegistry(tester, t)
+// 	defer cleanup(tester, t)
+
+// 	reg, err := tester.repo.Registry.ReadRegistry(tester.initRegs[0].Model.ID)
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	err = tester.repo.Registry.DeleteRegistry(reg)
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	_, err = tester.repo.Registry.ReadRegistry(tester.initRegs[0].Model.ID)
+
+// 	if err != orm.ErrRecordNotFound {
+// 		t.Fatalf("incorrect error: expected %v, got %v\n", orm.ErrRecordNotFound, err)
+// 	}
+
+// 	regs, err := tester.repo.Registry.ListRegistriesByProjectID(tester.initProjects[0].Model.ID)
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	if len(regs) != 0 {
+// 		t.Fatalf("length of clusters was not 0")
+// 	}
+// }

+ 55 - 1
internal/repository/gorm/helpers_test.go

@@ -21,8 +21,10 @@ type tester struct {
 	initGRs      []*models.GitRepo
 	initRegs     []*models.Registry
 	initClusters []*models.Cluster
+	initHRs      []*models.HelmRepo
 	initCCs      []*models.ClusterCandidate
 	initKIs      []*ints.KubeIntegration
+	initBasics   []*ints.BasicIntegration
 	initOIDCs    []*ints.OIDCIntegration
 	initOAuths   []*ints.OAuthIntegration
 	initGCPs     []*ints.GCPIntegration
@@ -49,16 +51,19 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&models.Session{},
 		&models.GitRepo{},
 		&models.Registry{},
+		&models.HelmRepo{},
 		&models.Cluster{},
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
 		&ints.KubeIntegration{},
+		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
 		&ints.OAuthIntegration{},
 		&ints.GCPIntegration{},
 		&ints.AWSIntegration{},
-		&ints.TokenCache{},
+		&ints.ClusterTokenCache{},
 		&ints.RegTokenCache{},
+		&ints.HelmRepoTokenCache{},
 	)
 
 	if err != nil {
@@ -159,6 +164,33 @@ func initKubeIntegration(tester *tester, t *testing.T) {
 	tester.initKIs = append(tester.initKIs, ki)
 }
 
+func initBasicIntegration(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initUsers) == 0 {
+		initUser(tester, t)
+	}
+
+	basic := &ints.BasicIntegration{
+		ProjectID: tester.initProjects[0].ID,
+		UserID:    tester.initUsers[0].ID,
+		Username:  []byte("username"),
+		Password:  []byte("password"),
+	}
+
+	basic, err := tester.repo.BasicIntegration.CreateBasicIntegration(basic)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initBasics = append(tester.initBasics, basic)
+}
+
 func initOIDCIntegration(tester *tester, t *testing.T) {
 	t.Helper()
 
@@ -380,3 +412,25 @@ func initRegistry(tester *tester, t *testing.T) {
 
 	tester.initRegs = append(tester.initRegs, reg)
 }
+
+func initHelmRepo(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	hr := &models.HelmRepo{
+		Name:      "helm-repo-test",
+		RepoURL:   "https://example-repo.com",
+		ProjectID: tester.initProjects[0].Model.ID,
+	}
+
+	hr, err := tester.repo.HelmRepo.CreateHelmRepo(hr)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initHRs = append(tester.initHRs, hr)
+}

+ 4 - 2
internal/repository/gorm/registry_test.go

@@ -138,8 +138,10 @@ func TestUpdateRegistryToken(t *testing.T) {
 		Name:      "registry-test",
 		ProjectID: tester.initProjects[0].Model.ID,
 		TokenCache: ints.RegTokenCache{
-			Token:  []byte("token-1"),
-			Expiry: time.Now().Add(-1 * time.Hour),
+			TokenCache: ints.TokenCache{
+				Token:  []byte("token-1"),
+				Expiry: time.Now().Add(-1 * time.Hour),
+			},
 		},
 	}
 

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

@@ -14,8 +14,10 @@ func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 		Project:          NewProjectRepository(db),
 		GitRepo:          NewGitRepoRepository(db, key),
 		Cluster:          NewClusterRepository(db, key),
+		HelmRepo:         NewHelmRepoRepository(db, key),
 		Registry:         NewRegistryRepository(db, key),
 		KubeIntegration:  NewKubeIntegrationRepository(db, key),
+		BasicIntegration: NewBasicIntegrationRepository(db, key),
 		OIDCIntegration:  NewOIDCIntegrationRepository(db, key),
 		OAuthIntegration: NewOAuthIntegrationRepository(db, key),
 		GCPIntegration:   NewGCPIntegrationRepository(db, key),

+ 16 - 0
internal/repository/helm_repo.go

@@ -0,0 +1,16 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+// HelmRepoRepository represents the set of queries on the HelmRepo model
+type HelmRepoRepository interface {
+	CreateHelmRepo(repo *models.HelmRepo) (*models.HelmRepo, error)
+	ReadHelmRepo(id uint) (*models.HelmRepo, error)
+	ListHelmReposByProjectID(projectID uint) ([]*models.HelmRepo, error)
+	UpdateHelmRepo(repo *models.HelmRepo) (*models.HelmRepo, error)
+	UpdateHelmRepoTokenCache(tokenCache *ints.HelmRepoTokenCache) (*models.HelmRepo, error)
+	DeleteHelmRepo(repo *models.HelmRepo) error
+}

+ 8 - 0
internal/repository/integrations.go

@@ -12,6 +12,14 @@ type KubeIntegrationRepository interface {
 	ListKubeIntegrationsByProjectID(projectID uint) ([]*ints.KubeIntegration, error)
 }
 
+// BasicIntegrationRepository represents the set of queries on the "basic" auth
+// mechanism
+type BasicIntegrationRepository interface {
+	CreateBasicIntegration(am *ints.BasicIntegration) (*ints.BasicIntegration, error)
+	ReadBasicIntegration(id uint) (*ints.BasicIntegration, error)
+	ListBasicIntegrationsByProjectID(projectID uint) ([]*ints.BasicIntegration, error)
+}
+
 // OIDCIntegrationRepository represents the set of queries on the OIDC auth
 // mechanism
 type OIDCIntegrationRepository interface {

+ 64 - 0
internal/repository/test/auth.go → internal/repository/memory/auth.go

@@ -73,6 +73,70 @@ func (repo *KubeIntegrationRepository) ListKubeIntegrationsByProjectID(
 	return res, nil
 }
 
+// BasicIntegrationRepository implements repository.BasicIntegrationRepository
+type BasicIntegrationRepository struct {
+	canQuery          bool
+	basicIntegrations []*ints.BasicIntegration
+}
+
+// NewBasicIntegrationRepository will return errors if canQuery is false
+func NewBasicIntegrationRepository(canQuery bool) repository.BasicIntegrationRepository {
+	return &BasicIntegrationRepository{
+		canQuery,
+		[]*ints.BasicIntegration{},
+	}
+}
+
+// CreateBasicIntegration creates a new basic auth mechanism
+func (repo *BasicIntegrationRepository) CreateBasicIntegration(
+	am *ints.BasicIntegration,
+) (*ints.BasicIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.basicIntegrations = append(repo.basicIntegrations, am)
+	am.ID = uint(len(repo.basicIntegrations))
+
+	return am, nil
+}
+
+// ReadBasicIntegration finds a basic auth mechanism by id
+func (repo *BasicIntegrationRepository) ReadBasicIntegration(
+	id uint,
+) (*ints.BasicIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.basicIntegrations) || repo.basicIntegrations[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.basicIntegrations[index], nil
+}
+
+// ListBasicIntegrationsByProjectID finds all basic auth mechanisms
+// for a given project id
+func (repo *BasicIntegrationRepository) ListBasicIntegrationsByProjectID(
+	projectID uint,
+) ([]*ints.BasicIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	res := make([]*ints.BasicIntegration, 0)
+
+	for _, basicAM := range repo.basicIntegrations {
+		if basicAM.ProjectID == projectID {
+			res = append(res, basicAM)
+		}
+	}
+
+	return res, nil
+}
+
 // OIDCIntegrationRepository implements repository.OIDCIntegrationRepository
 type OIDCIntegrationRepository struct {
 	canQuery         bool

+ 1 - 1
internal/repository/test/cluster.go → internal/repository/memory/cluster.go

@@ -164,7 +164,7 @@ func (repo *ClusterRepository) UpdateCluster(
 
 // UpdateClusterTokenCache updates the token cache for a cluster
 func (repo *ClusterRepository) UpdateClusterTokenCache(
-	tokenCache *ints.TokenCache,
+	tokenCache *ints.ClusterTokenCache,
 ) (*models.Cluster, error) {
 	if !repo.canQuery {
 		return nil, errors.New("Cannot write database")

+ 0 - 0
internal/repository/test/gitrepo.go → internal/repository/memory/gitrepo.go


+ 125 - 0
internal/repository/memory/helm_repo.go

@@ -0,0 +1,125 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// HelmRepoRepository implements repository.HelmRepoRepository
+type HelmRepoRepository struct {
+	canQuery  bool
+	helmRepos []*models.HelmRepo
+}
+
+// NewHelmRepoRepository will return errors if canQuery is false
+func NewHelmRepoRepository(canQuery bool) repository.HelmRepoRepository {
+	return &HelmRepoRepository{
+		canQuery,
+		[]*models.HelmRepo{},
+	}
+}
+
+// CreateHelmRepo creates a new repoistry
+func (repo *HelmRepoRepository) CreateHelmRepo(
+	hr *models.HelmRepo,
+) (*models.HelmRepo, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.helmRepos = append(repo.helmRepos, hr)
+	hr.ID = uint(len(repo.helmRepos))
+
+	return hr, nil
+}
+
+// ReadHelmRepo finds a repoistry by id
+func (repo *HelmRepoRepository) ReadHelmRepo(
+	id uint,
+) (*models.HelmRepo, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.helmRepos) || repo.helmRepos[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.helmRepos[index], nil
+}
+
+// ListHelmReposByProjectID finds all repoistries
+// for a given project id
+func (repo *HelmRepoRepository) ListHelmReposByProjectID(
+	projectID uint,
+) ([]*models.HelmRepo, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	res := make([]*models.HelmRepo, 0)
+
+	for _, hr := range repo.helmRepos {
+		if hr != nil && hr.ProjectID == projectID {
+			res = append(res, hr)
+		}
+	}
+
+	return res, nil
+}
+
+// UpdateHelmRepo modifies an existing HelmRepo in the database
+func (repo *HelmRepoRepository) UpdateHelmRepo(
+	hr *models.HelmRepo,
+) (*models.HelmRepo, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(hr.ID-1) >= len(repo.helmRepos) || repo.helmRepos[hr.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(hr.ID - 1)
+	repo.helmRepos[index] = hr
+
+	return hr, nil
+}
+
+// UpdateHelmRepoTokenCache updates the token cache for a repoistry
+func (repo *HelmRepoRepository) UpdateHelmRepoTokenCache(
+	tokenCache *ints.HelmRepoTokenCache,
+) (*models.HelmRepo, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	index := int(tokenCache.HelmRepoID - 1)
+	repo.helmRepos[index].TokenCache.Token = tokenCache.Token
+	repo.helmRepos[index].TokenCache.Expiry = tokenCache.Expiry
+
+	return repo.helmRepos[index], nil
+}
+
+// DeleteHelmRepo removes a repoistry from the array by setting it to nil
+func (repo *HelmRepoRepository) DeleteHelmRepo(
+	hr *models.HelmRepo,
+) error {
+	if !repo.canQuery {
+		return errors.New("Cannot write database")
+	}
+
+	if int(hr.ID-1) >= len(repo.helmRepos) || repo.helmRepos[hr.ID-1] == nil {
+		return gorm.ErrRecordNotFound
+	}
+
+	index := int(hr.ID - 1)
+	repo.helmRepos[index] = nil
+
+	return nil
+}

+ 0 - 0
internal/repository/test/project.go → internal/repository/memory/project.go


+ 0 - 0
internal/repository/test/registry.go → internal/repository/memory/registry.go


+ 2 - 0
internal/repository/test/repository.go → internal/repository/memory/repository.go

@@ -12,9 +12,11 @@ func NewRepository(canQuery bool) *repository.Repository {
 		Session:          NewSessionRepository(canQuery),
 		Project:          NewProjectRepository(canQuery),
 		Cluster:          NewClusterRepository(canQuery),
+		HelmRepo:         NewHelmRepoRepository(canQuery),
 		Registry:         NewRegistryRepository(canQuery),
 		GitRepo:          NewGitRepoRepository(canQuery),
 		KubeIntegration:  NewKubeIntegrationRepository(canQuery),
+		BasicIntegration: NewBasicIntegrationRepository(canQuery),
 		OIDCIntegration:  NewOIDCIntegrationRepository(canQuery),
 		OAuthIntegration: NewOAuthIntegrationRepository(canQuery),
 		GCPIntegration:   NewGCPIntegrationRepository(canQuery),

+ 0 - 0
internal/repository/test/session.go → internal/repository/memory/session.go


+ 0 - 0
internal/repository/test/user.go → internal/repository/memory/user.go


+ 2 - 0
internal/repository/repository.go

@@ -7,8 +7,10 @@ type Repository struct {
 	Session          SessionRepository
 	GitRepo          GitRepoRepository
 	Cluster          ClusterRepository
+	HelmRepo         HelmRepoRepository
 	Registry         RegistryRepository
 	KubeIntegration  KubeIntegrationRepository
+	BasicIntegration BasicIntegrationRepository
 	OIDCIntegration  OIDCIntegrationRepository
 	OAuthIntegration OAuthIntegrationRepository
 	GCPIntegration   GCPIntegrationRepository

+ 9 - 9
internal/templater/dynamic/reader.go

@@ -30,8 +30,8 @@ type Object struct {
 	Name string
 }
 
-// DynamicTemplateReader reads any resource registered with the k8s apiserver
-type DynamicTemplateReader struct {
+// TemplateReader reads any resource registered with the k8s apiserver
+type TemplateReader struct {
 	// The object to read from, identified by its group-version-kind
 	Object *Object
 
@@ -47,7 +47,7 @@ type DynamicTemplateReader struct {
 
 // NewDynamicTemplateReader creates a new DynamicTemplateReader
 func NewDynamicTemplateReader(client dynamic.Interface, obj *Object) templater.TemplateReader {
-	r := &DynamicTemplateReader{
+	r := &TemplateReader{
 		Object: obj,
 		Client: client,
 	}
@@ -66,7 +66,7 @@ func NewDynamicTemplateReader(client dynamic.Interface, obj *Object) templater.T
 }
 
 // ValuesFromTarget retrieves cluster values from the k8s apiserver
-func (r *DynamicTemplateReader) ValuesFromTarget() (map[string]interface{}, error) {
+func (r *TemplateReader) ValuesFromTarget() (map[string]interface{}, error) {
 	// if name is not empty, this is a get operation
 	if r.Object.Name != "" {
 		return r.valuesFromGet()
@@ -76,14 +76,14 @@ func (r *DynamicTemplateReader) ValuesFromTarget() (map[string]interface{}, erro
 }
 
 // RegisterQuery adds a query to the list of queries to execute
-func (r *DynamicTemplateReader) RegisterQuery(query *templater.TemplateReaderQuery) error {
+func (r *TemplateReader) RegisterQuery(query *templater.TemplateReaderQuery) error {
 	r.Queries = append(r.Queries, query)
 
 	return nil
 }
 
 // Read returns the resulting queried data
-func (r *DynamicTemplateReader) Read() (map[string]interface{}, error) {
+func (r *TemplateReader) Read() (map[string]interface{}, error) {
 	values, err := r.ValuesFromTarget()
 
 	if err != nil {
@@ -95,7 +95,7 @@ func (r *DynamicTemplateReader) Read() (map[string]interface{}, error) {
 
 // ReadStream listens for CRUD 	operations on resources and returns resulting
 // queried data
-func (r *DynamicTemplateReader) ReadStream(
+func (r *TemplateReader) ReadStream(
 	on templater.OnDataStream,
 	stopCh <-chan struct{},
 ) error {
@@ -159,7 +159,7 @@ func (r *DynamicTemplateReader) ReadStream(
 	return nil
 }
 
-func (r *DynamicTemplateReader) valuesFromList() (map[string]interface{}, error) {
+func (r *TemplateReader) valuesFromList() (map[string]interface{}, error) {
 	list, err := r.resource.List(context.TODO(), metav1.ListOptions{})
 
 	if err != nil {
@@ -169,7 +169,7 @@ func (r *DynamicTemplateReader) valuesFromList() (map[string]interface{}, error)
 	return list.UnstructuredContent(), nil
 }
 
-func (r *DynamicTemplateReader) valuesFromGet() (map[string]interface{}, error) {
+func (r *TemplateReader) valuesFromGet() (map[string]interface{}, error) {
 	get, err := r.resource.Get(context.TODO(), r.Object.Name, metav1.GetOptions{})
 
 	if err != nil {

+ 12 - 9
internal/templater/dynamic/writer.go

@@ -14,7 +14,9 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
-type DynamicTemplateWriter struct {
+// TemplateWriter satisfies the templater.TemplateWriter interface
+// by creating/updating dynamic k8s resources
+type TemplateWriter struct {
 	// The object to read from, identified by its group-version-kind
 	Object *Object
 
@@ -30,12 +32,13 @@ type DynamicTemplateWriter struct {
 	base map[string]interface{}
 }
 
+// NewDynamicTemplateWriter returns a dynamic TemplateWriter
 func NewDynamicTemplateWriter(
 	client dynamic.Interface,
 	obj *Object,
 	base map[string]interface{},
 ) templater.TemplateWriter {
-	w := &DynamicTemplateWriter{
+	w := &TemplateWriter{
 		Object: obj,
 		Client: client,
 		base:   base,
@@ -52,17 +55,15 @@ func NewDynamicTemplateWriter(
 	return w
 }
 
-func (w *DynamicTemplateWriter) Transform() error {
+// Transform merges base configuration with vals
+func (w *TemplateWriter) Transform() error {
 	w.vals = utils.CoalesceValues(w.base, w.vals)
 
 	return nil
 }
 
-func (w *DynamicTemplateWriter) Write() (map[string]interface{}, error) {
-	return nil, nil
-}
-
-func (w *DynamicTemplateWriter) Create(vals map[string]interface{}) (map[string]interface{}, error) {
+// Create creates a new dynamic resource, this must be registered with the API server
+func (w *TemplateWriter) Create(vals map[string]interface{}) (map[string]interface{}, error) {
 	w.vals = vals
 	err := w.Transform()
 
@@ -81,7 +82,9 @@ func (w *DynamicTemplateWriter) Create(vals map[string]interface{}) (map[string]
 	return create.Object, nil
 }
 
-func (w *DynamicTemplateWriter) Update(vals map[string]interface{}) (map[string]interface{}, error) {
+// Update performs an update operation on a k8s resource. The resource must be
+// registered with the API server.
+func (w *TemplateWriter) Update(vals map[string]interface{}) (map[string]interface{}, error) {
 	w.vals = vals
 	err := w.Transform()
 

+ 3 - 2
internal/templater/form.go

@@ -1,7 +1,8 @@
 package templater
 
 import (
-	"k8s.io/client-go/util/jsonpath"
+	// "k8s.io/client-go/util/jsonpath"
+	"github.com/itchyny/gojq"
 )
 
 // OnDataStream is a function that gets called when new data should be
@@ -12,7 +13,7 @@ type TemplateReaderQuery struct {
 	Key         string
 	QueryString string
 
-	Template *jsonpath.JSONPath
+	Template *gojq.Query
 }
 
 // TemplateReader retrieves data from a target data source, registers a set of

+ 6 - 6
internal/templater/helm/manifests_reader.go → internal/templater/helm/manifests/reader.go

@@ -10,11 +10,11 @@ import (
 	"sigs.k8s.io/yaml"
 )
 
-// ManifestsTemplateReader implements the TemplateReader for reading from
+// TemplateReader implements the templater.TemplateReader for reading from
 // the Helm manifests of a given release.
 //
 // Note: ReadStream does nothing at the moment.
-type ManifestsTemplateReader struct {
+type TemplateReader struct {
 	Queries []*templater.TemplateReaderQuery
 
 	Release *release.Release
@@ -22,7 +22,7 @@ type ManifestsTemplateReader struct {
 
 // ValuesFromTarget returns a set of values by reading from the Helm release's manifest,
 // unmarshaling from the bytes
-func (r *ManifestsTemplateReader) ValuesFromTarget() (map[string]interface{}, error) {
+func (r *TemplateReader) ValuesFromTarget() (map[string]interface{}, error) {
 	if r.Release == nil {
 		return nil, fmt.Errorf("must set release to read manifest")
 	}
@@ -51,14 +51,14 @@ func (r *ManifestsTemplateReader) ValuesFromTarget() (map[string]interface{}, er
 }
 
 // RegisterQuery adds a new query to be executed against the values
-func (r *ManifestsTemplateReader) RegisterQuery(query *templater.TemplateReaderQuery) error {
+func (r *TemplateReader) RegisterQuery(query *templater.TemplateReaderQuery) error {
 	r.Queries = append(r.Queries, query)
 
 	return nil
 }
 
 // Read executes a set of queries against the helm values in the release/chart
-func (r *ManifestsTemplateReader) Read() (map[string]interface{}, error) {
+func (r *TemplateReader) Read() (map[string]interface{}, error) {
 	values, err := r.ValuesFromTarget()
 
 	if err != nil {
@@ -69,7 +69,7 @@ func (r *ManifestsTemplateReader) Read() (map[string]interface{}, error) {
 }
 
 // ReadStream is unimplemented: stub just to implement TemplateReader
-func (r *ManifestsTemplateReader) ReadStream(
+func (r *TemplateReader) ReadStream(
 	on templater.OnDataStream,
 	stopCh <-chan struct{},
 ) error {

+ 6 - 6
internal/templater/helm/values_reader.go → internal/templater/helm/values/reader.go

@@ -10,11 +10,11 @@ import (
 	"helm.sh/helm/v3/pkg/release"
 )
 
-// ValuesTemplateReader implements the TemplateReader for reading from
+// TemplateReader implements the templater.TemplateReader for reading from
 // the Helm values.
 //
 // Note: ReadStream does nothing at the moment.
-type ValuesTemplateReader struct {
+type TemplateReader struct {
 	Queries []*templater.TemplateReaderQuery
 
 	Release *release.Release
@@ -23,7 +23,7 @@ type ValuesTemplateReader struct {
 
 // ValuesFromTarget returns a set of values by reading from a Helm release if set, otherwise
 // a helm chart.
-func (r *ValuesTemplateReader) ValuesFromTarget() (map[string]interface{}, error) {
+func (r *TemplateReader) ValuesFromTarget() (map[string]interface{}, error) {
 	// if release exists, read values from release
 	if r.Release != nil {
 		// merge config values with overriding values
@@ -37,14 +37,14 @@ func (r *ValuesTemplateReader) ValuesFromTarget() (map[string]interface{}, error
 }
 
 // RegisterQuery adds a new query to be executed against the values
-func (r *ValuesTemplateReader) RegisterQuery(query *templater.TemplateReaderQuery) error {
+func (r *TemplateReader) RegisterQuery(query *templater.TemplateReaderQuery) error {
 	r.Queries = append(r.Queries, query)
 
 	return nil
 }
 
 // Read executes a set of queries against the helm values in the release/chart
-func (r *ValuesTemplateReader) Read() (map[string]interface{}, error) {
+func (r *TemplateReader) Read() (map[string]interface{}, error) {
 	values, err := r.ValuesFromTarget()
 
 	if err != nil {
@@ -55,7 +55,7 @@ func (r *ValuesTemplateReader) Read() (map[string]interface{}, error) {
 }
 
 // ReadStream is unimplemented: stub just to implement TemplateReader
-func (r *ValuesTemplateReader) ReadStream(
+func (r *TemplateReader) ReadStream(
 	on templater.OnDataStream,
 	stopCh <-chan struct{},
 ) error {

+ 5 - 5
internal/templater/helm/writer.go → internal/templater/helm/values/writer.go

@@ -7,8 +7,8 @@ import (
 	"helm.sh/helm/v3/pkg/chart"
 )
 
-// ValuesTemplateWriter upgrades and installs charts by setting Helm values
-type ValuesTemplateWriter struct {
+// TemplateWriter upgrades and installs charts by setting Helm values
+type TemplateWriter struct {
 	// The object to read from, identified by its group-version-kind
 	Agent *helm.Agent
 
@@ -23,12 +23,12 @@ type ValuesTemplateWriter struct {
 }
 
 // Transform does nothing, since Helm handles the transforms internally
-func (w *ValuesTemplateWriter) Transform() error {
+func (w *TemplateWriter) Transform() error {
 	return nil
 }
 
 // Create installs a new chart, ChartPath must be set
-func (w *ValuesTemplateWriter) Create(
+func (w *TemplateWriter) Create(
 	vals map[string]interface{},
 ) (map[string]interface{}, error) {
 	if w.Chart == nil {
@@ -52,7 +52,7 @@ func (w *ValuesTemplateWriter) Create(
 }
 
 // Update upgrades a chart, ReleaseName must be set
-func (w *ValuesTemplateWriter) Update(
+func (w *TemplateWriter) Update(
 	vals map[string]interface{},
 ) (map[string]interface{}, error) {
 	if w.ReleaseName != "" {

+ 48 - 48
internal/templater/parser/parser.go

@@ -13,12 +13,12 @@ import (
 	"sigs.k8s.io/yaml"
 
 	td "github.com/porter-dev/porter/internal/templater/dynamic"
-	th "github.com/porter-dev/porter/internal/templater/helm"
+	tm "github.com/porter-dev/porter/internal/templater/helm/manifests"
+	tv "github.com/porter-dev/porter/internal/templater/helm/values"
 )
 
-// TODO -- handle all continue statements, errors should at least be logged if not
-// thrown
-
+// ClientConfigDefault is a set of default clients to be used if a context in
+// form.yaml does not declare otherwise.
 type ClientConfigDefault struct {
 	DynamicClient dynamic.Interface
 
@@ -27,29 +27,43 @@ type ClientConfigDefault struct {
 	HelmChart   *chart.Chart
 }
 
-func FormYAMLFromBytes(def *ClientConfigDefault, bytes []byte) (*models.FormYAML, error) {
+// ContextConfig can read/write from a specified context (data source)
+type ContextConfig struct {
+	FromType       string   // "live" or "declared"
+	Capabilities   []string // "read", "write"
+	TemplateReader templater.TemplateReader
+	TemplateWriter templater.TemplateWriter
+}
+
+// FormYAMLFromBytes generates a usable form yaml from raw form config and a
+// set of default clients.
+//
+// stateType refers to the types of state that should be read. The two state types
+// are "live" and "declared" -- if stateType is "", this will read both live and
+// declared states.
+func FormYAMLFromBytes(def *ClientConfigDefault, bytes []byte, stateType string) (*models.FormYAML, error) {
 	form, err := unqueriedFormYAMLFromBytes(bytes)
 
 	if err != nil {
 		return nil, err
 	}
 
-	lookup := formToLookupTable(def, form)
+	lookup := formToLookupTable(def, form, stateType)
 
 	// merge data from lookup
 	data := make(map[string]interface{})
 
 	for _, lookupVal := range lookup {
-		queryRes, err := lookupVal.TemplateReader.Read()
+		if lookupVal != nil {
+			queryRes, err := lookupVal.TemplateReader.Read()
 
-		if err != nil {
-			continue
-		}
-
-		for queryResKey, queryResVal := range queryRes {
-			fmt.Printf("PARSER: found value %s, %v\n", queryResKey, queryResVal)
+			if err != nil {
+				continue
+			}
 
-			data[queryResKey] = queryResVal
+			for queryResKey, queryResVal := range queryRes {
+				data[queryResKey] = queryResVal
+			}
 		}
 	}
 
@@ -105,16 +119,9 @@ func unqueriedFormYAMLFromBytes(bytes []byte) (*models.FormYAML, error) {
 	return form, nil
 }
 
-type ContextConfig struct {
-	FromType       string   // "live" or "declared"
-	Capabilities   []string // "read", "write"
-	TemplateReader templater.TemplateReader
-	TemplateWriter templater.TemplateWriter
-}
-
 // create map[*FormContext]*ContextConfig
 // assumes all contexts populated
-func formToLookupTable(def *ClientConfigDefault, form *models.FormYAML) map[*models.FormContext]*ContextConfig {
+func formToLookupTable(def *ClientConfigDefault, form *models.FormYAML, stateType string) map[*models.FormContext]*ContextConfig {
 	lookup := make(map[*models.FormContext]*ContextConfig)
 
 	for i, tab := range form.Tabs {
@@ -125,10 +132,12 @@ func formToLookupTable(def *ClientConfigDefault, form *models.FormYAML) map[*mod
 				}
 
 				if _, ok := lookup[content.Context]; !ok {
-					lookup[content.Context] = formContextToContextConfig(def, content.Context)
+					lookup[content.Context] = formContextToContextConfig(def, content.Context, stateType)
 				}
 
-				fmt.Printf("PARSER: content value %v, variable %s\n", content.Value, content.Variable)
+				if lookup[content.Context] == nil {
+					continue
+				}
 
 				if content.Value != nil && fmt.Sprintf("%v", content.Value) != "" {
 					// TODO -- case on whether value is proper query string, if not resolve it to a
@@ -138,36 +147,28 @@ func formToLookupTable(def *ClientConfigDefault, form *models.FormYAML) map[*mod
 						fmt.Sprintf("%v", content.Value),
 					)
 
-					fmt.Printf(
-						"PARSER: added query %s, %s\n",
-						fmt.Sprintf("tabs[%d].sections[%d].contents[%d]", i, j, k),
-						fmt.Sprintf("%v", content.Value),
-					)
-
 					if err != nil {
 						continue
 					}
 
-					lookup[content.Context].TemplateReader.RegisterQuery(query)
+					if stateType == "" || stateType == lookup[content.Context].FromType {
+						lookup[content.Context].TemplateReader.RegisterQuery(query)
+					}
 				} else if content.Variable != "" {
 					// if variable field set without value field set, make variable field into jsonpath
 					// query
 					query, err := utils.NewQuery(
 						fmt.Sprintf("tabs[%d].sections[%d].contents[%d]", i, j, k),
-						fmt.Sprintf("{ .%v }", content.Variable),
-					)
-
-					fmt.Printf(
-						"PARSER: added query %s, %s\n",
-						fmt.Sprintf("tabs[%d].sections[%d].contents[%d]", i, j, k),
-						fmt.Sprintf("{ .%v }", content.Variable),
+						fmt.Sprintf(".%v", content.Variable),
 					)
 
 					if err != nil {
 						continue
 					}
 
-					lookup[content.Context].TemplateReader.RegisterQuery(query)
+					if stateType == "" || stateType == lookup[content.Context].FromType {
+						lookup[content.Context].TemplateReader.RegisterQuery(query)
+					}
 				}
 			}
 		}
@@ -179,16 +180,15 @@ func formToLookupTable(def *ClientConfigDefault, form *models.FormYAML) map[*mod
 // TODO -- this needs to be able to construct new context configs based on
 // configuration for each context, but right now just uses the default config
 // for everything
-func formContextToContextConfig(def *ClientConfigDefault, context *models.FormContext) *ContextConfig {
+func formContextToContextConfig(def *ClientConfigDefault, context *models.FormContext, stateType string) *ContextConfig {
 	res := &ContextConfig{}
 
-	switch context.Type {
-	case "helm/values":
+	if context.Type == "helm/values" && (stateType == "" || stateType == "declared") {
 		res.FromType = "declared"
 
 		res.Capabilities = []string{"read", "write"}
 
-		res.TemplateReader = &th.ValuesTemplateReader{
+		res.TemplateReader = &tv.TemplateReader{
 			Release: def.HelmRelease,
 			Chart:   def.HelmChart,
 		}
@@ -199,20 +199,20 @@ func formContextToContextConfig(def *ClientConfigDefault, context *models.FormCo
 			relName = def.HelmRelease.Name
 		}
 
-		res.TemplateWriter = &th.ValuesTemplateWriter{
+		res.TemplateWriter = &tv.TemplateWriter{
 			Agent:       def.HelmAgent,
 			Chart:       def.HelmChart,
 			ReleaseName: relName,
 		}
-	case "helm/manifests":
+	} else if context.Type == "helm/manifests" && (stateType == "" || stateType == "live") {
 		res.FromType = "live"
 
 		res.Capabilities = []string{"read"}
 
-		res.TemplateReader = &th.ManifestsTemplateReader{
+		res.TemplateReader = &tm.TemplateReader{
 			Release: def.HelmRelease,
 		}
-	case "cluster":
+	} else if context.Type == "cluster" && (stateType == "" || stateType == "live") {
 		res.FromType = "live"
 
 		res.Capabilities = []string{"read"}
@@ -227,7 +227,7 @@ func formContextToContextConfig(def *ClientConfigDefault, context *models.FormCo
 		}
 
 		res.TemplateReader = td.NewDynamicTemplateReader(def.DynamicClient, obj)
-	default:
+	} else {
 		return nil
 	}
 

+ 15 - 17
internal/templater/utils/query.go

@@ -1,19 +1,14 @@
 package utils
 
 import (
-	"fmt"
-
+	"github.com/itchyny/gojq"
 	"github.com/porter-dev/porter/internal/templater"
-	"k8s.io/client-go/util/jsonpath"
 )
 
 // NewQuery constructs a templater.TemplateReaderQuery by parsing the jsonpath
 // query string
 func NewQuery(key, query string) (*templater.TemplateReaderQuery, error) {
-	j := jsonpath.New(key)
-	j.AllowMissingKeys(true)
-
-	err := j.Parse(query)
+	jquery, err := gojq.Parse(query)
 
 	if err != nil {
 		return nil, err
@@ -22,7 +17,7 @@ func NewQuery(key, query string) (*templater.TemplateReaderQuery, error) {
 	return &templater.TemplateReaderQuery{
 		Key:         key,
 		QueryString: query,
-		Template:    j,
+		Template:    jquery,
 	}, nil
 }
 
@@ -36,19 +31,22 @@ func QueryValues(
 
 	// iterate through all registered queries, add to resulting map
 	for _, query := range queries {
-		fullResults, err := query.Template.FindResults(values)
-
-		if err != nil {
-			fmt.Printf("query error %s", err)
-			continue
-		}
+		iter := query.Template.Run(values)
 
 		queryRes := make([]interface{}, 0)
 
-		for ix := range fullResults {
-			for _, result := range fullResults[ix] {
-				queryRes = append(queryRes, result.Interface())
+		for {
+			v, ok := iter.Next()
+
+			if !ok {
+				break
+			}
+
+			if err, ok := v.(error); ok {
+				return nil, err
 			}
+
+			queryRes = append(queryRes, v)
 		}
 
 		res[query.Key] = queryRes

+ 0 - 37
internal/templater/utils/query_test.go

@@ -1,37 +0,0 @@
-package utils_test
-
-import (
-	"encoding/json"
-	"testing"
-
-	"github.com/porter-dev/porter/internal/templater"
-	"github.com/porter-dev/porter/internal/templater/utils"
-)
-
-type testType struct {
-	Value interface{} `json:"value,omitempty"`
-}
-
-func TestQueryValues(t *testing.T) {
-	vals := map[string]interface{}{
-		"testing": map[string]interface{}{
-			"hello": "there",
-		},
-	}
-
-	queries := make([]*templater.TemplateReaderQuery, 0)
-
-	query, _ := utils.NewQuery("test", `{ .testing }`)
-
-	queries = append(queries, query)
-
-	res, _ := utils.QueryValues(vals, queries)
-
-	test := &testType{
-		Value: res["test"],
-	}
-
-	bytes, _ := json.Marshal(test)
-
-	t.Errorf(string(bytes))
-}

+ 134 - 54
server/api/api.go

@@ -1,9 +1,12 @@
 package api
 
 import (
+	"fmt"
+
 	"github.com/go-playground/locales/en"
 	ut "github.com/go-playground/universal-translator"
-	"github.com/go-playground/validator/v10"
+	vr "github.com/go-playground/validator/v10"
+	sessionstore "github.com/porter-dev/porter/internal/auth"
 	"github.com/porter-dev/porter/internal/oauth"
 	"golang.org/x/oauth2"
 	"gorm.io/gorm"
@@ -13,7 +16,11 @@ import (
 	"github.com/porter-dev/porter/internal/kubernetes"
 	lr "github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/repository"
+	memory "github.com/porter-dev/porter/internal/repository/memory"
+	"github.com/porter-dev/porter/internal/validator"
 	"helm.sh/helm/v3/pkg/storage"
+
+	"github.com/porter-dev/porter/internal/config"
 )
 
 // TestAgents are the k8s agents used for testing
@@ -23,75 +30,148 @@ type TestAgents struct {
 	K8sAgent              *kubernetes.Agent
 }
 
+// AppConfig is the configuration required for creating a new App
+type AppConfig struct {
+	DB         *gorm.DB
+	Logger     *lr.Logger
+	Repository *repository.Repository
+	ServerConf config.ServerConf
+
+	// TestAgents if API is in testing mode
+	TestAgents *TestAgents
+}
+
 // App represents an API instance with handler methods attached, a DB connection
 // and a logger instance
 type App struct {
-	db           *gorm.DB
-	logger       *lr.Logger
-	repo         *repository.Repository
-	validator    *validator.Validate
-	store        sessions.Store
-	translator   *ut.Translator
-	cookieName   string
-	testing      bool
-	isLocal      bool
-	TestAgents   *TestAgents
-	GithubConfig *oauth2.Config
+	// Server configuration
+	ServerConf config.ServerConf
+
+	// Logger for logging
+	Logger *lr.Logger
+
+	// Repo implements a query repository
+	Repo *repository.Repository
+
+	// session store for cookie-based sessions
+	Store sessions.Store
+
+	// agents exposed for testing
+	TestAgents *TestAgents
+
+	// oauth-specific clients
+	GithubConf *oauth2.Config
+
+	db         *gorm.DB
+	validator  *vr.Validate
+	translator *ut.Translator
 }
 
 // New returns a new App instance
-// TODO -- this should accept an app/server config
-func New(
-	logger *lr.Logger,
-	db *gorm.DB,
-	repo *repository.Repository,
-	validator *validator.Validate,
-	store sessions.Store,
-	cookieName string,
-	testing bool,
-	isLocal bool,
-	githubConfig *oauth.Config,
-) *App {
-	// for now, will just support the english translator from the
-	// validator/translations package
+func New(conf *AppConfig) (*App, error) {
+	// create a new validator and translator
+	validator := validator.New()
+
 	en := en.New()
 	uni := ut.New(en, en)
-	trans, _ := uni.GetTranslator("en")
+	translator, found := uni.GetTranslator("en")
 
-	var testAgents *TestAgents = nil
+	if !found {
+		return nil, fmt.Errorf("could not find \"en\" translator")
+	}
 
-	if testing {
-		memStorage := helm.StorageMap["memory"](nil, nil, "")
+	app := &App{
+		Logger:     conf.Logger,
+		Repo:       conf.Repository,
+		ServerConf: conf.ServerConf,
+		TestAgents: conf.TestAgents,
+		db:         conf.DB,
+		validator:  validator,
+		translator: &translator,
+	}
 
-		testAgents = &TestAgents{
-			HelmAgent:             helm.GetAgentTesting(&helm.Form{}, nil, logger),
-			HelmTestStorageDriver: memStorage,
-			K8sAgent:              kubernetes.GetAgentTesting(),
-		}
+	// if repository not specified, default to in-memory
+	if app.Repo == nil {
+		app.Repo = memory.NewRepository(true)
 	}
 
-	var oauthGithubConf *oauth2.Config
+	// create the session store
+	store, err := sessionstore.NewStore(app.Repo, app.ServerConf)
 
-	if githubConfig != nil {
-		oauthGithubConf = oauth.NewGithubClient(githubConfig)
+	if err != nil {
+		return nil, err
 	}
 
-	return &App{
-		db:           db,
-		logger:       logger,
-		repo:         repo,
-		validator:    validator,
-		store:        store,
-		translator:   &trans,
-		cookieName:   cookieName,
-		testing:      testing,
-		isLocal:      isLocal,
-		TestAgents:   testAgents,
-		GithubConfig: oauthGithubConf,
+	app.Store = store
+
+	// if server config contains OAuth client info, create clients
+	if sc := conf.ServerConf; sc.GithubClientID != "" && sc.GithubClientSecret != "" {
+		app.GithubConf = oauth.NewGithubClient(&oauth.Config{
+			ClientID:     sc.GithubClientID,
+			ClientSecret: sc.GithubClientSecret,
+			Scopes:       []string{"repo", "user", "read:user"},
+			BaseURL:      sc.ServerURL,
+		})
 	}
-}
 
-// Logger returns the logger instance in use by App
-func (app *App) Logger() *lr.Logger {
-	return app.logger
+	return app, nil
 }
+
+// // New returns a new App instance
+// // TODO -- this should accept an app/server config
+// func New(
+// 	logger *lr.Logger,
+// 	db *gorm.DB,
+// 	repo *repository.Repository,
+// 	validator *validator.Validate,
+// 	store sessions.Store,
+// 	cookieName string,
+// 	testing bool,
+// 	isLocal bool,
+// 	githubConfig *oauth.Config,
+// 	serverConf config.ServerConf,
+// ) *App {
+// 	// for now, will just support the english translator from the
+// 	// validator/translations package
+// 	en := en.New()
+// 	uni := ut.New(en, en)
+// 	trans, _ := uni.GetTranslator("en")
+
+// 	var testAgents *TestAgents = nil
+
+// 	if testing {
+// 		memStorage := helm.StorageMap["memory"](nil, nil, "")
+
+// 		testAgents = &TestAgents{
+// 			HelmAgent:             helm.GetAgentTesting(&helm.Form{}, nil, logger),
+// 			HelmTestStorageDriver: memStorage,
+// 			K8sAgent:              kubernetes.GetAgentTesting(),
+// 		}
+// 	}
+
+// 	var oauthGithubConf *oauth2.Config
+
+// 	if githubConfig != nil {
+// 		oauthGithubConf = oauth.NewGithubClient(githubConfig)
+// 	}
+
+// 	return &App{
+// 		db:           db,
+// 		logger:       logger,
+// 		repo:         repo,
+// 		validator:    validator,
+// 		store:        store,
+// 		translator:   &trans,
+// 		cookieName:   cookieName,
+// 		testing:      testing,
+// 		isLocal:      isLocal,
+// 		TestAgents:   testAgents,
+// 		GithubConfig: oauthGithubConf,
+// 		ServerConf:   serverConf,
+// 	}
+// }
+
+// // Logger returns the logger instance in use by App
+// func (app *App) Logger() *lr.Logger {
+// 	return app.logger
+// }

+ 23 - 23
server/api/cluster_handler.go

@@ -44,14 +44,14 @@ func (app *App) HandleCreateProjectCluster(w http.ResponseWriter, r *http.Reques
 	}
 
 	// handle write to the database
-	cluster, err = app.repo.Cluster.CreateCluster(cluster)
+	cluster, err = app.Repo.Cluster.CreateCluster(cluster)
 
 	if err != nil {
 		app.handleErrorDataWrite(err, w)
 		return
 	}
 
-	app.logger.Info().Msgf("New cluster created: %d", cluster.ID)
+	app.Logger.Info().Msgf("New cluster created: %d", cluster.ID)
 
 	w.WriteHeader(http.StatusCreated)
 
@@ -72,7 +72,7 @@ func (app *App) HandleReadProjectCluster(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	cluster, err := app.repo.Cluster.ReadCluster(uint(id))
+	cluster, err := app.Repo.Cluster.ReadCluster(uint(id))
 
 	if err != nil {
 		app.handleErrorRead(err, ErrProjectDataRead, w)
@@ -98,7 +98,7 @@ func (app *App) HandleListProjectClusters(w http.ResponseWriter, r *http.Request
 		return
 	}
 
-	clusters, err := app.repo.Cluster.ListClustersByProjectID(uint(projID))
+	clusters, err := app.Repo.Cluster.ListClustersByProjectID(uint(projID))
 
 	if err != nil {
 		app.handleErrorRead(err, ErrProjectDataRead, w)
@@ -152,7 +152,7 @@ func (app *App) HandleUpdateProjectCluster(w http.ResponseWriter, r *http.Reques
 	}
 
 	// convert the form to a registry
-	cluster, err := form.ToCluster(app.repo.Cluster)
+	cluster, err := form.ToCluster(app.Repo.Cluster)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -160,7 +160,7 @@ func (app *App) HandleUpdateProjectCluster(w http.ResponseWriter, r *http.Reques
 	}
 
 	// handle write to the database
-	cluster, err = app.repo.Cluster.UpdateCluster(cluster)
+	cluster, err = app.Repo.Cluster.UpdateCluster(cluster)
 
 	if err != nil {
 		app.handleErrorDataWrite(err, w)
@@ -186,14 +186,14 @@ func (app *App) HandleDeleteProjectCluster(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	cluster, err := app.repo.Cluster.ReadCluster(uint(id))
+	cluster, err := app.Repo.Cluster.ReadCluster(uint(id))
 
 	if err != nil {
 		app.handleErrorRead(err, ErrProjectDataRead, w)
 		return
 	}
 
-	err = app.repo.Cluster.DeleteCluster(cluster)
+	err = app.Repo.Cluster.DeleteCluster(cluster)
 
 	if err != nil {
 		app.handleErrorRead(err, ErrProjectDataRead, w)
@@ -230,7 +230,7 @@ func (app *App) HandleCreateProjectClusterCandidates(w http.ResponseWriter, r *h
 	}
 
 	// convert the form to a ClusterCandidate
-	ccs, err := form.ToClusterCandidates(app.isLocal)
+	ccs, err := form.ToClusterCandidates(app.ServerConf.IsLocal)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -239,7 +239,7 @@ func (app *App) HandleCreateProjectClusterCandidates(w http.ResponseWriter, r *h
 
 	extClusters := make([]*models.ClusterCandidateExternal, 0)
 
-	session, err := app.store.Get(r, app.cookieName)
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
 
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -250,20 +250,20 @@ func (app *App) HandleCreateProjectClusterCandidates(w http.ResponseWriter, r *h
 
 	for _, cc := range ccs {
 		// handle write to the database
-		cc, err = app.repo.Cluster.CreateClusterCandidate(cc)
+		cc, err = app.Repo.Cluster.CreateClusterCandidate(cc)
 
 		if err != nil {
 			app.handleErrorDataWrite(err, w)
 			return
 		}
 
-		app.logger.Info().Msgf("New cluster candidate created: %d", cc.ID)
+		app.Logger.Info().Msgf("New cluster candidate created: %d", cc.ID)
 
 		// if the ClusterCandidate does not have any actions to perform, create the Cluster
 		// automatically
 		if len(cc.Resolvers) == 0 {
 			// we query the repo again to get the decrypted version of the cluster candidate
-			cc, err = app.repo.Cluster.ReadClusterCandidate(cc.ID)
+			cc, err = app.Repo.Cluster.ReadClusterCandidate(cc.ID)
 
 			if err != nil {
 				app.handleErrorDataRead(err, w)
@@ -277,28 +277,28 @@ func (app *App) HandleCreateProjectClusterCandidates(w http.ResponseWriter, r *h
 				UserID:             userID,
 			}
 
-			err := clusterForm.ResolveIntegration(*app.repo)
+			err := clusterForm.ResolveIntegration(*app.Repo)
 
 			if err != nil {
 				app.handleErrorDataWrite(err, w)
 				return
 			}
 
-			cluster, err := clusterForm.ResolveCluster(*app.repo)
+			cluster, err := clusterForm.ResolveCluster(*app.Repo)
 
 			if err != nil {
 				app.handleErrorDataWrite(err, w)
 				return
 			}
 
-			cc, err = app.repo.Cluster.UpdateClusterCandidateCreatedClusterID(cc.ID, cluster.ID)
+			cc, err = app.Repo.Cluster.UpdateClusterCandidateCreatedClusterID(cc.ID, cluster.ID)
 
 			if err != nil {
 				app.handleErrorDataWrite(err, w)
 				return
 			}
 
-			app.logger.Info().Msgf("New cluster created: %d", cluster.ID)
+			app.Logger.Info().Msgf("New cluster created: %d", cluster.ID)
 		}
 
 		extClusters = append(extClusters, cc.Externalize())
@@ -322,7 +322,7 @@ func (app *App) HandleListProjectClusterCandidates(w http.ResponseWriter, r *htt
 		return
 	}
 
-	ccs, err := app.repo.Cluster.ListClusterCandidatesByProjectID(uint(projID))
+	ccs, err := app.Repo.Cluster.ListClusterCandidatesByProjectID(uint(projID))
 
 	if err != nil {
 		app.handleErrorRead(err, ErrProjectDataRead, w)
@@ -361,7 +361,7 @@ func (app *App) HandleResolveClusterCandidate(w http.ResponseWriter, r *http.Req
 		return
 	}
 
-	session, err := app.store.Get(r, app.cookieName)
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
 
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -385,28 +385,28 @@ func (app *App) HandleResolveClusterCandidate(w http.ResponseWriter, r *http.Req
 		UserID:             userID,
 	}
 
-	err = clusterResolver.ResolveIntegration(*app.repo)
+	err = clusterResolver.ResolveIntegration(*app.Repo)
 
 	if err != nil {
 		app.handleErrorDataWrite(err, w)
 		return
 	}
 
-	cluster, err := clusterResolver.ResolveCluster(*app.repo)
+	cluster, err := clusterResolver.ResolveCluster(*app.Repo)
 
 	if err != nil {
 		app.handleErrorDataWrite(err, w)
 		return
 	}
 
-	_, err = app.repo.Cluster.UpdateClusterCandidateCreatedClusterID(uint(candID), cluster.ID)
+	_, err = app.Repo.Cluster.UpdateClusterCandidateCreatedClusterID(uint(candID), cluster.ID)
 
 	if err != nil {
 		app.handleErrorDataWrite(err, w)
 		return
 	}
 
-	app.logger.Info().Msgf("New cluster created: %d", cluster.ID)
+	app.Logger.Info().Msgf("New cluster created: %d", cluster.ID)
 
 	clusterExt := cluster.Externalize()
 

+ 1 - 1
server/api/cluster_handler_test.go

@@ -250,7 +250,7 @@ var createProjectClusterCandidatesTests = []*clusterTest{
 					Name:                     "cluster-test",
 					Server:                   "https://10.10.10.10",
 					OIDCIntegrationID:        1,
-					TokenCache:               integrations.TokenCache{},
+					TokenCache:               integrations.ClusterTokenCache{},
 					CertificateAuthorityData: []byte("-----BEGIN CER"),
 				}
 

+ 4 - 4
server/api/deploy_handler.go

@@ -24,7 +24,7 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 	getChartForm := &forms.ChartForm{
 		Name:    name,
 		Version: version,
-		RepoURL: "https://porter-dev.github.io/chart-repo/",
+		RepoURL: app.ServerConf.DefaultHelmRepoURL,
 	}
 
 	// if a repo_url is passed as query param, it will be populated
@@ -37,7 +37,7 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 
 	getChartForm.PopulateRepoURLFromQueryParams(vals)
 
-	chart, err := loader.LoadChart(getChartForm.RepoURL, getChartForm.Name, getChartForm.Version)
+	chart, err := loader.LoadChartPublic(getChartForm.RepoURL, getChartForm.Name, getChartForm.Version)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
@@ -47,7 +47,7 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 	form := &forms.InstallChartTemplateForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				Repo: app.repo,
+				Repo: app.Repo,
 			},
 		},
 		ChartTemplateForm: &forms.ChartTemplateForm{},
@@ -55,7 +55,7 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 
 	form.ReleaseForm.PopulateHelmOptionsFromQueryParams(
 		vals,
-		app.repo.Cluster,
+		app.Repo.Cluster,
 	)
 
 	if err := json.NewDecoder(r.Body).Decode(form); err != nil {

+ 1 - 1
server/api/errors.go

@@ -76,7 +76,7 @@ func (app *App) sendExternalError(
 	respBytes, _ := json.Marshal(errExt)
 	respBody := string(respBytes)
 
-	app.logger.Warn().Err(err).
+	app.Logger.Warn().Err(err).
 		Str("errExt", respBody).
 		Msg("")
 

+ 131 - 0
server/api/helm_repo_handler.go

@@ -0,0 +1,131 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+	"strconv"
+
+	"github.com/porter-dev/porter/internal/helm/repo"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// HandleCreateHelmRepo creates a new helm repo for a project
+func (app *App) HandleCreateHelmRepo(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	form := &forms.CreateHelmRepo{
+		ProjectID: uint(projID),
+	}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// convert the form to a registry
+	hr, err := form.ToHelmRepo()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	hr, err = app.Repo.HelmRepo.CreateHelmRepo(hr)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	app.Logger.Info().Msgf("New helm repo created: %d", hr.ID)
+
+	w.WriteHeader(http.StatusCreated)
+
+	hrExt := hr.Externalize()
+
+	if err := json.NewEncoder(w).Encode(hrExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleListProjectHelmRepos returns a list of helm repos for a project
+func (app *App) HandleListProjectHelmRepos(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	hrs, err := app.Repo.HelmRepo.ListHelmReposByProjectID(uint(projID))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	extHRs := make([]*models.HelmRepoExternal, 0)
+
+	for _, hr := range hrs {
+		extHRs = append(extHRs, hr.Externalize())
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(extHRs); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleListHelmRepoCharts lists the charts for a given linked helm repo
+func (app *App) HandleListHelmRepoCharts(w http.ResponseWriter, r *http.Request) {
+	helmID, err := strconv.ParseUint(chi.URLParam(r, "helm_id"), 0, 64)
+
+	if err != nil || helmID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	hr, err := app.Repo.HelmRepo.ReadHelmRepo(uint(helmID))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	// cast to a registry from registry package
+	_hr := repo.HelmRepo(*hr)
+	hrAPI := &_hr
+
+	charts, err := hrAPI.ListCharts(*app.Repo)
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(charts); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}

+ 157 - 0
server/api/helm_repo_handler_test.go

@@ -0,0 +1,157 @@
+package api_test
+
+import (
+	"encoding/json"
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// ------------------------- TEST TYPES AND MAIN LOOP ------------------------- //
+
+type helmTest struct {
+	initializers []func(t *tester)
+	msg          string
+	method       string
+	endpoint     string
+	body         string
+	expStatus    int
+	expBody      string
+	useCookie    bool
+	validators   []func(c *helmTest, tester *tester, t *testing.T)
+}
+
+func testHelmRepoRequests(t *testing.T, tests []*helmTest, canQuery bool) {
+	for _, c := range tests {
+		// create a new tester
+		tester := newTester(canQuery)
+
+		// if there's an initializer, call it
+		for _, init := range c.initializers {
+			init(tester)
+		}
+
+		req, err := http.NewRequest(
+			c.method,
+			c.endpoint,
+			strings.NewReader(c.body),
+		)
+
+		tester.req = req
+
+		if c.useCookie {
+			req.AddCookie(tester.cookie)
+		}
+
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		tester.execute()
+		rr := tester.rr
+
+		// first, check that the status matches
+		if status := rr.Code; status != c.expStatus {
+			t.Errorf("%s, handler returned wrong status code: got %v want %v",
+				c.msg, status, c.expStatus)
+		}
+
+		// if there's a validator, call it
+		for _, validate := range c.validators {
+			validate(c, tester, t)
+		}
+	}
+}
+
+// ------------------------- TEST FIXTURES AND FUNCTIONS  ------------------------- //
+
+var createHelmRepoTests = []*helmTest{
+	&helmTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+		},
+		msg:       "Create helm repo",
+		method:    "POST",
+		endpoint:  "/api/projects/1/helmrepos",
+		body:      `{"name":"helm-repo-test","basic_integration_id":1,"repo_url":"https://example-repo.com"}`,
+		expStatus: http.StatusCreated,
+		expBody:   `{"id":1,"project_id":1,"name":"helm-repo-test","repo_name":"https://example-repo.com","service":"helm"}`,
+		useCookie: true,
+		validators: []func(c *helmTest, tester *tester, t *testing.T){
+			helmRepoBodyValidator,
+		},
+	},
+}
+
+func TestHandleCreateHelmRepo(t *testing.T) {
+	testHelmRepoRequests(t, createHelmRepoTests, true)
+}
+
+var listHelmReposTest = []*helmTest{
+	&helmTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+			initHelmRepo,
+		},
+		msg:       "List helm repos",
+		method:    "GET",
+		endpoint:  "/api/projects/1/helmrepos",
+		body:      ``,
+		expStatus: http.StatusOK,
+		expBody:   `[{"id":1,"project_id":1,"name":"helm-repo-test","repo_name":"https://example-repo.com","service":"helm"}]`,
+		useCookie: true,
+		validators: []func(c *helmTest, tester *tester, t *testing.T){
+			hrsBodyValidator,
+		},
+	},
+}
+
+func TestHandleListHelmRepos(t *testing.T) {
+	testHelmRepoRequests(t, listHelmReposTest, true)
+}
+
+// ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
+
+func initHelmRepo(tester *tester) {
+	proj, _ := tester.repo.Project.ReadProject(1)
+
+	hr := &models.HelmRepo{
+		Name:                   "helm-repo-test",
+		ProjectID:              proj.Model.ID,
+		RepoURL:                "https://example-repo.com",
+		BasicAuthIntegrationID: 1,
+	}
+
+	tester.repo.HelmRepo.CreateHelmRepo(hr)
+}
+
+func helmRepoBodyValidator(c *helmTest, tester *tester, t *testing.T) {
+	gotBody := &models.HelmRepoExternal{}
+	expBody := &models.HelmRepoExternal{}
+
+	json.Unmarshal(tester.rr.Body.Bytes(), &gotBody)
+	json.Unmarshal([]byte(c.expBody), &expBody)
+
+	if diff := deep.Equal(gotBody, expBody); diff != nil {
+		t.Errorf("handler returned wrong body:\n")
+		t.Error(diff)
+	}
+}
+
+func hrsBodyValidator(c *helmTest, tester *tester, t *testing.T) {
+	gotBody := make([]*models.HelmRepoExternal, 0)
+	expBody := make([]*models.HelmRepoExternal, 0)
+
+	json.Unmarshal(tester.rr.Body.Bytes(), &gotBody)
+	json.Unmarshal([]byte(c.expBody), &expBody)
+
+	if diff := deep.Equal(gotBody, expBody); diff != nil {
+		t.Errorf("handler returned wrong body:\n")
+		t.Error(diff)
+	}
+}

+ 17 - 7
server/api/helpers_test.go

@@ -8,14 +8,15 @@ import (
 
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/config"
+	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/kubernetes"
 	lr "github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/repository"
-	"github.com/porter-dev/porter/internal/repository/test"
+	memory "github.com/porter-dev/porter/internal/repository/memory"
 	"github.com/porter-dev/porter/server/api"
 	"github.com/porter-dev/porter/server/router"
 
 	sessionstore "github.com/porter-dev/porter/internal/auth"
-	vr "github.com/porter-dev/porter/internal/validator"
 )
 
 type tester struct {
@@ -64,6 +65,7 @@ func newTester(canQuery bool) *tester {
 			TimeoutRead:  time.Second * 5,
 			TimeoutWrite: time.Second * 10,
 			TimeoutIdle:  time.Second * 15,
+			IsTesting:    true,
 		},
 		// unimportant here
 		Db: config.DBConf{},
@@ -74,13 +76,21 @@ func newTester(canQuery bool) *tester {
 	}
 
 	logger := lr.NewConsole(appConf.Debug)
-	validator := vr.New()
+	repo := memory.NewRepository(canQuery)
+	store, _ := sessionstore.NewStore(repo, appConf.Server)
 
-	repo := test.NewRepository(canQuery)
+	app, _ := api.New(&api.AppConfig{
+		Logger:     logger,
+		Repository: repo,
+		ServerConf: appConf.Server,
+		TestAgents: &api.TestAgents{
+			HelmAgent:             helm.GetAgentTesting(&helm.Form{}, nil, logger),
+			HelmTestStorageDriver: helm.StorageMap["memory"](nil, nil, ""),
+			K8sAgent:              kubernetes.GetAgentTesting(),
+		},
+	})
 
-	store, _ := sessionstore.NewStore(repo, appConf.Server)
-	app := api.New(logger, nil, repo, validator, store, appConf.Server.CookieName, true, false, nil)
-	r := router.New(app, store, appConf.Server.CookieName, appConf.Server.StaticFilePath, repo)
+	r := router.New(app)
 
 	return &tester{
 		app:    app,

+ 83 - 7
server/api/integration_handler.go

@@ -37,10 +37,23 @@ func (app *App) HandleListRegistryIntegrations(w http.ResponseWriter, r *http.Re
 	}
 }
 
+// HandleListHelmRepoIntegrations lists the Helm repo integrations available to the
+// instance
+func (app *App) HandleListHelmRepoIntegrations(w http.ResponseWriter, r *http.Request) {
+	hrs := ints.PorterHelmRepoIntegrations
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(&hrs); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
 // HandleListRepoIntegrations lists the repo integrations available to the
 // instance
 func (app *App) HandleListRepoIntegrations(w http.ResponseWriter, r *http.Request) {
-	repos := ints.PorterRepoIntegrations
+	repos := ints.PorterGitRepoIntegrations
 
 	w.WriteHeader(http.StatusOK)
 
@@ -52,7 +65,7 @@ func (app *App) HandleListRepoIntegrations(w http.ResponseWriter, r *http.Reques
 
 // HandleCreateGCPIntegration creates a new GCP integration in the DB
 func (app *App) HandleCreateGCPIntegration(w http.ResponseWriter, r *http.Request) {
-	session, err := app.store.Get(r, app.cookieName)
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
 
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -94,14 +107,14 @@ func (app *App) HandleCreateGCPIntegration(w http.ResponseWriter, r *http.Reques
 	}
 
 	// handle write to the database
-	gcp, err = app.repo.GCPIntegration.CreateGCPIntegration(gcp)
+	gcp, err = app.Repo.GCPIntegration.CreateGCPIntegration(gcp)
 
 	if err != nil {
 		app.handleErrorDataWrite(err, w)
 		return
 	}
 
-	app.logger.Info().Msgf("New gcp integration created: %d", gcp.ID)
+	app.Logger.Info().Msgf("New gcp integration created: %d", gcp.ID)
 
 	w.WriteHeader(http.StatusCreated)
 
@@ -115,7 +128,7 @@ func (app *App) HandleCreateGCPIntegration(w http.ResponseWriter, r *http.Reques
 
 // HandleCreateAWSIntegration creates a new AWS integration in the DB
 func (app *App) HandleCreateAWSIntegration(w http.ResponseWriter, r *http.Request) {
-	session, err := app.store.Get(r, app.cookieName)
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
 
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -157,14 +170,14 @@ func (app *App) HandleCreateAWSIntegration(w http.ResponseWriter, r *http.Reques
 	}
 
 	// handle write to the database
-	aws, err = app.repo.AWSIntegration.CreateAWSIntegration(aws)
+	aws, err = app.Repo.AWSIntegration.CreateAWSIntegration(aws)
 
 	if err != nil {
 		app.handleErrorDataWrite(err, w)
 		return
 	}
 
-	app.logger.Info().Msgf("New aws integration created: %d", aws.ID)
+	app.Logger.Info().Msgf("New aws integration created: %d", aws.ID)
 
 	w.WriteHeader(http.StatusCreated)
 
@@ -175,3 +188,66 @@ func (app *App) HandleCreateAWSIntegration(w http.ResponseWriter, r *http.Reques
 		return
 	}
 }
+
+// HandleCreateBasicAuthIntegration creates a new basic auth integration in the DB
+func (app *App) HandleCreateBasicAuthIntegration(w http.ResponseWriter, r *http.Request) {
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	form := &forms.CreateBasicAuthIntegrationForm{
+		UserID:    userID,
+		ProjectID: uint(projID),
+	}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// convert the form to a gcp integration
+	basic, err := form.ToBasicIntegration()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	basic, err = app.Repo.BasicIntegration.CreateBasicIntegration(basic)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	app.Logger.Info().Msgf("New basic integration created: %d", basic.ID)
+
+	w.WriteHeader(http.StatusCreated)
+
+	basicExt := basic.Externalize()
+
+	if err := json.NewEncoder(w).Encode(basicExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}

+ 71 - 1
server/api/integration_handler_test.go

@@ -2,6 +2,7 @@ package api_test
 
 import (
 	"encoding/json"
+	"fmt"
 	"net/http"
 	"strings"
 	"testing"
@@ -112,6 +113,28 @@ func TestHandleListRegistryIntegrations(t *testing.T) {
 	testPublicIntegrationRequests(t, listRegistryIntegrationsTests, true)
 }
 
+var listHelmRepoIntegrationsTest = []*publicIntTest{
+	&publicIntTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+		},
+		msg:       "List Helm repo integrations",
+		method:    "GET",
+		endpoint:  "/api/integrations/helm",
+		body:      ``,
+		expStatus: http.StatusOK,
+		expBody:   `[{"auth_mechanism":"basic","category":"helm","service":"helm"},{"auth_mechanism":"gcp","category":"helm","service":"gcs"},{"auth_mechanism":"aws","category":"helm","service":"s3"}]`,
+		useCookie: true,
+		validators: []func(c *publicIntTest, tester *tester, t *testing.T){
+			publicIntBodyValidator,
+		},
+	},
+}
+
+func TestHandleListHelmRepoIntegrations(t *testing.T) {
+	testPublicIntegrationRequests(t, listHelmRepoIntegrationsTest, true)
+}
+
 var listRepoIntegrationsTests = []*publicIntTest{
 	&publicIntTest{
 		initializers: []func(t *tester){
@@ -186,13 +209,43 @@ func TestHandleCreateAWSIntegration(t *testing.T) {
 	testPublicIntegrationRequests(t, createGCPIntegrationTests, true)
 }
 
+var createBasicIntegrationTests = []*publicIntTest{
+	&publicIntTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+		},
+		msg:      "Create basic integration",
+		method:   "POST",
+		endpoint: "/api/projects/1/integrations/basic",
+		body: `{
+			"username": "username",
+			"password": "password"
+		}`,
+		expStatus: http.StatusCreated,
+		expBody:   `{"id":1,"user_id":1,"project_id":1}`,
+		useCookie: true,
+		validators: []func(c *publicIntTest, tester *tester, t *testing.T){
+			basicIntBodyValidator,
+		},
+	},
+}
+
+func TestHandleCreateBasicIntegration(t *testing.T) {
+	testPublicIntegrationRequests(t, createBasicIntegrationTests, true)
+}
+
 // ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
 
 func publicIntBodyValidator(c *publicIntTest, tester *tester, t *testing.T) {
 	gotBody := make([]*ints.PorterIntegration, 0)
 	expBody := make([]*ints.PorterIntegration, 0)
 
-	json.Unmarshal(tester.rr.Body.Bytes(), &gotBody)
+	bytes := tester.rr.Body.Bytes()
+
+	fmt.Println(string(bytes))
+
+	json.Unmarshal(bytes, &gotBody)
 	json.Unmarshal([]byte(c.expBody), &expBody)
 
 	if diff := deep.Equal(gotBody, expBody); diff != nil {
@@ -226,3 +279,20 @@ func awsIntBodyValidator(c *publicIntTest, tester *tester, t *testing.T) {
 		t.Error(diff)
 	}
 }
+
+func basicIntBodyValidator(c *publicIntTest, tester *tester, t *testing.T) {
+	gotBody := &ints.BasicIntegration{}
+	expBody := &ints.BasicIntegration{}
+
+	bytes := tester.rr.Body.Bytes()
+
+	fmt.Println(string(bytes))
+
+	json.Unmarshal(bytes, &gotBody)
+	json.Unmarshal([]byte(c.expBody), &expBody)
+
+	if diff := deep.Equal(gotBody, expBody); diff != nil {
+		t.Errorf("handler returned wrong body:\n")
+		t.Error(diff)
+	}
+}

+ 15 - 15
server/api/k8s_handler.go

@@ -36,11 +36,11 @@ func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
 	// get the filter options
 	form := &forms.K8sForm{
 		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
-			Repo: app.repo,
+			Repo: app.Repo,
 		},
 	}
 
-	form.PopulateK8sOptionsFromQueryParams(vals, app.repo.Cluster)
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
 
 	// validate the form
 	if err := app.validator.Struct(form); err != nil {
@@ -51,7 +51,7 @@ func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
 	// create a new agent
 	var agent *kubernetes.Agent
 
-	if app.testing {
+	if app.ServerConf.IsTesting {
 		agent = app.TestAgents.K8sAgent
 	} else {
 		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
@@ -75,7 +75,7 @@ func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleGetPodLogs(w http.ResponseWriter, r *http.Request) {
 
 	// get session to retrieve correct kubeconfig
-	_, err := app.store.Get(r, app.cookieName)
+	_, err := app.Store.Get(r, app.ServerConf.CookieName)
 
 	// get path parameters
 	namespace := chi.URLParam(r, "namespace")
@@ -96,11 +96,11 @@ func (app *App) HandleGetPodLogs(w http.ResponseWriter, r *http.Request) {
 	// get the filter options
 	form := &forms.K8sForm{
 		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
-			Repo: app.repo,
+			Repo: app.Repo,
 		},
 	}
 
-	form.PopulateK8sOptionsFromQueryParams(vals, app.repo.Cluster)
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
 
 	// validate the form
 	if err := app.validator.Struct(form); err != nil {
@@ -111,7 +111,7 @@ func (app *App) HandleGetPodLogs(w http.ResponseWriter, r *http.Request) {
 	// create a new agent
 	var agent *kubernetes.Agent
 
-	if app.testing {
+	if app.ServerConf.IsTesting {
 		agent = app.TestAgents.K8sAgent
 	} else {
 		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
@@ -139,7 +139,7 @@ func (app *App) HandleGetPodLogs(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleListPods(w http.ResponseWriter, r *http.Request) {
 
 	// get session to retrieve correct kubeconfig
-	_, err := app.store.Get(r, app.cookieName)
+	_, err := app.Store.Get(r, app.ServerConf.CookieName)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
@@ -156,11 +156,11 @@ func (app *App) HandleListPods(w http.ResponseWriter, r *http.Request) {
 	// get the filter options
 	form := &forms.K8sForm{
 		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
-			Repo: app.repo,
+			Repo: app.Repo,
 		},
 	}
 
-	form.PopulateK8sOptionsFromQueryParams(vals, app.repo.Cluster)
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
 
 	// validate the form
 	if err := app.validator.Struct(form); err != nil {
@@ -171,7 +171,7 @@ func (app *App) HandleListPods(w http.ResponseWriter, r *http.Request) {
 	// create a new agent
 	var agent *kubernetes.Agent
 
-	if app.testing {
+	if app.ServerConf.IsTesting {
 		agent = app.TestAgents.K8sAgent
 	} else {
 		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
@@ -202,7 +202,7 @@ func (app *App) HandleListPods(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleStreamControllerStatus(w http.ResponseWriter, r *http.Request) {
 
 	// get session to retrieve correct kubeconfig
-	_, err := app.store.Get(r, app.cookieName)
+	_, err := app.Store.Get(r, app.ServerConf.CookieName)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
@@ -219,11 +219,11 @@ func (app *App) HandleStreamControllerStatus(w http.ResponseWriter, r *http.Requ
 	// get the filter options
 	form := &forms.K8sForm{
 		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
-			Repo: app.repo,
+			Repo: app.Repo,
 		},
 	}
 
-	form.PopulateK8sOptionsFromQueryParams(vals, app.repo.Cluster)
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
 
 	// validate the form
 	if err := app.validator.Struct(form); err != nil {
@@ -234,7 +234,7 @@ func (app *App) HandleStreamControllerStatus(w http.ResponseWriter, r *http.Requ
 	// create a new agent
 	var agent *kubernetes.Agent
 
-	if app.testing {
+	if app.ServerConf.IsTesting {
 		agent = app.TestAgents.K8sAgent
 	} else {
 		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)

+ 8 - 8
server/api/project_handler.go

@@ -20,7 +20,7 @@ const (
 // HandleCreateProject validates a project form entry, converts the project to a gorm
 // model, and saves the user to the database
 func (app *App) HandleCreateProject(w http.ResponseWriter, r *http.Request) {
-	session, err := app.store.Get(r, app.cookieName)
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
 
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -44,7 +44,7 @@ func (app *App) HandleCreateProject(w http.ResponseWriter, r *http.Request) {
 	}
 
 	// convert the form to a project model
-	projModel, err := form.ToProject(app.repo.Project)
+	projModel, err := form.ToProject(app.Repo.Project)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -52,7 +52,7 @@ func (app *App) HandleCreateProject(w http.ResponseWriter, r *http.Request) {
 	}
 
 	// handle write to the database
-	projModel, err = app.repo.Project.CreateProject(projModel)
+	projModel, err = app.Repo.Project.CreateProject(projModel)
 
 	if err != nil {
 		app.handleErrorDataWrite(err, w)
@@ -60,7 +60,7 @@ func (app *App) HandleCreateProject(w http.ResponseWriter, r *http.Request) {
 	}
 
 	// create a new Role with the user as the admin
-	_, err = app.repo.Project.CreateProjectRole(projModel, &models.Role{
+	_, err = app.Repo.Project.CreateProjectRole(projModel, &models.Role{
 		UserID:    userID,
 		ProjectID: projModel.ID,
 		Kind:      models.RoleAdmin,
@@ -71,7 +71,7 @@ func (app *App) HandleCreateProject(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	app.logger.Info().Msgf("New project created: %d", projModel.ID)
+	app.Logger.Info().Msgf("New project created: %d", projModel.ID)
 
 	w.WriteHeader(http.StatusCreated)
 
@@ -93,7 +93,7 @@ func (app *App) HandleReadProject(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	proj, err := app.repo.Project.ReadProject(uint(id))
+	proj, err := app.Repo.Project.ReadProject(uint(id))
 
 	if err != nil {
 		app.handleErrorRead(err, ErrProjectDataRead, w)
@@ -120,14 +120,14 @@ func (app *App) HandleDeleteProject(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	proj, err := app.repo.Project.ReadProject(uint(id))
+	proj, err := app.Repo.Project.ReadProject(uint(id))
 
 	if err != nil {
 		app.handleErrorRead(err, ErrProjectDataRead, w)
 		return
 	}
 
-	proj, err = app.repo.Project.DeleteProject(proj)
+	proj, err = app.Repo.Project.DeleteProject(proj)
 
 	if err != nil {
 		app.handleErrorRead(err, ErrProjectDataRead, w)

+ 11 - 11
server/api/registry_handler.go

@@ -46,14 +46,14 @@ func (app *App) HandleCreateRegistry(w http.ResponseWriter, r *http.Request) {
 	}
 
 	// handle write to the database
-	registry, err = app.repo.Registry.CreateRegistry(registry)
+	registry, err = app.Repo.Registry.CreateRegistry(registry)
 
 	if err != nil {
 		app.handleErrorDataWrite(err, w)
 		return
 	}
 
-	app.logger.Info().Msgf("New registry created: %d", registry.ID)
+	app.Logger.Info().Msgf("New registry created: %d", registry.ID)
 
 	w.WriteHeader(http.StatusCreated)
 
@@ -74,7 +74,7 @@ func (app *App) HandleListProjectRegistries(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
-	regs, err := app.repo.Registry.ListRegistriesByProjectID(uint(projID))
+	regs, err := app.Repo.Registry.ListRegistriesByProjectID(uint(projID))
 
 	if err != nil {
 		app.handleErrorRead(err, ErrProjectDataRead, w)
@@ -128,7 +128,7 @@ func (app *App) HandleUpdateProjectRegistry(w http.ResponseWriter, r *http.Reque
 	}
 
 	// convert the form to a registry
-	registry, err := form.ToRegistry(app.repo.Registry)
+	registry, err := form.ToRegistry(app.Repo.Registry)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -136,7 +136,7 @@ func (app *App) HandleUpdateProjectRegistry(w http.ResponseWriter, r *http.Reque
 	}
 
 	// handle write to the database
-	registry, err = app.repo.Registry.UpdateRegistry(registry)
+	registry, err = app.Repo.Registry.UpdateRegistry(registry)
 
 	if err != nil {
 		app.handleErrorDataWrite(err, w)
@@ -162,14 +162,14 @@ func (app *App) HandleDeleteProjectRegistry(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
-	reg, err := app.repo.Registry.ReadRegistry(uint(id))
+	reg, err := app.Repo.Registry.ReadRegistry(uint(id))
 
 	if err != nil {
 		app.handleErrorRead(err, ErrProjectDataRead, w)
 		return
 	}
 
-	err = app.repo.Registry.DeleteRegistry(reg)
+	err = app.Repo.Registry.DeleteRegistry(reg)
 
 	if err != nil {
 		app.handleErrorRead(err, ErrProjectDataRead, w)
@@ -188,7 +188,7 @@ func (app *App) HandleListRepositories(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	reg, err := app.repo.Registry.ReadRegistry(uint(regID))
+	reg, err := app.Repo.Registry.ReadRegistry(uint(regID))
 
 	if err != nil {
 		app.handleErrorRead(err, ErrProjectDataRead, w)
@@ -199,7 +199,7 @@ func (app *App) HandleListRepositories(w http.ResponseWriter, r *http.Request) {
 	_reg := registry.Registry(*reg)
 	regAPI := &_reg
 
-	repos, err := regAPI.ListRepositories(*app.repo)
+	repos, err := regAPI.ListRepositories(*app.Repo)
 
 	if err != nil {
 		app.handleErrorRead(err, ErrProjectDataRead, w)
@@ -225,7 +225,7 @@ func (app *App) HandleListImages(w http.ResponseWriter, r *http.Request) {
 
 	repoName := chi.URLParam(r, "*")
 
-	reg, err := app.repo.Registry.ReadRegistry(uint(regID))
+	reg, err := app.Repo.Registry.ReadRegistry(uint(regID))
 
 	if err != nil {
 		app.handleErrorRead(err, ErrProjectDataRead, w)
@@ -236,7 +236,7 @@ func (app *App) HandleListImages(w http.ResponseWriter, r *http.Request) {
 	_reg := registry.Registry(*reg)
 	regAPI := &_reg
 
-	imgs, err := regAPI.ListImages(repoName, *app.repo)
+	imgs, err := regAPI.ListImages(repoName, *app.Repo)
 
 	if err != nil {
 		app.handleErrorRead(err, ErrProjectDataRead, w)

+ 18 - 20
server/api/release_handler.go

@@ -2,7 +2,6 @@ package api
 
 import (
 	"encoding/json"
-	"fmt"
 	"net/http"
 	"net/url"
 	"strconv"
@@ -34,7 +33,7 @@ func (app *App) HandleListReleases(w http.ResponseWriter, r *http.Request) {
 	form := &forms.ListReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				Repo: app.repo,
+				Repo: app.Repo,
 			},
 		},
 		ListFilter: &helm.ListFilter{},
@@ -80,7 +79,7 @@ func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 	form := &forms.GetReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				Repo: app.repo,
+				Repo: app.Repo,
 			},
 		},
 		Name:     name,
@@ -113,7 +112,7 @@ func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 	// get the filter options
 	k8sForm := &forms.K8sForm{
 		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
-			Repo: app.repo,
+			Repo: app.Repo,
 		},
 	}
 
@@ -124,7 +123,7 @@ func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	k8sForm.PopulateK8sOptionsFromQueryParams(vals, app.repo.Cluster)
+	k8sForm.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
 
 	// validate the form
 	if err := app.validator.Struct(k8sForm); err != nil {
@@ -150,8 +149,7 @@ func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 
 	for _, file := range release.Chart.Files {
 		if strings.Contains(file.Name, "form.yaml") {
-			formYAML, err := parser.FormYAMLFromBytes(parserDef, file.Data)
-			fmt.Println("FORM RESULT:", formYAML, err)
+			formYAML, err := parser.FormYAMLFromBytes(parserDef, file.Data, "")
 
 			if err != nil {
 				break
@@ -176,7 +174,7 @@ func (app *App) HandleGetReleaseComponents(w http.ResponseWriter, r *http.Reques
 	form := &forms.GetReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				Repo: app.repo,
+				Repo: app.Repo,
 			},
 		},
 		Name:     name,
@@ -232,7 +230,7 @@ func (app *App) HandleGetReleaseControllers(w http.ResponseWriter, r *http.Reque
 	form := &forms.GetReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				Repo: app.repo,
+				Repo: app.Repo,
 			},
 		},
 		Name:     name,
@@ -272,11 +270,11 @@ func (app *App) HandleGetReleaseControllers(w http.ResponseWriter, r *http.Reque
 	// get the filter options
 	k8sForm := &forms.K8sForm{
 		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
-			Repo: app.repo,
+			Repo: app.Repo,
 		},
 	}
 
-	k8sForm.PopulateK8sOptionsFromQueryParams(vals, app.repo.Cluster)
+	k8sForm.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
 
 	// validate the form
 	if err := app.validator.Struct(k8sForm); err != nil {
@@ -287,7 +285,7 @@ func (app *App) HandleGetReleaseControllers(w http.ResponseWriter, r *http.Reque
 	// create a new kubernetes agent
 	var k8sAgent *kubernetes.Agent
 
-	if app.testing {
+	if app.ServerConf.IsTesting {
 		k8sAgent = app.TestAgents.K8sAgent
 	} else {
 		k8sAgent, err = kubernetes.GetAgentOutOfClusterConfig(k8sForm.OutOfClusterConfig)
@@ -358,7 +356,7 @@ func (app *App) HandleListReleaseHistory(w http.ResponseWriter, r *http.Request)
 	form := &forms.ListReleaseHistoryForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				Repo: app.repo,
+				Repo: app.Repo,
 			},
 		},
 		Name: name,
@@ -407,7 +405,7 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 	form := &forms.UpgradeReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				Repo: app.repo,
+				Repo: app.Repo,
 			},
 		},
 		Name: name,
@@ -415,7 +413,7 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 
 	form.ReleaseForm.PopulateHelmOptionsFromQueryParams(
 		vals,
-		app.repo.Cluster,
+		app.Repo.Cluster,
 	)
 
 	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
@@ -462,7 +460,7 @@ func (app *App) HandleRollbackRelease(w http.ResponseWriter, r *http.Request) {
 	form := &forms.RollbackReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				Repo: app.repo,
+				Repo: app.Repo,
 			},
 		},
 		Name: name,
@@ -470,7 +468,7 @@ func (app *App) HandleRollbackRelease(w http.ResponseWriter, r *http.Request) {
 
 	form.ReleaseForm.PopulateHelmOptionsFromQueryParams(
 		vals,
-		app.repo.Cluster,
+		app.Repo.Cluster,
 	)
 
 	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
@@ -523,7 +521,7 @@ func (app *App) getAgentFromQueryParams(
 	}
 
 	for _, f := range populate {
-		err := f(vals, app.repo.Cluster)
+		err := f(vals, app.Repo.Cluster)
 
 		if err != nil {
 			return nil, err
@@ -551,10 +549,10 @@ func (app *App) getAgentFromReleaseForm(
 	// create a new agent
 	var agent *helm.Agent
 
-	if app.testing {
+	if app.ServerConf.IsTesting {
 		agent = app.TestAgents.HelmAgent
 	} else {
-		agent, err = helm.GetAgentOutOfClusterConfig(form.Form, app.logger)
+		agent, err = helm.GetAgentOutOfClusterConfig(form.Form, app.Logger)
 	}
 
 	return agent, err

+ 5 - 25
server/api/template_handler.go

@@ -2,7 +2,6 @@ package api
 
 import (
 	"encoding/json"
-	"fmt"
 	"net/http"
 	"net/url"
 	"strings"
@@ -19,26 +18,14 @@ import (
 // TODO: test and reduce fragility (handle untar/parse error for individual charts)
 // TODO: separate markdown retrieval into its own query if necessary
 func (app *App) HandleListTemplates(w http.ResponseWriter, r *http.Request) {
-	repoIndex, err := loader.LoadRepoIndex("https://porter-dev.github.io/chart-repo/index.yaml")
+	repoIndex, err := loader.LoadRepoIndexPublic(app.ServerConf.DefaultHelmRepoURL)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
 		return
 	}
 
-	// Loop over charts in index.yaml
-	porterCharts := []models.PorterChartList{}
-
-	for _, entry := range repoIndex.Entries {
-		indexChart := entry[0]
-
-		porterChart := models.PorterChartList{}
-		porterChart.Name = indexChart.Name
-		porterChart.Description = indexChart.Description
-		porterChart.Icon = indexChart.Icon
-
-		porterCharts = append(porterCharts, porterChart)
-	}
+	porterCharts := loader.RepoIndexToPorterChartList(repoIndex)
 
 	json.NewEncoder(w).Encode(porterCharts)
 }
@@ -56,7 +43,7 @@ func (app *App) HandleReadTemplate(w http.ResponseWriter, r *http.Request) {
 	form := &forms.ChartForm{
 		Name:    name,
 		Version: version,
-		RepoURL: "https://porter-dev.github.io/chart-repo/",
+		RepoURL: app.ServerConf.DefaultHelmRepoURL,
 	}
 
 	// if a repo_url is passed as query param, it will be populated
@@ -69,10 +56,9 @@ func (app *App) HandleReadTemplate(w http.ResponseWriter, r *http.Request) {
 
 	form.PopulateRepoURLFromQueryParams(vals)
 
-	chart, err := loader.LoadChart(form.RepoURL, form.Name, form.Version)
+	chart, err := loader.LoadChartPublic(form.RepoURL, form.Name, form.Version)
 
 	if err != nil {
-		fmt.Println("ERROR LOADING CHART", form.RepoURL, form.Name, form.Version, err)
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
 		return
 	}
@@ -87,9 +73,7 @@ func (app *App) HandleReadTemplate(w http.ResponseWriter, r *http.Request) {
 
 	for _, file := range chart.Files {
 		if strings.Contains(file.Name, "form.yaml") {
-			formYAML, err := parser.FormYAMLFromBytes(parserDef, file.Data)
-
-			fmt.Println("FORM RESULT:", formYAML, err)
+			formYAML, err := parser.FormYAMLFromBytes(parserDef, file.Data, "declared")
 
 			if err != nil {
 				break
@@ -101,9 +85,5 @@ func (app *App) HandleReadTemplate(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
-	bytesRes, _ := json.Marshal(res)
-
-	fmt.Println("RAW RESPONSE:", string(bytesRes), res)
-
 	json.NewEncoder(w).Encode(res)
 }

+ 21 - 21
server/api/template_handler_test.go

@@ -68,27 +68,27 @@ func testTemplatesRequests(t *testing.T, tests []*templateTest, canQuery bool) {
 
 // ------------------------- TEST FIXTURES AND FUNCTIONS  ------------------------- //
 
-var listTemplatesTests = []*templateTest{
-	&templateTest{
-		initializers: []func(tester *tester){
-			initUserDefault,
-		},
-		msg:       "List templates",
-		method:    "GET",
-		endpoint:  "/api/templates",
-		body:      "",
-		expStatus: http.StatusOK,
-		expBody:   `[{"name":"Docker","description":"Template to deploy any Docker container on Porter.","icon":"https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png"},{"name":"redis","description":"DEPRECATED Open source, advanced key-value store. It is often referred to as a data structure server since keys can contain strings, hashes, lists, sets and sorted sets.","icon":"https://bitnami.com/assets/stacks/redis/img/redis-stack-220x234.png"}]`,
-		useCookie: true,
-		validators: []func(c *templateTest, tester *tester, t *testing.T){
-			templatesListValidator,
-		},
-	},
-}
-
-func TestHandleListTemplates(t *testing.T) {
-	testTemplatesRequests(t, listTemplatesTests, true)
-}
+// var listTemplatesTests = []*templateTest{
+// 	&templateTest{
+// 		initializers: []func(tester *tester){
+// 			initUserDefault,
+// 		},
+// 		msg:       "List templates",
+// 		method:    "GET",
+// 		endpoint:  "/api/templates",
+// 		body:      "",
+// 		expStatus: http.StatusOK,
+// 		expBody:   `[{"name":"Docker","description":"Template to deploy any Docker container on Porter.","icon":"https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png"},{"name":"redis","description":"DEPRECATED Open source, advanced key-value store. It is often referred to as a data structure server since keys can contain strings, hashes, lists, sets and sorted sets.","icon":"https://bitnami.com/assets/stacks/redis/img/redis-stack-220x234.png"}]`,
+// 		useCookie: true,
+// 		validators: []func(c *templateTest, tester *tester, t *testing.T){
+// 			templatesListValidator,
+// 		},
+// 	},
+// }
+
+// func TestHandleListTemplates(t *testing.T) {
+// 	testTemplatesRequests(t, listTemplatesTests, true)
+// }
 
 // ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
 

+ 14 - 14
server/api/user_handler.go

@@ -27,7 +27,7 @@ const (
 // HandleCreateUser validates a user form entry, converts the user to a gorm
 // model, and saves the user to the database
 func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
-	session, err := app.store.Get(r, app.cookieName)
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
 
 	if err != nil {
 		app.handleErrorDataRead(err, w)
@@ -37,14 +37,14 @@ func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
 
 	user, err := app.writeUser(
 		form,
-		app.repo.User.CreateUser,
+		app.Repo.User.CreateUser,
 		w,
 		r,
 		doesUserExist,
 	)
 
 	if err == nil {
-		app.logger.Info().Msgf("New user created: %d", user.ID)
+		app.Logger.Info().Msgf("New user created: %d", user.ID)
 		session.Values["authenticated"] = true
 		session.Values["user_id"] = user.ID
 		session.Values["email"] = user.Email
@@ -61,7 +61,7 @@ func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
 
 // HandleAuthCheck checks whether current session is authenticated and returns user ID if so.
 func (app *App) HandleAuthCheck(w http.ResponseWriter, r *http.Request) {
-	session, err := app.store.Get(r, app.cookieName)
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
 
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -80,7 +80,7 @@ func (app *App) HandleAuthCheck(w http.ResponseWriter, r *http.Request) {
 
 // HandleLoginUser checks the request header for cookie and validates the user.
 func (app *App) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
-	session, err := app.store.Get(r, app.cookieName)
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
 
 	if err != nil {
 		app.handleErrorDataRead(err, w)
@@ -94,7 +94,7 @@ func (app *App) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	storedUser, readErr := app.repo.User.ReadUserByEmail(form.Email)
+	storedUser, readErr := app.Repo.User.ReadUserByEmail(form.Email)
 
 	if readErr != nil {
 		app.sendExternalError(readErr, http.StatusUnauthorized, HTTPError{
@@ -119,7 +119,7 @@ func (app *App) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
 	session.Values["user_id"] = storedUser.ID
 	session.Values["email"] = storedUser.Email
 	if err := session.Save(r, w); err != nil {
-		app.logger.Warn().Err(err)
+		app.Logger.Warn().Err(err)
 	}
 
 	w.WriteHeader(http.StatusOK)
@@ -132,7 +132,7 @@ func (app *App) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
 
 // HandleLogoutUser detaches the user from the session
 func (app *App) HandleLogoutUser(w http.ResponseWriter, r *http.Request) {
-	session, err := app.store.Get(r, app.cookieName)
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
 
 	if err != nil {
 		app.handleErrorDataRead(err, w)
@@ -174,7 +174,7 @@ func (app *App) HandleListUserProjects(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	projects, err := app.repo.Project.ListProjectsByUserID(uint(id))
+	projects, err := app.Repo.Project.ListProjectsByUserID(uint(id))
 
 	if err != nil {
 		app.handleErrorRead(err, ErrUserDataRead, w)
@@ -208,10 +208,10 @@ func (app *App) HandleDeleteUser(w http.ResponseWriter, r *http.Request) {
 		ID: uint(id),
 	}
 
-	user, err := app.writeUser(form, app.repo.User.DeleteUser, w, r)
+	user, err := app.writeUser(form, app.Repo.User.DeleteUser, w, r)
 
 	if err == nil {
-		app.logger.Info().Msgf("User deleted: %d", user.ID)
+		app.Logger.Info().Msgf("User deleted: %d", user.ID)
 		w.WriteHeader(http.StatusNoContent)
 	}
 }
@@ -241,7 +241,7 @@ func (app *App) writeUser(
 	}
 
 	// convert the form to a user model -- WriteUserForm must implement ToUser
-	userModel, err := form.ToUser(app.repo.User)
+	userModel, err := form.ToUser(app.Repo.User)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
@@ -253,7 +253,7 @@ func (app *App) writeUser(
 	// with http.StatusUnprocessableEntity (422), unless this is
 	// an internal server error
 	for _, validator := range validators {
-		err := validator(app.repo, userModel)
+		err := validator(app.Repo, userModel)
 
 		if err != nil {
 			goErr := errors.New(strings.Join(err.Errors, ", "))
@@ -296,7 +296,7 @@ func (app *App) readUser(w http.ResponseWriter, r *http.Request) (*models.User,
 		return nil, err
 	}
 
-	user, err := app.repo.User.ReadUser(uint(id))
+	user, err := app.Repo.User.ReadUser(uint(id))
 
 	if err != nil {
 		app.handleErrorRead(err, ErrUserDataRead, w)

+ 57 - 12
server/router/router.go

@@ -5,24 +5,18 @@ import (
 	"os"
 
 	"github.com/go-chi/chi"
-	"github.com/gorilla/sessions"
-	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/server/api"
 	"github.com/porter-dev/porter/server/requestlog"
 	mw "github.com/porter-dev/porter/server/router/middleware"
 )
 
-// New creates a new Chi router instance
-func New(
-	a *api.App,
-	store sessions.Store,
-	cookieName string,
-	staticFilePath string,
-	repo *repository.Repository,
-) *chi.Mux {
-	l := a.Logger()
+// New creates a new Chi router instance and registers all routes supported by the
+// API
+func New(a *api.App) *chi.Mux {
+	l := a.Logger
 	r := chi.NewRouter()
-	auth := mw.NewAuth(store, cookieName, repo)
+
+	auth := mw.NewAuth(a.Store, a.ServerConf.CookieName, a.Repo)
 
 	r.Route("/api", func(r chi.Router) {
 		r.Use(mw.ContentTypeJSON)
@@ -104,6 +98,14 @@ func New(
 			),
 		)
 
+		r.Method(
+			"GET",
+			"/integrations/helm",
+			auth.BasicAuthenticate(
+				requestlog.NewHandler(a.HandleListHelmRepoIntegrations, l),
+			),
+		)
+
 		r.Method(
 			"GET",
 			"/integrations/repo",
@@ -290,6 +292,47 @@ func New(
 			),
 		)
 
+		r.Method(
+			"POST",
+			"/projects/{project_id}/integrations/basic",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleCreateBasicAuthIntegration, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
+		// /api/projects/{project_id}/helmrepos routes
+		r.Method(
+			"POST",
+			"/projects/{project_id}/helmrepos",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleCreateHelmRepo, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/helmrepos",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleListProjectHelmRepos, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/helmrepos/{helm_id}/charts",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleListHelmRepoCharts, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
 		// /api/projects/{project_id}/registries routes
 		r.Method(
 			"POST",
@@ -574,6 +617,8 @@ func New(
 		)
 	})
 
+	staticFilePath := a.ServerConf.StaticFilePath
+
 	fs := http.FileServer(http.Dir(staticFilePath))
 
 	r.Get("/*", func(w http.ResponseWriter, r *http.Request) {