Explorar el Código

Merge pull request #37 from porter-dev/api-charts

Api charts
jusrhee hace 5 años
padre
commit
a31cd81b05

+ 1 - 1
cmd/app/main.go

@@ -37,7 +37,7 @@ func main() {
 
 	validator := vr.New()
 
-	a := api.New(logger, repo, validator, store, &appConf.Helm, appConf.Server.CookieName)
+	a := api.New(logger, repo, validator, store, appConf.Server.CookieName, false)
 
 	appRouter := router.New(a, store, appConf.Server.CookieName)
 

+ 1 - 1
dashboard/src/shared/Context.tsx

@@ -51,7 +51,7 @@ class ContextProvider extends Component {
   };
 
   componentDidMount() {
-    this.setState({ userId: 1 });
+    this.setState({ userId: 3 });
   }
 
   render() {

+ 1 - 1
docker/.env

@@ -12,4 +12,4 @@ DB_PASS=porter
 DB_NAME=porter
 COOKIE_SECRETS=secret
 
-QUICK_START=true
+QUICK_START=false

+ 220 - 0
docs/API.md

@@ -13,6 +13,12 @@
   - [`POST /api/logout`](#post-apilogout)
   - [`PUT /api/users/{id}`](#put-apiusersid)
   - [`DELETE /api/users/{id}`](#delete-apiusersid)
+- [`/api/charts`](#apicharts)
+  - [`GET /api/charts`](#get-apicharts)
+  - [`GET /api/charts/{name}/{revision}`](#get-apichartsnamerevision)
+- [`/api/k8s`](#apik8s)
+  - [`GET /api/k8s/namespaces`](#get-apik8snamespaces)
+
 
 ### Overview
 
@@ -392,3 +398,217 @@ User{
     }
     ```
 
+### `/api/charts`
+
+#### `GET /api/charts`
+
+**Description:** Gets a list of charts for a current context and a kubeconfig retrieved from the user's ID. 
+
+**URL parameters:** N/A
+
+**Query parameters:** N/A
+
+**Request Body**:
+
+```js
+{
+  "user_id": Number,
+  "helm": {
+    // The namespace of the cluster to be used
+    "namespace": String,
+    // The name of the context in the kubeconfig being used
+    "context": String,
+    // The Helm storage option to use
+    "storage": String("secret"|"configmap"|"memory")
+  },
+  "filter": {
+    "namespace": String,
+    "limit": Number,
+    "skip": Number,
+    "byDate": Boolean,
+    "statusFilter": []String
+  }
+}
+```
+
+**Successful Response Body**: the full body is determined by the [release specification](https://pkg.go.dev/helm.sh/helm/v3@v3.3.4/pkg/release#Release): listed here is a subset of fields deemed to be most relevant. Note that all of the top-level fields are optional.
+
+```js
+[]Chart{
+  // Name is the name of the release
+  "name": String,
+  "info": Info{
+    // LastDeployed is when the release was last deployed.
+    "last_deployed": String,
+    // Deleted tracks when this object was deleted.
+    "deleted": String,
+    // Description is human-friendly "log entry" about this release.
+    "description": String,
+    // Status is the current state of the release
+    "status": String("unknown"|"deployed"|"uninstalled"|"superseded"|"failed"|"uninstalling"|"pending-install"|"pending-upgrade"|"pending-rollback")
+  },
+  "chart": Chart{
+    "metadata": Metadata{
+      // The name of the chart
+      "name": String,
+      // The URL to a relevant project page, git repo, or contact person
+      "home": String,
+      // Sources is a list of URLs to the source code of this chart
+      "sources": []String,
+      // A SemVer 2 conformant version string of the chart
+      "version": String,
+      // A one-sentence description of the chart
+      "description": String,
+      // The URL to an icon file.
+      "icon": String,
+      // The API Version of this chart.
+      "apiVersion": String,
+    },
+    "templates": []File{
+      // Name is the path-like name of the template.
+      "name": String,
+      // Data is the template as byte data.
+      "data": String
+    },
+    // Values are default config for this chart.
+    "values": Map[String]{}
+  },
+  // The set of extra Values added to the chart, which override the 
+  // default values inside of the chart
+  "config": Map[String]{},
+  // Manifest is the string representation of the rendered template
+  "manifest": String,
+  // Version is an int which represents the revision of the release.
+  "version": Number,
+  // Namespace is the kubernetes namespace of the release.
+  "namespace": String
+}
+```
+
+**Successful Status Code**: `200`
+
+**Errors:** TBD
+
+#### `GET /api/charts/{name}/{revision}`
+
+**Description:** Gets a single chart for a current context and a kubeconfig retrieved from the user's ID based on a **name** and **revision**. To retrieve the latest deployed chart, set **revision** to 0. 
+
+**URL parameters:** 
+
+- `name` The name of the release.
+- `revision` The number of the release (set to `0` for the latest deployed release).
+
+**Query parameters:** N/A
+
+**Request Body**:
+
+```js
+{
+  "user_id": Number,
+  "helm": {
+    // The namespace of the cluster to be used
+    "namespace": String,
+    // The name of the context in the kubeconfig being used
+    "context": String,
+    // The Helm storage option to use
+    "storage": String("secret"|"configmap"|"memory")
+  }
+}
+```
+
+**Successful Response Body**: the full body is determined by the [release specification](https://pkg.go.dev/helm.sh/helm/v3@v3.3.4/pkg/release#Release): listed here is a subset of fields deemed to be most relevant. Note that all of the top-level fields are optional.
+
+```js
+Chart{
+  // Name is the name of the release
+  "name": String,
+  "info": Info{
+    // LastDeployed is when the release was last deployed.
+    "last_deployed": String,
+    // Deleted tracks when this object was deleted.
+    "deleted": String,
+    // Description is human-friendly "log entry" about this release.
+    "description": String,
+    // Status is the current state of the release
+    "status": String("unknown"|"deployed"|"uninstalled"|"superseded"|"failed"|"uninstalling"|"pending-install"|"pending-upgrade"|"pending-rollback")
+  },
+  "chart": Chart{
+    "metadata": Metadata{
+      // The name of the chart
+      "name": String,
+      // The URL to a relevant project page, git repo, or contact person
+      "home": String,
+      // Sources is a list of URLs to the source code of this chart
+      "sources": []String,
+      // A SemVer 2 conformant version string of the chart
+      "version": String,
+      // A one-sentence description of the chart
+      "description": String,
+      // The URL to an icon file.
+      "icon": String,
+      // The API Version of this chart.
+      "apiVersion": String,
+    },
+    "templates": []File{
+      // Name is the path-like name of the template.
+      "name": String,
+      // Data is the template as byte data.
+      "data": String
+    },
+    // Values are default config for this chart.
+    "values": Map[String]{}
+  },
+  // The set of extra Values added to the chart, which override the 
+  // default values inside of the chart
+  "config": Map[String]{},
+  // Manifest is the string representation of the rendered template
+  "manifest": String,
+  // Version is an int which represents the revision of the release.
+  "version": Number,
+  // Namespace is the kubernetes namespace of the release.
+  "namespace": String
+}
+```
+
+**Successful Status Code**: `200`
+
+**Errors:** TBD
+
+### `/api/k8s`
+
+#### `GET /api/k8s/namespaces`
+
+**Description:** 
+
+**URL parameters:** N/A
+
+**Query parameters:** N/A
+
+**Request Body**: 
+
+```js
+{
+  "user_id": Number,
+  "k8s": {
+    // The name of the context in the kubeconfig being used
+    "context": String,
+  }
+}
+```
+
+**Successful Response Body**: the full body is determined by the [namespace specification](https://pkg.go.dev/k8s.io/api/core/v1#NamespaceList), but we're primarily only interested in namespace `name`:
+
+```js
+{
+  "metadata": {},
+  "items": []Namespace{
+    "metadata": {
+      "name": String
+    }
+  }
+}
+```
+
+**Successful Status Code**: `200`
+
+**Errors:** TBD

+ 3 - 0
go.sum

@@ -719,6 +719,7 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
 github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
@@ -818,6 +819,7 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
 github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
@@ -1235,6 +1237,7 @@ gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 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=
 gorm.io/driver/postgres v1.0.2 h1:mB5JjD4QglbCTdMT1aZDxQzHr87XDK1qh0MKIU3P96g=
 gorm.io/driver/postgres v1.0.2/go.mod h1:FvRSYfBI9jEp6ZSjlpS9qNcSjxwYxFc03UOTrHdvvYA=

+ 5 - 8
internal/adapter/gorm.go

@@ -2,8 +2,6 @@ package gorm
 
 import (
 	"fmt"
-	"os"
-	"strconv"
 
 	"github.com/porter-dev/porter/internal/config"
 	"gorm.io/driver/postgres"
@@ -13,6 +11,10 @@ import (
 
 // New returns a new gorm database instance
 func New(conf *config.DBConf) (*gorm.DB, error) {
+	if conf.SQLLite {
+		return gorm.Open(sqlite.Open("./internal/porter.db"), &gorm.Config{})
+	}
+
 	dsn := fmt.Sprintf(
 		"user=%s password=%s port=%d host=%s sslmode=disable",
 		conf.Username,
@@ -21,10 +23,5 @@ func New(conf *config.DBConf) (*gorm.DB, error) {
 		conf.Host,
 	)
 
-	if quickstart, _ := strconv.ParseBool(os.Getenv("QUICK_START")); quickstart {
-		return gorm.Open(sqlite.Open("./internal/porter.db"), &gorm.Config{})
-	} else {
-		return gorm.Open(postgres.Open(dsn), &gorm.Config{})
-	}
-
+	return gorm.Open(postgres.Open(dsn), &gorm.Config{})
 }

+ 6 - 4
internal/config/config.go

@@ -12,7 +12,7 @@ type Conf struct {
 	Debug  bool `env:"DEBUG,default=false"`
 	Server ServerConf
 	Db     DBConf
-	Helm   HelmGlobalConf
+	K8s    K8sConf
 }
 
 // ServerConf is the server configuration
@@ -33,11 +33,13 @@ type DBConf struct {
 	Username string `env:"DB_USER,default=porter"`
 	Password string `env:"DB_PASS,default=porter"`
 	DbName   string `env:"DB_NAME,default=porter"`
+
+	SQLLite bool `env:"QUICK_START,default=false"`
 }
 
-// HelmGlobalConf is the global configuration for the Helm agent
-type HelmGlobalConf struct {
-	IsTesting bool `env:"HELM_IS_TESTING,default=false"`
+// K8sConf is the global configuration for the k8s agents
+type K8sConf struct {
+	IsTesting bool `env:"K8S_IS_TESTING,default=false"`
 }
 
 // FromEnv generates a configuration from environment variables

+ 21 - 9
internal/forms/chart.go

@@ -5,23 +5,35 @@ import (
 	"github.com/porter-dev/porter/internal/repository"
 )
 
-// ListChartForm represents the accepted values for listing Helm charts
-type ListChartForm struct {
-	HelmOptions *helm.Form       `json:"helm" form:"required"`
-	ListFilter  *helm.ListFilter `json:"filter" form:"required"`
-	UserID      uint             `json:"user_id"`
+// ChartForm is the generic base type for CRUD operations on charts
+type ChartForm struct {
+	HelmOptions *helm.Form `json:"helm" form:"required"`
+	UserID      uint       `json:"user_id"`
 }
 
 // PopulateHelmOptions uses the passed user ID to populate the HelmOptions object
-func (lcf *ListChartForm) PopulateHelmOptions(repo repository.UserRepository) error {
-	user, err := repo.ReadUser(lcf.UserID)
+func (cf *ChartForm) PopulateHelmOptions(repo repository.UserRepository) error {
+	user, err := repo.ReadUser(cf.UserID)
 
 	if err != nil {
 		return err
 	}
 
-	lcf.HelmOptions.AllowedContexts = user.ContextToSlice()
+	cf.HelmOptions.AllowedContexts = user.ContextToSlice()
+	cf.HelmOptions.KubeConfig = user.RawKubeConfig
 
-	lcf.HelmOptions.KubeConfig = user.RawKubeConfig
 	return nil
 }
+
+// ListChartForm represents the accepted values for listing Helm charts
+type ListChartForm struct {
+	ChartForm
+	ListFilter *helm.ListFilter `json:"filter" form:"required"`
+}
+
+// GetChartForm represents the accepted values for getting a single Helm chart
+type GetChartForm struct {
+	ChartForm
+	Name     string `json:"name" form:"required"`
+	Revision int    `json:"release"`
+}

+ 26 - 0
internal/forms/k8s.go

@@ -0,0 +1,26 @@
+package forms
+
+import (
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+// K8sForm is the generic base type for CRUD operations on k8s objects
+type K8sForm struct {
+	K8sOptions *kubernetes.OutOfClusterConfig `json:"k8s" form:"required"`
+	UserID     uint                           `json:"user_id"`
+}
+
+// PopulateK8sOptions uses the passed user ID to populate the HelmOptions object
+func (kf *K8sForm) PopulateK8sOptions(repo repository.UserRepository) error {
+	user, err := repo.ReadUser(kf.UserID)
+
+	if err != nil {
+		return err
+	}
+
+	kf.K8sOptions.AllowedContexts = user.ContextToSlice()
+	kf.K8sOptions.KubeConfig = user.RawKubeConfig
+
+	return nil
+}

+ 1 - 1
internal/forms/user.go

@@ -65,7 +65,7 @@ type UpdateUserForm struct {
 	WriteUserForm
 	ID              uint     `form:"required"`
 	RawKubeConfig   string   `json:"rawKubeConfig,omitempty"`
-	AllowedContexts []string `json:"allowedContexts,omitempty"`
+	AllowedContexts []string `json:"allowedContexts"`
 }
 
 // ToUser converts an UpdateUserForm to models.User by parsing the kubeconfig

+ 0 - 50
internal/helm/action_config.go

@@ -1,50 +0,0 @@
-package helm
-
-import (
-	"github.com/porter-dev/porter/internal/logger"
-
-	"helm.sh/helm/v3/pkg/action"
-	"helm.sh/helm/v3/pkg/kube"
-	"k8s.io/cli-runtime/pkg/genericclioptions"
-	"k8s.io/client-go/kubernetes"
-	"k8s.io/client-go/rest"
-)
-
-// NewActionConfig creates an action.Configuration, which can then be used to create Helm 3 actions.
-// Among other things, the action.Configuration controls which namespace the command is run against.
-func NewActionConfig(
-	l *logger.Logger,
-	newStorageDriver NewStorageDriver,
-	config *rest.Config,
-	clientset *kubernetes.Clientset,
-	namespace string,
-) (*action.Configuration, error) {
-	actionConfig := &action.Configuration{}
-	store := newStorageDriver(l, namespace, clientset)
-	restClientGetter := NewConfigFlagsFromCluster(namespace, config)
-	actionConfig.RESTClientGetter = restClientGetter
-	actionConfig.KubeClient = kube.New(restClientGetter)
-	actionConfig.Releases = store
-	actionConfig.Log = l.Printf
-	return actionConfig, nil
-}
-
-// NewConfigFlagsFromCluster returns ConfigFlags with default values set from within cluster.
-func NewConfigFlagsFromCluster(namespace string, clusterConfig *rest.Config) genericclioptions.RESTClientGetter {
-	impersonateGroup := []string{}
-
-	// CertFile and KeyFile must be nil for the BearerToken to be used for authentication and authorization instead of the pod's service account.
-	return &genericclioptions.ConfigFlags{
-		Insecure:         &clusterConfig.TLSClientConfig.Insecure,
-		Timeout:          stringptr("0"),
-		Namespace:        stringptr(namespace),
-		APIServer:        stringptr(clusterConfig.Host),
-		CAFile:           stringptr(clusterConfig.CAFile),
-		BearerToken:      stringptr(clusterConfig.BearerToken),
-		ImpersonateGroup: &impersonateGroup,
-	}
-}
-
-func stringptr(val string) *string {
-	return &val
-}

+ 3 - 80
internal/helm/agent.go

@@ -1,17 +1,8 @@
 package helm
 
 import (
-	"io/ioutil"
-
-	"github.com/porter-dev/porter/internal/config"
-	"github.com/porter-dev/porter/internal/kubernetes"
-	"github.com/porter-dev/porter/internal/logger"
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/release"
-	"helm.sh/helm/v3/pkg/storage"
-
-	"helm.sh/helm/v3/pkg/chartutil"
-	kubefake "helm.sh/helm/v3/pkg/kube/fake"
 )
 
 // Agent is a Helm agent for performing helm operations
@@ -19,77 +10,6 @@ type Agent struct {
 	ActionConfig *action.Configuration
 }
 
-// Form represents the options for connecting to a cluster and
-// creating a Helm agent
-type Form struct {
-	KubeConfig      []byte
-	AllowedContexts []string
-	Context         string `json:"context" form:"required"`
-	Storage         string `json:"storage" form:"oneof=secret configmap memory"`
-	Namespace       string `json:"namespace"`
-}
-
-// ToAgent uses the Form to generate an agent. Setting testing=true will create
-// a test agent with in-memory storage
-func (h *Form) ToAgent(
-	l *logger.Logger,
-	helmConf *config.HelmGlobalConf,
-	storage *storage.Storage,
-) (*Agent, error) {
-	if helmConf.IsTesting {
-		testStorage := storage
-
-		if testStorage == nil {
-			testStorage = StorageMap["memory"](nil, h.Namespace, nil)
-		}
-
-		return &Agent{&action.Configuration{
-			Releases: testStorage,
-			KubeClient: &kubefake.FailingKubeClient{
-				PrintingKubeClient: kubefake.PrintingKubeClient{
-					Out: ioutil.Discard,
-				},
-			},
-			Capabilities: chartutil.DefaultCapabilities,
-			Log:          l.Printf,
-		}}, nil
-	}
-
-	// create a client config using the app's helm/kubernetes agents
-	conf, err := kubernetes.GetRestrictedClientConfigFromBytes(
-		h.KubeConfig,
-		h.Context,
-		h.AllowedContexts,
-	)
-
-	if err != nil {
-		return nil, err
-	}
-
-	restConf, err := conf.ClientConfig()
-
-	clientset, err := kubernetes.GetClientsetFromConfig(conf)
-
-	if err != nil {
-		return nil, err
-	}
-
-	// create a new agent
-	actionConfig, err := NewActionConfig(
-		l,
-		StorageMap[h.Storage],
-		restConf,
-		clientset,
-		h.Namespace,
-	)
-
-	if err != nil {
-		return nil, err
-	}
-
-	return &Agent{actionConfig}, nil
-}
-
 // ListReleases lists releases based on a ListFilter
 func (a *Agent) ListReleases(
 	namespace string,
@@ -105,9 +25,12 @@ func (a *Agent) ListReleases(
 // GetRelease returns the info of a release.
 func (a *Agent) GetRelease(
 	name string,
+	version int,
 ) (*release.Release, error) {
 	// Namespace is already known by the RESTClientGetter.
 	cmd := action.NewGet(a.ActionConfig)
 
+	cmd.Version = version
+
 	return cmd.Run(name)
 }

+ 46 - 7
internal/helm/agent_test.go

@@ -5,7 +5,6 @@ import (
 
 	"helm.sh/helm/v3/pkg/storage/driver"
 
-	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/logger"
 
@@ -17,15 +16,12 @@ func newAgentFixture(t *testing.T, namespace string) *helm.Agent {
 	t.Helper()
 
 	l := logger.NewConsole(true)
-	opts := &helm.Form{
+
+	form := &helm.Form{
 		Namespace: namespace,
 	}
 
-	agent, _ := opts.ToAgent(l, &config.HelmGlobalConf{
-		IsTesting: true,
-	}, nil)
-
-	return agent
+	return helm.GetAgentTesting(form, nil, l)
 }
 
 type releaseStub struct {
@@ -173,3 +169,46 @@ func TestListReleases(t *testing.T) {
 		compareReleaseToStubs(t, releases, tc.expRes)
 	}
 }
+
+type getReleaseTest struct {
+	name       string
+	namespace  string
+	releases   []releaseStub
+	getName    string
+	getVersion int
+	expRes     releaseStub
+}
+
+var getReleaseTests = []getReleaseTest{
+	getReleaseTest{
+		name:      "simple get with revision 0 (latest)",
+		namespace: "default",
+		releases: []releaseStub{
+			releaseStub{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
+			releaseStub{"wordpress", "default", 1, "1.0.1", release.StatusDeployed},
+			releaseStub{"not-in-default-namespace", "other", 1, "1.0.2", release.StatusDeployed},
+		},
+		getName:    "airwatch",
+		getVersion: 0,
+		expRes:     releaseStub{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
+	},
+}
+
+func TestGetReleases(t *testing.T) {
+	for _, tc := range getReleaseTests {
+		agent := newAgentFixture(t, tc.namespace)
+		makeReleases(t, agent, tc.releases)
+
+		// calling agent.ActionConfig.Releases.Create in makeReleases will automatically set the
+		// namespace, so we have to reset the namespace of the storage driver
+		agent.ActionConfig.Releases.Driver.(*driver.Memory).SetNamespace(tc.namespace)
+
+		rel, err := agent.GetRelease(tc.getName, tc.getVersion)
+
+		if err != nil {
+			t.Errorf("%v", err)
+		}
+
+		compareReleaseToStubs(t, []*release.Release{rel}, []releaseStub{tc.expRes})
+	}
+}

+ 101 - 0
internal/helm/config.go

@@ -0,0 +1,101 @@
+package helm
+
+import (
+	"errors"
+	"io/ioutil"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/logger"
+	"helm.sh/helm/v3/pkg/action"
+	"helm.sh/helm/v3/pkg/chartutil"
+	"helm.sh/helm/v3/pkg/kube"
+	kubefake "helm.sh/helm/v3/pkg/kube/fake"
+	"helm.sh/helm/v3/pkg/storage"
+	k8s "k8s.io/client-go/kubernetes"
+)
+
+// Form represents the options for connecting to a cluster and
+// creating a Helm agent
+type Form struct {
+	KubeConfig      []byte
+	AllowedContexts []string
+	Context         string `json:"context" form:"required"`
+	Storage         string `json:"storage" form:"oneof=secret configmap memory"`
+	Namespace       string `json:"namespace"`
+}
+
+// GetAgentOutOfClusterConfig creates a new Agent from outside the cluster using
+// the underlying kubernetes.GetAgentOutOfClusterConfig method
+func GetAgentOutOfClusterConfig(form *Form, l *logger.Logger) (*Agent, error) {
+	// create a kubernetes agent
+	conf := &kubernetes.OutOfClusterConfig{
+		KubeConfig:      form.KubeConfig,
+		AllowedContexts: form.AllowedContexts,
+		Context:         form.Context,
+	}
+
+	k8sAgent, err := kubernetes.GetAgentOutOfClusterConfig(conf)
+
+	if err != nil {
+		return nil, err
+	}
+
+	clientset, ok := k8sAgent.Clientset.(*k8s.Clientset)
+
+	if !ok {
+		return nil, errors.New("Agent Clientset was not of type *(k8s.io/client-go/kubernetes).Clientset")
+	}
+
+	// use k8s agent to create Helm agent
+	return &Agent{&action.Configuration{
+		RESTClientGetter: k8sAgent.RESTClientGetter,
+		KubeClient:       kube.New(k8sAgent.RESTClientGetter),
+		Releases:         StorageMap[form.Storage](l, form.Namespace, clientset),
+		Log:              l.Printf,
+	}}, nil
+}
+
+// GetAgentInClusterConfig creates a new Agent from inside the cluster using
+// the underlying kubernetes.GetAgentInClusterConfig method
+func GetAgentInClusterConfig(form *Form, l *logger.Logger) (*Agent, error) {
+	// create a kubernetes agent
+	k8sAgent, err := kubernetes.GetAgentInClusterConfig()
+
+	if err != nil {
+		return nil, err
+	}
+
+	clientset, ok := k8sAgent.Clientset.(*k8s.Clientset)
+
+	if !ok {
+		return nil, errors.New("Agent Clientset was not of type *(k8s.io/client-go/kubernetes).Clientset")
+	}
+
+	// use k8s agent to create Helm agent
+	return &Agent{&action.Configuration{
+		RESTClientGetter: k8sAgent.RESTClientGetter,
+		KubeClient:       kube.New(k8sAgent.RESTClientGetter),
+		Releases:         StorageMap[form.Storage](l, form.Namespace, clientset),
+		Log:              l.Printf,
+	}}, nil
+}
+
+// GetAgentTesting creates a new Agent using an optional existing storage class
+func GetAgentTesting(form *Form, storage *storage.Storage, l *logger.Logger) *Agent {
+	testStorage := storage
+
+	if testStorage == nil {
+		testStorage = StorageMap["memory"](nil, form.Namespace, nil)
+	}
+
+	return &Agent{&action.Configuration{
+		Releases: testStorage,
+		KubeClient: &kubefake.FailingKubeClient{
+			PrintingKubeClient: kubefake.PrintingKubeClient{
+				Out: ioutil.Discard,
+			},
+		},
+		Capabilities: chartutil.DefaultCapabilities,
+		Log:          l.Printf,
+	}}
+}

+ 4 - 2
internal/helm/driver.go → internal/helm/storage.go

@@ -8,8 +8,10 @@ package helm
 // - memory
 // - postgres
 //
-// This file implements first-class support for each driver type, and integrates with the
-// logger.
+// This file implements first-class support for the first three driver types,
+// and integrates with the logger.
+//
+// TODO -- include support for SQL storage...
 
 import (
 	"github.com/porter-dev/porter/internal/logger"

+ 12 - 6
internal/kubernetes/agent.go

@@ -4,16 +4,22 @@ import (
 	"context"
 
 	v1 "k8s.io/api/core/v1"
-	v1Machinery "k8s.io/apimachinery/pkg/apis/meta/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/cli-runtime/pkg/genericclioptions"
 	"k8s.io/client-go/kubernetes"
 )
 
+// Agent is a Kubernetes agent for performing operations that interact with the
+// api server
+type Agent struct {
+	RESTClientGetter genericclioptions.RESTClientGetter
+	Clientset        kubernetes.Interface
+}
+
 // ListNamespaces simply lists namespaces
-func ListNamespaces(clientset *kubernetes.Clientset) *v1.NamespaceList {
-	namespaces, _ := clientset.CoreV1().Namespaces().List(
+func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
+	return a.Clientset.CoreV1().Namespaces().List(
 		context.TODO(),
-		v1Machinery.ListOptions{},
+		metav1.ListOptions{},
 	)
-
-	return namespaces
 }

+ 65 - 0
internal/kubernetes/agent_test.go

@@ -0,0 +1,65 @@
+package kubernetes_test
+
+import (
+	"testing"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"k8s.io/apimachinery/pkg/api/meta"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/client-go/discovery"
+	"k8s.io/client-go/rest"
+	"k8s.io/client-go/tools/clientcmd"
+
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+type fakeRESTClientGetter struct{}
+
+func (f *fakeRESTClientGetter) ToRESTConfig() (*rest.Config, error) {
+	return nil, nil
+}
+
+func (f *fakeRESTClientGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig {
+	return nil
+}
+
+func (f *fakeRESTClientGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) {
+	return nil, nil
+}
+
+func (f *fakeRESTClientGetter) ToRESTMapper() (meta.RESTMapper, error) {
+	return nil, nil
+}
+
+func newAgentFixture(t *testing.T, objects ...runtime.Object) *kubernetes.Agent {
+	t.Helper()
+
+	return kubernetes.GetAgentTesting(objects...)
+}
+
+func TestOutOfClusterConfig(t *testing.T) {
+	k8sAgent := newAgentFixture(t, &v1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "test-namespace-0",
+		},
+	}, &v1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "test-namespace-1",
+		},
+	})
+
+	namespaces, err := k8sAgent.ListNamespaces()
+
+	if err != nil {
+		t.Fatalf(err.Error())
+	}
+
+	names := []string{"test-namespace-0", "test-namespace-1"}
+
+	for i, ns := range namespaces.Items {
+		if names[i] != ns.Name {
+			t.Errorf("Namespace names do not match: expected %s, got %s\n", names[i], ns.Name)
+		}
+	}
+}

+ 0 - 18
internal/kubernetes/client.go

@@ -1,18 +0,0 @@
-package kubernetes
-
-import (
-	"k8s.io/client-go/kubernetes"
-	"k8s.io/client-go/tools/clientcmd"
-)
-
-// GetClientsetFromConfig is a simple wrapper that returns a *kubernetes.Clientset based on
-// a clientcmd.ClientConfig
-func GetClientsetFromConfig(conf clientcmd.ClientConfig) (*kubernetes.Clientset, error) {
-	clientConf, err := conf.ClientConfig()
-
-	if err != nil {
-		return nil, err
-	}
-
-	return kubernetes.NewForConfig(clientConf)
-}

+ 170 - 0
internal/kubernetes/config.go

@@ -0,0 +1,170 @@
+package kubernetes
+
+import (
+	"path/filepath"
+	"regexp"
+	"strings"
+	"time"
+
+	"k8s.io/apimachinery/pkg/api/meta"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/cli-runtime/pkg/genericclioptions"
+	"k8s.io/client-go/discovery"
+	diskcached "k8s.io/client-go/discovery/cached/disk"
+	"k8s.io/client-go/kubernetes"
+	"k8s.io/client-go/kubernetes/fake"
+	"k8s.io/client-go/rest"
+	"k8s.io/client-go/restmapper"
+	"k8s.io/client-go/tools/clientcmd"
+	"k8s.io/client-go/util/homedir"
+)
+
+// GetAgentOutOfClusterConfig creates a new Agent using the OutOfClusterConfig
+func GetAgentOutOfClusterConfig(conf *OutOfClusterConfig) (*Agent, error) {
+	restConf, err := conf.ToRESTConfig()
+
+	if err != nil {
+		return nil, err
+	}
+
+	clientset, err := kubernetes.NewForConfig(restConf)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &Agent{conf, clientset}, nil
+}
+
+// GetAgentInClusterConfig uses the service account that kubernetes
+// gives to pods to connect
+func GetAgentInClusterConfig() (*Agent, error) {
+	conf, err := rest.InClusterConfig()
+
+	if err != nil {
+		return nil, err
+	}
+
+	restClientGetter := newRESTClientGetterFromInClusterConfig(conf)
+	clientset, err := kubernetes.NewForConfig(conf)
+
+	return &Agent{restClientGetter, clientset}, nil
+}
+
+// GetAgentTesting creates a new Agent using an optional existing storage class
+func GetAgentTesting(objects ...runtime.Object) *Agent {
+	return &Agent{&fakeRESTClientGetter{}, fake.NewSimpleClientset(objects...)}
+}
+
+// OutOfClusterConfig is the set of parameters required for an out-of-cluster connection.
+// This implements RESTClientGetter
+type OutOfClusterConfig struct {
+	KubeConfig      []byte
+	AllowedContexts []string
+	Context         string `json:"context" form:"required"`
+}
+
+// ToRESTConfig creates a kubernetes REST client factory -- it simply calls ClientConfig on
+// the result of ToRawKubeConfigLoader
+func (conf *OutOfClusterConfig) ToRESTConfig() (*rest.Config, error) {
+	restConf, err := conf.ToRawKubeConfigLoader().ClientConfig()
+
+	if err != nil {
+		return nil, err
+	}
+
+	rest.SetKubernetesDefaults(restConf)
+	return restConf, nil
+}
+
+// ToRawKubeConfigLoader creates a clientcmd.ClientConfig from the raw kubeconfig found in
+// the OutOfClusterConfig. It does not implement loading rules or overrides.
+func (conf *OutOfClusterConfig) ToRawKubeConfigLoader() clientcmd.ClientConfig {
+	cmdConf, _ := GetRestrictedClientConfigFromBytes(
+		conf.KubeConfig,
+		conf.Context,
+		conf.AllowedContexts,
+	)
+
+	return cmdConf
+}
+
+// ToDiscoveryClient returns a CachedDiscoveryInterface using a computed RESTConfig
+// It's required to implement the interface genericclioptions.RESTClientGetter
+func (conf *OutOfClusterConfig) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) {
+	// From: k8s.io/cli-runtime/pkg/genericclioptions/config_flags.go > func (*configFlags) ToDiscoveryClient()
+	restConf, err := conf.ToRESTConfig()
+
+	if err != nil {
+		return nil, err
+	}
+
+	restConf.Burst = 100
+	defaultHTTPCacheDir := filepath.Join(homedir.HomeDir(), ".kube", "http-cache")
+
+	// takes the parentDir and the host and comes up with a "usually non-colliding" name for the discoveryCacheDir
+	parentDir := filepath.Join(homedir.HomeDir(), ".kube", "cache", "discovery")
+	// strip the optional scheme from host if its there:
+	schemelessHost := strings.Replace(strings.Replace(restConf.Host, "https://", "", 1), "http://", "", 1)
+	// now do a simple collapse of non-AZ09 characters.  Collisions are possible but unlikely.  Even if we do collide the problem is short lived
+	safeHost := regexp.MustCompile(`[^(\w/\.)]`).ReplaceAllString(schemelessHost, "_")
+	discoveryCacheDir := filepath.Join(parentDir, safeHost)
+
+	return diskcached.NewCachedDiscoveryClientForConfig(restConf, discoveryCacheDir, defaultHTTPCacheDir, time.Duration(10*time.Minute))
+}
+
+// ToRESTMapper returns a mapper
+func (conf *OutOfClusterConfig) ToRESTMapper() (meta.RESTMapper, error) {
+	// From: k8s.io/cli-runtime/pkg/genericclioptions/config_flags.go > func (*configFlags) ToRESTMapper()
+	discoveryClient, err := conf.ToDiscoveryClient()
+	if err != nil {
+		return nil, err
+	}
+
+	mapper := restmapper.NewDeferredDiscoveryRESTMapper(discoveryClient)
+	expander := restmapper.NewShortcutExpander(mapper, discoveryClient)
+	return expander, nil
+}
+
+// newRESTClientGetterFromInClusterConfig returns a RESTClientGetter using
+// default values set from the *rest.Config
+func newRESTClientGetterFromInClusterConfig(conf *rest.Config) genericclioptions.RESTClientGetter {
+	cfs := genericclioptions.NewConfigFlags(false)
+
+	cfs.ClusterName = &conf.ServerName
+	cfs.Insecure = &conf.Insecure
+	cfs.APIServer = &conf.Host
+	cfs.CAFile = &conf.CAFile
+	cfs.KeyFile = &conf.KeyFile
+	cfs.CertFile = &conf.CertFile
+	cfs.BearerToken = &conf.BearerToken
+	cfs.Timeout = stringptr(conf.Timeout.String())
+	cfs.Impersonate = &conf.Impersonate.UserName
+	cfs.ImpersonateGroup = &conf.Impersonate.Groups
+	cfs.Username = &conf.Username
+	cfs.Password = &conf.Password
+
+	return cfs
+}
+
+func stringptr(val string) *string {
+	return &val
+}
+
+type fakeRESTClientGetter struct{}
+
+func (f *fakeRESTClientGetter) ToRESTConfig() (*rest.Config, error) {
+	return nil, nil
+}
+
+func (f *fakeRESTClientGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig {
+	return nil
+}
+
+func (f *fakeRESTClientGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) {
+	return nil, nil
+}
+
+func (f *fakeRESTClientGetter) ToRESTMapper() (meta.RESTMapper, error) {
+	return nil, nil
+}

+ 27 - 8
server/api/api.go

@@ -6,12 +6,20 @@ import (
 	"github.com/go-playground/validator/v10"
 
 	"github.com/gorilla/sessions"
-	"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"
 	"helm.sh/helm/v3/pkg/storage"
 )
 
+// TestAgents are the k8s agents used for testing
+type TestAgents struct {
+	HelmAgent             *helm.Agent
+	HelmTestStorageDriver *storage.Storage
+	K8sAgent              *kubernetes.Agent
+}
+
 // App represents an API instance with handler methods attached, a DB connection
 // and a logger instance
 type App struct {
@@ -20,11 +28,9 @@ type App struct {
 	validator  *validator.Validate
 	store      sessions.Store
 	translator *ut.Translator
-	helmConf   *config.HelmGlobalConf
-	// HelmTestStorageDriver is used by testing libraries to query the in-memory
-	// Helm storage driver
-	HelmTestStorageDriver *storage.Storage
-	cookieName            string
+	cookieName string
+	testing    bool
+	TestAgents *TestAgents
 }
 
 // New returns a new App instance
@@ -33,8 +39,8 @@ func New(
 	repo *repository.Repository,
 	validator *validator.Validate,
 	store sessions.Store,
-	helmConf *config.HelmGlobalConf,
 	cookieName string,
+	testing bool,
 ) *App {
 	// for now, will just support the english translator from the
 	// validator/translations package
@@ -42,14 +48,27 @@ func 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(),
+		}
+	}
+
 	return &App{
 		logger:     logger,
 		repo:       repo,
 		validator:  validator,
 		store:      store,
 		translator: &trans,
-		helmConf:   helmConf,
 		cookieName: cookieName,
+		testing:    testing,
+		TestAgents: testAgents,
 	}
 }
 

+ 64 - 1
server/api/chart_handler.go

@@ -3,8 +3,11 @@ package api
 import (
 	"encoding/json"
 	"net/http"
+	"strconv"
 
+	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/helm"
 )
 
 // Enumeration of chart API error codes, represented as int64
@@ -33,7 +36,14 @@ func (app *App) HandleListCharts(w http.ResponseWriter, r *http.Request) {
 	}
 
 	// create a new agent
-	agent, err := form.HelmOptions.ToAgent(app.logger, app.helmConf, app.HelmTestStorageDriver)
+	var agent *helm.Agent
+	var err error
+
+	if app.testing {
+		agent = app.TestAgents.HelmAgent
+	} else {
+		agent, err = helm.GetAgentOutOfClusterConfig(form.HelmOptions, app.logger)
+	}
 
 	releases, err := agent.ListReleases(form.HelmOptions.Namespace, form.ListFilter)
 
@@ -47,3 +57,56 @@ func (app *App) HandleListCharts(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 }
+
+// HandleGetChart retrieves a single chart based on a name and revision
+func (app *App) HandleGetChart(w http.ResponseWriter, r *http.Request) {
+	name := chi.URLParam(r, "name")
+	revision, err := strconv.ParseUint(chi.URLParam(r, "revision"), 0, 64)
+
+	// decode from JSON to form value
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrChartDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.GetChartForm{
+		Name:     name,
+		Revision: int(revision),
+	}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrChartDecode, w)
+		return
+	}
+
+	form.PopulateHelmOptions(app.repo.User)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrChartValidateFields, w)
+		return
+	}
+
+	// create a new agent
+	var agent *helm.Agent
+
+	if app.testing {
+		agent = app.TestAgents.HelmAgent
+	} else {
+		agent, err = helm.GetAgentOutOfClusterConfig(form.HelmOptions, app.logger)
+	}
+
+	release, err := agent.GetRelease(form.Name, form.Revision)
+
+	if err != nil {
+		app.handleErrorFormValidation(err, ErrChartValidateFields, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(release); err != nil {
+		app.handleErrorFormDecoding(err, ErrChartDecode, w)
+		return
+	}
+}

+ 39 - 41
server/api/chart_handler_test.go

@@ -7,13 +7,10 @@ import (
 	"strings"
 	"testing"
 
-	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/helm"
-	"github.com/porter-dev/porter/internal/logger"
 
 	"helm.sh/helm/v3/pkg/chart"
 	"helm.sh/helm/v3/pkg/release"
-	"helm.sh/helm/v3/pkg/storage"
 	"helm.sh/helm/v3/pkg/storage/driver"
 )
 
@@ -25,28 +22,6 @@ type releaseStub struct {
 	status       release.Status
 }
 
-// type ListFilter struct {
-// 	Namespace    string   `json:"namespace"`
-// 	Limit        int      `json:"limit"`
-// 	Skip         int      `json:"skip"`
-// 	ByDate       bool     `json:"byDate"`
-// 	StatusFilter []string `json:"statusFilter"`
-// }
-
-// type Form struct {
-// 	KubeConfig      []byte   `form:"required"`
-// 	AllowedContexts []string `form:"required"`
-// 	Context         string   `json:"context" form:"required"`
-// 	Storage         string   `json:"storage" form:"oneof=secret configmap memory"`
-// 	Namespace       string   `json:"namespace"`
-// }
-
-// type ListChartForm struct {
-// 	HelmOptions *helm.Form       `json:"helm" form:"required"`
-// 	ListFilter  *helm.ListFilter `json:"filter" form:"required"`
-// 	UserID      uint             `json:"user_id"`
-// }
-
 // ------------------------- TEST TYPES AND MAIN LOOP ------------------------- //
 
 type chartTest struct {
@@ -64,8 +39,7 @@ type chartTest struct {
 func testChartRequests(t *testing.T, tests []*chartTest, canQuery bool) {
 	for _, c := range tests {
 		// create a new tester
-		storage := helm.StorageMap["memory"](nil, "", nil)
-		tester := newTester(canQuery, storage)
+		tester := newTester(canQuery)
 
 		// if there's an initializer, call it
 		for _, init := range c.initializers {
@@ -142,12 +116,41 @@ func TestHandleListCharts(t *testing.T) {
 	testChartRequests(t, listChartsTests, true)
 }
 
+var getChartTests = []*chartTest{
+	&chartTest{
+		initializers: []func(tester *tester){
+			initDefaultCharts,
+		},
+		msg:      "Get charts",
+		method:   "GET",
+		endpoint: "/api/charts/airwatch/0",
+		body: `{
+			"user_id": 1,
+			"helm": {
+				"namespace": "",
+				"context": "context-test",
+				"storage": "memory"
+			}
+		}`,
+		expStatus: http.StatusOK,
+		expBody:   releaseStubToChartJSON(sampleReleaseStubs[0]),
+		useCookie: true,
+		validators: []func(c *chartTest, tester *tester, t *testing.T){
+			chartReleaseBodyValidator,
+		},
+	},
+}
+
+func TestHandleGetChart(t *testing.T) {
+	testChartRequests(t, getChartTests, true)
+}
+
 // ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
 
 func initDefaultCharts(tester *tester) {
 	initUserDefault(tester)
 
-	agent := newAgentFixture("default", tester.app.HelmTestStorageDriver)
+	agent := tester.app.TestAgents.HelmAgent
 
 	makeReleases(agent, sampleReleaseStubs)
 
@@ -156,19 +159,6 @@ func initDefaultCharts(tester *tester) {
 	agent.ActionConfig.Releases.Driver.(*driver.Memory).SetNamespace("")
 }
 
-func newAgentFixture(namespace string, storage *storage.Storage) *helm.Agent {
-	l := logger.NewConsole(true)
-	opts := &helm.Form{
-		Namespace: namespace,
-	}
-
-	agent, _ := opts.ToAgent(l, &config.HelmGlobalConf{
-		IsTesting: true,
-	}, storage)
-
-	return agent
-}
-
 var sampleReleaseStubs = []releaseStub{
 	releaseStub{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
 	releaseStub{"not-in-default-namespace", "other", 1, "1.0.1", release.StatusDeployed},
@@ -189,6 +179,14 @@ func releaseStubsToChartJSON(rels []releaseStub) string {
 	return string(str)
 }
 
+func releaseStubToChartJSON(r releaseStub) string {
+	rel := releaseStubToRelease(r)
+
+	str, _ := json.Marshal(rel)
+
+	return string(str)
+}
+
 func releaseStubToRelease(r releaseStub) *release.Release {
 	return &release.Release{
 		Name:      r.name,

+ 3 - 5
server/api/helpers_test.go

@@ -13,7 +13,6 @@ import (
 	"github.com/porter-dev/porter/internal/repository/test"
 	"github.com/porter-dev/porter/server/api"
 	"github.com/porter-dev/porter/server/router"
-	"helm.sh/helm/v3/pkg/storage"
 
 	sessionstore "github.com/porter-dev/porter/internal/auth"
 	vr "github.com/porter-dev/porter/internal/validator"
@@ -55,7 +54,7 @@ func (t *tester) createUserSession(email string, pw string) {
 	t.reset()
 }
 
-func newTester(canQuery bool, storage *storage.Storage) *tester {
+func newTester(canQuery bool) *tester {
 	appConf := config.Conf{
 		Debug: true,
 		Server: config.ServerConf{
@@ -69,7 +68,7 @@ func newTester(canQuery bool, storage *storage.Storage) *tester {
 		// unimportant here
 		Db: config.DBConf{},
 		// set the helm config to testing
-		Helm: config.HelmGlobalConf{
+		K8s: config.K8sConf{
 			IsTesting: true,
 		},
 	}
@@ -80,8 +79,7 @@ func newTester(canQuery bool, storage *storage.Storage) *tester {
 	repo := test.NewRepository(canQuery)
 
 	store, _ := sessionstore.NewStore(repo, appConf.Server)
-	app := api.New(logger, repo, validator, store, &appConf.Helm, appConf.Server.CookieName)
-	app.HelmTestStorageDriver = storage
+	app := api.New(logger, repo, validator, store, appConf.Server.CookieName, true)
 	r := router.New(app, store, appConf.Server.CookieName)
 
 	return &tester{

+ 57 - 0
server/api/k8s_handler.go

@@ -0,0 +1,57 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
+
+	"github.com/porter-dev/porter/internal/forms"
+)
+
+// Enumeration of k8s API error codes, represented as int64
+const (
+	ErrK8sDecode ErrorCode = iota + 600
+	ErrK8sValidate
+)
+
+// HandleListNamespaces retrieves a list of namespaces
+func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
+	form := &forms.K8sForm{}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
+		return
+	}
+
+	form.PopulateK8sOptions(app.repo.User)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+	var err error
+
+	if app.testing {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.K8sOptions)
+	}
+
+	namespaces, err := agent.ListNamespaces()
+
+	if err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(namespaces); err != nil {
+		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
+		return
+	}
+}

+ 143 - 0
server/api/k8s_handler_test.go

@@ -0,0 +1,143 @@
+package api_test
+
+import (
+	"encoding/json"
+	"net/http"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+)
+
+// ------------------------- TEST TYPES AND MAIN LOOP ------------------------- //
+
+type k8sTest struct {
+	initializers []func(tester *tester)
+	msg          string
+	method       string
+	endpoint     string
+	body         string
+	expStatus    int
+	expBody      string
+	useCookie    bool
+	validators   []func(c *k8sTest, tester *tester, t *testing.T)
+}
+
+func testK8sRequests(t *testing.T, tests []*k8sTest, 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 listNamespacesTests = []*k8sTest{
+	&k8sTest{
+		initializers: []func(tester *tester){
+			initDefaultK8s,
+		},
+		msg:      "List namespaces",
+		method:   "GET",
+		endpoint: "/api/k8s/namespaces",
+		body: `{
+			"user_id": 1,
+			"k8s": {
+				"namespace": "",
+				"context": "context-test"
+			}
+		}`,
+		expStatus: http.StatusOK,
+		expBody:   objectsToJSON(defaultObjects),
+		useCookie: true,
+		validators: []func(c *k8sTest, tester *tester, t *testing.T){
+			k8sNamespaceListValidator,
+		},
+	},
+}
+
+func TestHandleListNamespaces(t *testing.T) {
+	testK8sRequests(t, listNamespacesTests, true)
+}
+
+// ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
+
+var defaultObjects = []runtime.Object{
+	&v1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "test-namespace-0",
+		},
+	},
+	&v1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "test-namespace-1",
+		},
+	},
+}
+
+func initDefaultK8s(tester *tester) {
+	initUserDefault(tester)
+
+	agent := kubernetes.GetAgentTesting(defaultObjects...)
+
+	// overwrite the test agent with new resources
+	tester.app.TestAgents.K8sAgent = agent
+}
+
+func objectsToJSON(objs []runtime.Object) string {
+	str, _ := json.Marshal(objs)
+
+	return string(str)
+}
+
+func k8sNamespaceListValidator(c *k8sTest, tester *tester, t *testing.T) {
+	gotBody := &v1.NamespaceList{}
+	expBody := &[]v1.Namespace{}
+
+	json.Unmarshal(tester.rr.Body.Bytes(), gotBody)
+	json.Unmarshal([]byte(c.expBody), expBody)
+
+	if !reflect.DeepEqual(gotBody.Items, *expBody) {
+		t.Errorf("%s, handler returned wrong body: got %v want %v",
+			c.msg, gotBody.Items, expBody)
+	}
+}

+ 1 - 1
server/api/user_handler_test.go

@@ -29,7 +29,7 @@ type userTest struct {
 func testUserRequests(t *testing.T, tests []*userTest, canQuery bool) {
 	for _, c := range tests {
 		// create a new tester
-		tester := newTester(canQuery, nil)
+		tester := newTester(canQuery)
 
 		// if there's an initializer, call it
 		for _, init := range c.initializers {

+ 1 - 0
server/router/middleware/auth.go

@@ -66,6 +66,7 @@ func (auth *Auth) DoesUserIDMatch(next http.Handler, loc IDLocation) http.Handle
 			form := &bodyID{}
 			body, _ := ioutil.ReadAll(r.Body)
 			err = json.Unmarshal(body, form)
+
 			id = form.UserID
 
 			// need to create a new stream for the body

+ 4 - 0
server/router/router.go

@@ -29,6 +29,10 @@ func New(a *api.App, store sessions.Store, cookieName string) *chi.Mux {
 
 		// /api/charts routes
 		r.Method("GET", "/charts", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleListCharts, l), mw.BodyParam))
+		r.Method("GET", "/charts/{name}/{revision}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleGetChart, l), mw.BodyParam))
+
+		// /api/k8s routes
+		r.Method("GET", "/k8s/namespaces", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleListNamespaces, l), mw.BodyParam))
 	})
 
 	return r