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

Merge branch 'api-users-context' into single-container

sunguroku 5 лет назад
Родитель
Сommit
f7bc41c83e
45 измененных файлов с 4723 добавлено и 8667 удалено
  1. 1 1
      cmd/app/main.go
  2. 2375 7851
      dashboard/package-lock.json
  3. 3 14
      dashboard/package.json
  4. 122 0
      dashboard/src/main/CurrentError.tsx
  5. 6 73
      dashboard/src/main/Main.tsx
  6. 94 0
      dashboard/src/main/home/Toolbar.tsx
  7. 35 41
      dashboard/src/main/home/modals/ClusterConfigModal.tsx
  8. 9 35
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  9. 3 4
      dashboard/src/main/home/sidebar/Drawer.tsx
  10. 11 5
      dashboard/src/main/home/sidebar/Sidebar.tsx
  11. 12 0
      dashboard/src/shared/Context.tsx
  12. 5 10
      dashboard/src/shared/api.tsx
  13. 2 2
      dashboard/src/shared/baseApi.tsx
  14. 3 2
      dashboard/src/shared/types.tsx
  15. 8 58
      docs/API.md
  16. 20 4
      go.mod
  17. 589 9
      go.sum
  18. 2 2
      internal/auth/sessionstore_test.go
  19. 7 1
      internal/config/config.go
  20. 27 0
      internal/forms/chart.go
  21. 44 9
      internal/forms/user.go
  22. 50 0
      internal/helm/action_config.go
  23. 113 0
      internal/helm/agent.go
  24. 175 0
      internal/helm/agent_test.go
  25. 63 0
      internal/helm/driver.go
  26. 48 0
      internal/helm/filter.go
  27. 19 0
      internal/kubernetes/agent.go
  28. 18 0
      internal/kubernetes/client.go
  29. 91 131
      internal/kubernetes/kubeconfig.go
  30. 94 88
      internal/kubernetes/kubeconfig_test.go
  31. 0 39
      internal/models/cluster_configs.go
  32. 0 38
      internal/models/cluster_configs_test.go
  33. 16 0
      internal/models/context.go
  34. 22 15
      internal/models/user.go
  35. 6 13
      internal/models/user_test.go
  36. 1 1
      internal/repository/gorm/user.go
  37. 9 1
      server/api/api.go
  38. 49 0
      server/api/chart_handler.go
  39. 230 0
      server/api/chart_handler_test.go
  40. 2 2
      server/api/errors.go
  41. 96 0
      server/api/helpers_test.go
  42. 11 39
      server/api/user_handler.go
  43. 188 169
      server/api/user_handler_test.go
  44. 34 2
      server/router/middleware/auth.go
  45. 10 8
      server/router/router.go

+ 1 - 1
cmd/app/main.go

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

Разница между файлами не показана из-за своего большого размера
+ 2375 - 7851
dashboard/package-lock.json


+ 3 - 14
dashboard/package.json

@@ -3,17 +3,6 @@
   "version": "0.1.0",
   "private": true,
   "dependencies": {
-    "@testing-library/jest-dom": "^4.2.4",
-    "@testing-library/react": "^9.3.2",
-    "@testing-library/user-event": "^7.1.2",
-    "@types/jest": "^24.0.0",
-    "@types/node": "^12.12.62",
-    "@types/react": "^16.9.49",
-    "@types/react-dom": "^16.9.8",
-    "@types/react-modal": "^3.10.6",
-    "@types/react-router": "^5.1.8",
-    "@types/react-router-dom": "^5.1.5",
-    "@types/styled-components": "^5.1.3",
     "ace-builds": "^1.4.12",
     "axios": "^0.20.0",
     "dotenv": "^8.2.0",
@@ -22,9 +11,7 @@
     "react-dom": "^16.13.1",
     "react-modal": "^3.11.2",
     "react-router-dom": "^5.2.0",
-    "react-scripts": "3.4.3",
-    "styled-components": "^5.2.0",
-    "typescript": "~3.7.2"
+    "styled-components": "^5.2.0"
   },
   "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1",
@@ -40,6 +27,8 @@
     "@types/react": "^16.9.49",
     "@types/react-dom": "^16.9.8",
     "@types/react-modal": "^3.10.6",
+    "@types/react-router": "^5.1.8",
+    "@types/react-router-dom": "^5.1.5",
     "@types/styled-components": "^5.1.3",
     "file-loader": "^6.1.0",
     "html-webpack-plugin": "^4.5.0",

+ 122 - 0
dashboard/src/main/CurrentError.tsx

@@ -0,0 +1,122 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import close from '../assets/close.png';
+
+import { Context } from '../shared/Context';
+
+type PropsType = {
+};
+
+type StateType = {
+};
+
+export default class CurrentError extends Component<PropsType, StateType> {
+  state = {
+    expanded: false
+  }
+  
+  render() {
+    if (this.context.currentError) {
+      if (!this.state.expanded) {
+        return (
+          <StyledCurrentError onClick={() => this.setState({ expanded: true })}>
+            <ErrorText>Error: {this.context.currentError}</ErrorText>
+            <CloseButton onClick={(e) => {
+              this.context.setCurrentError(null);
+              e.stopPropagation();
+            }}>
+              <CloseButtonImg src={close} />
+            </CloseButton>
+          </StyledCurrentError>
+        );
+      }
+
+      return (
+        <ExpandedError onClick={() => this.setState({ expanded: false })}>
+          Error: {this.context.currentError}
+          <CloseButtonAlt onClick={() => this.context.setCurrentError(null)}>
+            <CloseButtonImg src={close} />
+          </CloseButtonAlt>
+        </ExpandedError>
+      );
+    }
+
+    return null;
+  }
+}
+
+CurrentError.contextType = Context;
+
+const CloseButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 30px;
+  height: 30px;
+  border-radius: 50%;
+  margin-left: 10px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonAlt = styled(CloseButton)`
+  position: absolute;
+  top: 5px;
+  right: 5px;
+`;
+
+const CloseButtonImg = styled.img`
+  width: 10px;
+`;
+
+const ErrorText = styled.div`
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  width: calc(100% - 50px);
+`;
+
+const StyledCurrentError = styled.div`
+  position: fixed;
+  bottom: 20px;
+  width: 300px;
+  left: 17px;
+  padding: 15px;
+  padding-right: 0px;
+  font-family: 'Work Sans', sans-serif;
+  height: 50px;
+  font-size: 13px;
+  border-radius: 3px;
+  background: #383842dd;
+  border: 1px solid #ffffff55;
+  display: flex;
+  align-items: center;
+  color: #FFDB8C;
+
+  > i {
+    font-size: 18px;
+    margin-right: 10px;
+  }
+
+  animation: floatIn 0.5s;
+  animation-fill-mode: forwards;
+
+  @keyframes floatIn {
+    from {
+      opacity: 0; transform: translateY(20px);
+    }
+    to {
+      opacity: 1; transform: translateY(0px);
+    }
+  }
+`;
+
+const ExpandedError = styled(StyledCurrentError)`
+  width: 500px;
+  height: auto;
+  max-height: 300px;
+  padding: 20px;
+  overflow-y: auto;
+`;

+ 6 - 73
dashboard/src/main/Main.tsx

@@ -7,6 +7,7 @@ import { Context } from '../shared/Context';
 
 import Login from './Login';
 import Register from './Register';
+import CurrentError from './CurrentError';
 import Home from './home/Home';
 import { BrowserRouter, Route, Redirect, Switch } from 'react-router-dom';
 
@@ -42,18 +43,9 @@ export default class Main extends Component<PropsType, StateType> {
     this.setState({isLoggedIn: true, initialized: true});
     localStorage.setItem('init', 'true');
   }
-
-  renderCurrentError = (): JSX.Element | undefined => {
-    if (this.context.currentError) {
-      return (
-        <CurrentError>
-          <ErrorText>Error: {this.context.currentError}</ErrorText>
-          <CloseButton onClick={() => { this.context.setCurrentError(null) }}>
-            <CloseButtonImg src={close} />
-          </CloseButton>
-        </CurrentError>
-      );
-    }
+  
+  authenticate = () => {
+    this.setState({ isLoggedIn: true, initialized: true });
   }
 
   render() {
@@ -65,7 +57,7 @@ export default class Main extends Component<PropsType, StateType> {
           <Switch>
             <Route path='/login' render={() => {
               if (!this.state.isLoggedIn && this.state.initialized) {
-                return <Login authenticate={() => this.setState({ isLoggedIn: true, initialized: true })} />
+                return <Login authenticate={this.authenticate} />
               } else {
                 return <Redirect to='/' />
               }
@@ -98,7 +90,7 @@ export default class Main extends Component<PropsType, StateType> {
             }}/>
           </Switch>
         </BrowserRouter>
-        {this.renderCurrentError()}
+        <CurrentError />
       </StyledMain>
     );
   }
@@ -106,71 +98,12 @@ export default class Main extends Component<PropsType, StateType> {
 
 Main.contextType = Context;
 
-const CloseButton = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  width: 30px;
-  height: 30px;
-  border-radius: 50%;
-  margin-left: 10px;
-  cursor: pointer;
-  :hover {
-    background-color: #ffffff11;
-  }
-`;
-
-const CloseButtonImg = styled.img`
-  width: 10px;
-`;
-
 const GlobalStyle = createGlobalStyle`
   * {
     box-sizing: border-box;
   }
 `;
 
-const ErrorText = styled.div`
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  width: calc(100% - 50px);
-`;
-
-const CurrentError = styled.div`
-  position: fixed;
-  bottom: 20px;
-  width: 300px;
-  left: 17px;
-  padding: 15px;
-  padding-right: 0px;
-  font-family: 'Work Sans', sans-serif;
-  height: 50px;
-  font-size: 13px;
-  border-radius: 3px;
-  background: #383842dd;
-  border: 1px solid #ffffff55;
-  display: flex;
-  align-items: center;
-
-  > i {
-    font-size: 18px;
-    margin-right: 10px;
-  }
-
-  animation: floatIn 0.5s;
-  animation-fill-mode: forwards;
-
-  @keyframes floatIn {
-    from {
-      opacity: 0; transform: translateY(20px);
-    }
-    to {
-      opacity: 1; transform: translateY(0px);
-    }
-  }
-`;
-
 const StyledMain = styled.div`
   height: 100vh;
   width: 100vw;

+ 94 - 0
dashboard/src/main/home/Toolbar.tsx

@@ -0,0 +1,94 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import ReactModal from 'react-modal';
+
+import { Context } from '../../shared/Context';
+
+import Sidebar from './sidebar/Sidebar';
+import ClusterConfigModal from './modals/ClusterConfigModal';
+
+type PropsType = {
+  logOut: () => void
+};
+
+type StateType = {
+};
+
+export default class Home extends Component<PropsType, StateType> {
+  render() {
+    return (
+      <StyledHome>
+        <ReactModal
+          isOpen={this.context.currentModal === 'ClusterConfigModal'}
+          onRequestClose={() => this.context.setCurrentModal(null)}
+          style={MediumModalStyles}
+          ariaHideApp={false}
+        >
+          <ClusterConfigModal />
+        </ReactModal>
+
+        <Sidebar logOut={this.props.logOut} />
+        <DummyDashboard>
+          🏗️🏗️🏗️🏗️🏗️
+        </DummyDashboard>
+      </StyledHome>
+    );
+  }
+}
+
+Home.contextType = Context;
+
+const MediumModalStyles = {
+  overlay: {
+    backgroundColor: 'rgba(0,0,0,0.6)',
+    zIndex: 2,
+  },
+  content: {
+    borderRadius: '7px',
+    border: 0,
+    width: '760px',
+    maxWidth: '80vw',
+    margin: '0 auto',
+    height: '575px',
+    top: 'calc(50% - 289px)',
+    backgroundColor: '#24272a',
+    animation: 'floatInModal 0.5s 0s',
+    overflow: 'visible',
+  },
+};
+
+const DummyDashboard = styled.div`
+  height: 100%;
+  width: 100vw;
+  font-family: 'Work Sans', sans-serif;
+  overflow-y: auto;
+  display: flex;
+  letter-spacing: 10px;
+  flex: 1;
+  justify-content: center;
+  padding-bottom: 30px;
+  align-items: center;
+  background: ${props => props.theme.bg};
+  position: relative;
+`;
+
+const StyledHome = styled.div`
+  width: 100vw;
+  height: 100vh;
+  position: fixed;
+  top: 0;
+  left: 0;
+  margin: 0;
+  user-select: none;
+  display: flex;
+  justify-content: center;
+
+  @keyframes floatInModal {
+    from {
+      opacity: 0; transform: translateY(30px);
+    }
+    to {
+      opacity: 1; transform: translateY(0px);
+    }
+  }
+`;

+ 35 - 41
dashboard/src/main/home/modals/ClusterConfigModal.tsx

@@ -4,7 +4,7 @@ import close from '../../../assets/close.png';
 
 import api from '../../../shared/api';
 import { Context } from '../../../shared/Context';
-import { ClusterConfig } from '../../../shared/types';
+import { KubeContextConfig } from '../../../shared/types';
 
 import YamlEditor from '../../../components/YamlEditor';
 import SaveButton from '../../../components/SaveButton';
@@ -14,8 +14,7 @@ type PropsType = {
 
 type StateType = {
   currentTab: string,
-  clusters: ClusterConfig[],
-  selected: boolean[],
+  kubeContexts: KubeContextConfig[],
   rawKubeconfig: string,
   saveKubeconfigStatus: string | null,
   saveSelectedStatus: string | null
@@ -24,44 +23,33 @@ type StateType = {
 export default class ClusterConfigModal extends Component<PropsType, StateType> {
   state = {
     currentTab: 'kubeconfig',
-    clusters: [] as ClusterConfig[],
-    selected: [] as boolean[],
+    kubeContexts: [] as KubeContextConfig[],
     rawKubeconfig: '# If you are using certificate files, include those explicitly',
     saveKubeconfigStatus: null as (string | null),
     saveSelectedStatus: null as (string | null),
   };
   
   updateChecklist = () => {
-    let { setCurrentError } = this.context;
+    let { setCurrentError, userId } = this.context;
 
     // Parse kubeconfig to retrieve all possible clusters
-    api.getAllClusters('<token>', {}, { id: 0 }, (err: any, res: any) => {
+    api.getContexts('<token>', {}, { id: userId }, (err: any, res: any) => {
       if (err) {
-        setCurrentError(JSON.stringify(err));
+        setCurrentError('getAllClusters: ' + JSON.stringify(err));
       } else {
-        let clusters = res.data.clusters;
-        this.setState({ clusters });
-
-        // Check against list of connected clusters
-        api.getClusters('<token>', {}, { id: 0 }, (err: any, res: any) => {
-          if (err) {
-            setCurrentError(JSON.stringify(err));
-          } else {
-            let selected = clusters.map((x: ClusterConfig) => res.data.clusters.includes(x));
-            this.setState({ selected });
-          }
-        });
+        console.log(res.data)
+        this.setState({ kubeContexts: res.data })
       }
     });
   }
 
   componentDidMount() {
-    let { setCurrentError } = this.context;
+    let { setCurrentError, userId } = this.context;
 
-    api.getUser('<token>', {}, { id: 0 }, (err: any, res: any) => {      
+    api.getUser('<token>', {}, { id: userId }, (err: any, res: any) => {
       if (err) {
         setCurrentError(JSON.stringify(err));
-      } else {
+      } else if (res.data.rawKubeConfig !== '') {
         this.setState({ rawKubeconfig: res.data.rawKubeConfig });
       }
     });
@@ -76,20 +64,22 @@ export default class ClusterConfigModal extends Component<PropsType, StateType>
   };
 
   toggleCluster = (i: number): void => {
-    let newSelected = this.state.selected;
-    newSelected[i] = !this.state.selected[i];
-    this.setState({ selected: newSelected });
+    let newKubeContexts = this.state.kubeContexts;
+    newKubeContexts[i].selected = !newKubeContexts[i].selected;
+    this.setState({ kubeContexts: newKubeContexts });
   };
 
   renderClusterList = (): JSX.Element[] | JSX.Element => {
-    if (this.state.clusters.length > 0) {
-      return this.state.clusters.map((cluster: ClusterConfig, i) => {
+    let { kubeContexts } = this.state;
+
+    if (kubeContexts && kubeContexts.length > 0) {
+      return kubeContexts.map((kubeContext: KubeContextConfig, i) => {
         return (
           <Row key={i} onClick={() => this.toggleCluster(i)}>
-            <Checkbox checked={this.state.selected[i]}>
+            <Checkbox checked={kubeContext.selected}>
               <i className="material-icons">done</i>
             </Checkbox>
-            {cluster.name}
+            {kubeContext.name}
           </Row>
         );
       })
@@ -108,12 +98,13 @@ export default class ClusterConfigModal extends Component<PropsType, StateType>
 
   handleSaveKubeconfig = () => {
     let { rawKubeconfig } = this.state;
+    let { userId } = this.context;
 
     this.setState({ saveKubeconfigStatus: 'loading' });
     api.updateUser(
       '<token>',
-      { rawKubeconfig },
-      { id: 0 },
+      { rawKubeConfig: rawKubeconfig },
+      { id: userId },
       (err: any, res: any) => {
         if (err) {
           this.setState({ saveKubeconfigStatus: 'error' });
@@ -130,21 +121,23 @@ export default class ClusterConfigModal extends Component<PropsType, StateType>
   }
 
   handleSaveSelected = () => {
-    let { clusters, selected } = this.state;
+    let { kubeContexts } = this.state;
+    let { userId } = this.context;
 
     this.setState({ saveSelectedStatus: 'loading' });
-
-    let allowedClusters: string[] = [];
-    clusters.forEach((x, i) => {
-      if (selected[i]) {
-        allowedClusters.push(x.name);
+    let allowedContexts: string[] = [];
+    kubeContexts.forEach((x, i) => {
+      if (x.selected) {
+        allowedContexts.push(x.name);
       }
     });
 
+    console.log(allowedContexts);
+    
     api.updateUser(
       '<token>',
-      { allowedClusters },
-      { id: 0 },
+      { allowedContexts },
+      { id: userId },
       (err: any, res: any) => {
         if (err) {
           this.setState({ saveSelectedStatus: 'error' });
@@ -181,8 +174,9 @@ export default class ClusterConfigModal extends Component<PropsType, StateType>
         </ClusterList>
         <SaveButton
           text='Save Selected'
-          disabled={this.state.clusters.length === 0}
+          disabled={this.state.kubeContexts.length === 0}
           onClick={this.handleSaveSelected}
+          status={this.state.saveSelectedStatus}
         />
       </div>
     )

+ 9 - 35
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -4,7 +4,6 @@ import drawerBg from '../../../assets/drawer-bg.png';
 
 import api from '../../../shared/api';
 import { Context } from '../../../shared/Context';
-import { ClusterConfig } from '../../../shared/types';
 
 import Drawer from './Drawer';
 
@@ -17,31 +16,10 @@ type StateType = {
   configExists: boolean,
   showDrawer: boolean,
   initializedDrawer: boolean,
-  clusters: any[],
+  kubeContexts: string[],
   activeIndex: number,
 };
 
-const dummyClusters: ClusterConfig[]  = [
-  { 
-    name: 'happy-ol-trees', 
-    server: 'idc',
-    context: 'idk',
-    user: 'jusrhee'
-  },
-  { 
-    name: 'joyous-petite-rocks', 
-    server: 'idc',
-    context: 'idk',
-    user: 'jusrhee'
-  },
-  { 
-    name: 'friendly-small-bush', 
-    server: 'idc',
-    context: 'idk',
-    user: 'jusrhee'
-  }
-];
-
 export default class ClusterSection extends Component<PropsType, StateType> {
 
   // Need to track initialized for animation mounting
@@ -49,22 +27,18 @@ export default class ClusterSection extends Component<PropsType, StateType> {
     configExists: true,
     showDrawer: false,
     initializedDrawer: false,
-    clusters: [] as ClusterConfig[],
+    kubeContexts: [] as string[],
     activeIndex: 0,
   };
 
   componentDidMount() {
-    console.log(process.env.API_SERVER);
-    // TODO: remove
-    // this.setState({ clusters: dummyClusters });
-
-    let { setCurrentError } = this.context;
+    let { setCurrentError, userId } = this.context;
 
-    api.getClusters('<token>', {}, { id: 0 }, (err: any, res: any) => {      
+    api.getContexts('<token>', {}, { id: userId }, (err: any, res: any) => {      
       if (err) {
         setCurrentError(JSON.stringify(err));
       } else {
-        this.setState({ clusters: res.data.clusters });
+        this.setState({ kubeContexts: res });
       }
     });
   }
@@ -92,7 +66,7 @@ export default class ClusterSection extends Component<PropsType, StateType> {
         <Drawer
           toggleDrawer={this.toggleDrawer}
           showDrawer={this.state.showDrawer}
-          clusters={this.state.clusters}
+          kubeContexts={this.state.kubeContexts}
           activeIndex={this.state.activeIndex}
           setActiveIndex={(i: number): void => this.setState({ activeIndex: i })}
         />
@@ -101,14 +75,14 @@ export default class ClusterSection extends Component<PropsType, StateType> {
   };
 
   renderContents = (): JSX.Element => {
-    let { clusters, activeIndex, showDrawer } = this.state;
+    let { kubeContexts, activeIndex, showDrawer } = this.state;
 
-    if (clusters.length > 0) {
+    if (kubeContexts.length > 0) {
       return (
         <ClusterSelector showDrawer={showDrawer}>
           <LinkWrapper>
             <ClusterIcon><i className="material-icons">polymer</i></ClusterIcon>
-            <ClusterName>{clusters[activeIndex].name}</ClusterName>
+            <ClusterName>{kubeContexts[activeIndex]}</ClusterName>
           </LinkWrapper>
           <DrawerButton onClick={this.toggleDrawer}>
             <BgAccent src={drawerBg} />

+ 3 - 4
dashboard/src/main/home/sidebar/Drawer.tsx

@@ -3,12 +3,11 @@ import styled from 'styled-components';
 import close from '../../../assets/close.png';
 
 import { Context } from '../../../shared/Context';
-import { ClusterConfig } from '../../../shared/types';
 
 type PropsType = {
   toggleDrawer: () => void,
   showDrawer: boolean,
-  clusters: ClusterConfig[],
+  kubeContexts: string[],
   activeIndex: number,
   setActiveIndex: (i: number) => void
 };
@@ -19,7 +18,7 @@ type StateType = {
 export default class Drawer extends Component<PropsType, StateType> {
 
   renderClusterList = (): JSX.Element[] => {
-    return this.props.clusters.map((cluster, i) => {
+    return this.props.kubeContexts.map((kubeContext: string, i: number) => {
       /*
       let active = this.context.activeProject &&
         this.context.activeProject.namespace == val.namespace; 
@@ -32,7 +31,7 @@ export default class Drawer extends Component<PropsType, StateType> {
           onClick={() => this.props.setActiveIndex(i)}
         >
           <ClusterIcon><i className="material-icons">polymer</i></ClusterIcon>
-          <ClusterName>{cluster.name}</ClusterName>
+          <ClusterName>{kubeContext}</ClusterName>
         </ClusterOption>
       );
     });

+ 11 - 5
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -117,9 +117,11 @@ export default class Sidebar extends Component<PropsType, StateType> {
             releaseDrawer={() => this.setState({ forceCloseDrawer: false })}
           />
 
-          <LogOutButton onClick={this.handleLogout}>
-            Log Out <i className="material-icons">keyboard_return</i>
-          </LogOutButton>
+          <BottomSection>
+            <LogOutButton onClick={this.handleLogout}>
+              Log Out <i className="material-icons">keyboard_return</i>
+            </LogOutButton>
+          </BottomSection>
         </StyledSidebar>
       </div>
     );
@@ -160,10 +162,14 @@ const NavButton = styled.div`
   }
 `;
 
-const LogOutButton = styled(NavButton)`
+const BottomSection = styled.div`
   position: absolute;
-  width: calc(100% - 55px); 
+  width: 100%;
   bottom: 12px;
+`;
+
+const LogOutButton = styled(NavButton)`
+  width: calc(100% - 55px); 
   border-top-right-radius: 3px;
   border-bottom-right-radius: 3px;
   margin-left: -1px;

+ 12 - 0
dashboard/src/shared/Context.tsx

@@ -36,9 +36,21 @@ class ContextProvider extends Component {
     currentCluster: null as string | null,
     setCurrentCluster: (currentCluster: string): void => {
       this.setState({ currentCluster });
+    },
+    userId: null as number | null,
+    setUserId: (userId: number): void => {
+      this.setState({ userId });
+    },
+    devOpsMode: true,
+    setDevOpsMode: (devOpsMode: boolean): void => {
+      this.setState({ devOpsMode });
     }
   };
 
+  componentDidMount() {
+    this.setState({ userId: 1 });
+  }
+
   render() {
     return (
       <Provider value={this.state}>{this.props.children}</Provider>

+ 5 - 10
dashboard/src/shared/api.tsx

@@ -28,18 +28,14 @@ const getUser = baseApi<{}, { id: number }>('GET', pathParams => {
 });
 
 const updateUser = baseApi<{
-  rawKubeconfig?: string,
-  allowedClusters?: string[]
+  rawKubeConfig?: string,
+  allowedContexts?: string[]
 }, { id: number }>('PUT', pathParams => {
   return `/api/users/${pathParams.id}`;
 });
 
-const getClusters = baseApi<{}, { id: number }>('GET', pathParams => {
-  return `/api/users/${pathParams.id}/clusters`;
-});
-
-const getAllClusters = baseApi<{}, { id: number }>('GET', pathParams => {
-  return `/api/users/${pathParams.id}/clusters/all`;
+const getContexts = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/users/${pathParams.id}/contexts`;
 });
 
 // Bundle export to allow default api import (api.<method> is more readable)
@@ -50,6 +46,5 @@ export default {
   logOutUser,
   getUser,
   updateUser,
-  getClusters,
-  getAllClusters
+  getContexts,
 }

+ 2 - 2
dashboard/src/shared/baseApi.tsx

@@ -20,7 +20,7 @@ export const baseApi = <T extends {}, S = {}>(requestType: string, endpoint: ((p
         }
       })
       .then(res => {
-        callback && callback(null, res.data);
+        callback && callback(null, res);
       })
       .catch(err => {
         callback && callback(err, null);
@@ -32,7 +32,7 @@ export const baseApi = <T extends {}, S = {}>(requestType: string, endpoint: ((p
         }
       })
       .then(res => {
-        callback && callback(null, res.data);
+        callback && callback(null, res);
       })
       .catch(err => {
         callback && callback(err, null);

+ 3 - 2
dashboard/src/shared/types.tsx

@@ -1,6 +1,7 @@
-export interface ClusterConfig {
+export interface KubeContextConfig {
+  cluster: string,
   name: string,
+  selected?: boolean,
   server: string,
-  context: string,
   user: string
 }

+ 8 - 58
docs/API.md

@@ -7,8 +7,7 @@
   - [`ErrorInternal`](#errorinternal)
 - [`/api/users`](#apiusers)
   - [`GET /api/users/{id}`](#get-apiusersid)
-  - [`GET /api/users/{id}/clusters`](#get-apiusersidclusters)
-  - [`GET /api/users/{id}/clusters/all`](#get-apiusersidclustersall)
+  - [`GET /api/users/{id}/contexts`](#get-apiusersidcontexts)
   - [`POST /api/users`](#post-apiusers)
   - [`POST /api/login`](#post-apilogin)
   - [`POST /api/logout`](#post-apilogout)
@@ -109,12 +108,7 @@ Internal server errors are shared across all endpoints and are listed in the [Gl
 User{
     "id": Number,
     "email": String,
-    "clusters": []ClusterConfig{
-        "name": String,
-        "server": String,
-        "context": String,
-        "user": String,
-    },
+    "contexts": []String,
     "rawKubeConfig": String,
 }
 ```
@@ -142,9 +136,9 @@ User{
     }
     ```
 
-#### `GET /api/users/{id}/clusters`
+#### `GET /api/users/{id}/contexts`
 
-**Description:** Retrieves the clusters that are currently linked to a User account. 
+**Description:** Retrieves a list of contexts parsed from the provided kubeconfig. 
 
 **URL parameters:** 
 
@@ -157,56 +151,12 @@ User{
 **Successful Response Body**: 
 
 ```js
-[]ClusterConfig{
+[]Context{
   "name": String,
   "server": String,
-  "context": String,
-  "user": String,
-}
-```
-
-**Successful Status Code**: `200`
-
-**Errors:** 
-- User not found
-  - Status Code: `404`
-  - Request Body:
-    ```json
-    {
-        "code":602,
-        "errors":["could not find requested object"]
-    }
-    ```
-- Invalid `id` URL parameter
-  - Status Code: `400`
-  - Request Body:
-    ```json
-    {
-        "code":600,
-        "errors":["could not process request"]
-    }
-    ```
-
-#### `GET /api/users/{id}/clusters/all`
-
-**Description:** Parses all clusters from the user's kubeconfig and returns a list of viable cluster configs. 
-
-**URL parameters:** 
-
-- `id` The user's ID. 
-
-**Query parameters:** N/A
-
-**Request Body**: N/A
-
-**Successful Response Body**: 
-
-```js
-[]ClusterConfig{
-  "name": String,
-  "server": String,
-  "context": String,
+  "cluster": String,
   "user": String,
+  "selected": Boolean,
 }
 ```
 
@@ -368,7 +318,7 @@ User{
 ```js
 {
   "rawKubeConfig": String,
-  "allowedClusters": []String,
+  "allowedContexts": []String,
 }
 ```
 

+ 20 - 4
go.mod

@@ -3,9 +3,12 @@ module github.com/porter-dev/porter
 go 1.14
 
 require (
+	github.com/Azure/go-autorest/autorest v0.11.1 // indirect
 	github.com/DATA-DOG/go-sqlmock v1.5.0
 	github.com/cosmtrek/air v1.21.2 // indirect
 	github.com/creack/pty v1.1.11 // indirect
+	github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 // indirect
+	github.com/evanphx/json-patch v4.9.0+incompatible // indirect
 	github.com/fatih/color v1.9.0 // indirect
 	github.com/go-chi/chi v4.1.2+incompatible
 	github.com/go-chi/cors v1.1.1
@@ -13,29 +16,42 @@ require (
 	github.com/go-playground/universal-translator v0.17.0
 	github.com/go-playground/validator/v10 v10.3.0
 	github.com/go-test/deep v1.0.7
+	github.com/google/go-cmp v0.5.1
 	github.com/gorilla/securecookie v1.1.1
 	github.com/gorilla/sessions v1.2.1
 	github.com/imdario/mergo v0.3.11 // 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
+	github.com/kr/pretty v0.2.0 // indirect
 	github.com/leodido/go-urn v1.2.0 // indirect
 	github.com/mattn/go-colorable v0.1.7 // indirect
 	github.com/pelletier/go-toml v1.8.1 // indirect
 	github.com/pkg/errors v0.9.1
 	github.com/rs/zerolog v1.20.0
-	github.com/stretchr/testify v1.5.1
+	github.com/sirupsen/logrus v1.6.0
+	github.com/stretchr/testify v1.6.1
 	golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a
 	golang.org/x/net v0.0.0-20200904194848-62affa334b73 // indirect
 	golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 // indirect
-	golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d // indirect
+	golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6 // indirect
 	golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect
+	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
 	gopkg.in/go-playground/validator.v9 v9.31.0
 	gopkg.in/yaml.v2 v2.3.0
 	gorm.io/driver/postgres v1.0.2
 	gorm.io/driver/sqlite v1.1.3
 	gorm.io/gorm v1.20.2
-	k8s.io/apimachinery v0.19.2
-	k8s.io/client-go v0.0.0-20200917000235-cba7285b7f29
+	helm.sh/helm v2.16.12+incompatible
+	helm.sh/helm/v3 v3.3.4
+	k8s.io/api v0.18.8
+	k8s.io/apimachinery v0.18.8
+	k8s.io/cli-runtime v0.18.8
+	k8s.io/client-go v0.18.8
+	k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac // indirect
+	k8s.io/helm v2.16.12+incompatible // indirect
 	k8s.io/klog v1.0.0 // indirect
+	k8s.io/klog/v2 v2.2.0 // indirect
 	k8s.io/utils v0.0.0-20200912215256-4140de9c8800 // indirect
+	sigs.k8s.io/structured-merge-diff/v4 v4.0.1 // indirect
 )

Разница между файлами не показана из-за своего большого размера
+ 589 - 9
go.sum


+ 2 - 2
internal/auth/sessionstore_test.go

@@ -34,7 +34,7 @@ func TestPGStore(t *testing.T) {
 	repo := test.NewRepository(true)
 
 	ss, err := sessionstore.NewStore(repo, config.ServerConf{
-		CookieSecrets: [][]byte{[]byte("secret")},
+		CookieSecret: []byte("secret"),
 	})
 
 	if err != nil {
@@ -134,7 +134,7 @@ func TestSessionOptionsAreUniquePerSession(t *testing.T) {
 	repo := test.NewRepository(true)
 
 	ss, err := sessionstore.NewStore(repo, config.ServerConf{
-		CookieSecrets: [][]byte{[]byte("secret")},
+		CookieSecret: []byte("secret"),
 	})
 
 	if err != nil {

+ 7 - 1
internal/config/config.go

@@ -12,13 +12,14 @@ type Conf struct {
 	Debug  bool `env:"DEBUG,default=false"`
 	Server ServerConf
 	Db     DBConf
+	Helm   HelmGlobalConf
 }
 
 // ServerConf is the server configuration
 type ServerConf struct {
 	Port         int           `env:"SERVER_PORT,default=8080"`
 	CookieName   string        `env:"COOKIE_NAME,default=porter"`
-	CookieSecret []byte        `env:"COOKIE_SECRETS,default=secret"`
+	CookieSecret []byte        `env:"COOKIE_SECRET,default=secret"`
 	TimeoutRead  time.Duration `env:"SERVER_TIMEOUT_READ,default=5s"`
 	TimeoutWrite time.Duration `env:"SERVER_TIMEOUT_WRITE,default=10s"`
 	TimeoutIdle  time.Duration `env:"SERVER_TIMEOUT_IDLE,default=15s"`
@@ -34,6 +35,11 @@ type DBConf struct {
 	DbName   string `env:"DB_NAME,default=porter"`
 }
 
+// HelmGlobalConf is the global configuration for the Helm agent
+type HelmGlobalConf struct {
+	IsTesting bool `env:"HELM_IS_TESTING,default=false"`
+}
+
 // FromEnv generates a configuration from environment variables
 func FromEnv() *Conf {
 	var c Conf

+ 27 - 0
internal/forms/chart.go

@@ -0,0 +1,27 @@
+package forms
+
+import (
+	"github.com/porter-dev/porter/internal/helm"
+	"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"`
+}
+
+// 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)
+
+	if err != nil {
+		return err
+	}
+
+	lcf.HelmOptions.AllowedContexts = user.ContextToSlice()
+
+	lcf.HelmOptions.KubeConfig = user.RawKubeConfig
+	return nil
+}

+ 44 - 9
internal/forms/user.go

@@ -1,15 +1,18 @@
 package forms
 
 import (
+	"strings"
+
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
 	"golang.org/x/crypto/bcrypt"
 	"gorm.io/gorm"
 )
 
 // WriteUserForm is a generic form for write operations to the User model
 type WriteUserForm interface {
-	ToUser() (*models.User, error)
+	ToUser(repo repository.UserRepository) (*models.User, error)
 }
 
 // CreateUserForm represents the accepted values for creating a user
@@ -20,7 +23,7 @@ type CreateUserForm struct {
 }
 
 // ToUser converts a CreateUserForm to models.User
-func (cuf *CreateUserForm) ToUser() (*models.User, error) {
+func (cuf *CreateUserForm) ToUser(_ repository.UserRepository) (*models.User, error) {
 	hashed, err := bcrypt.GenerateFromPassword([]byte(cuf.Password), 8)
 
 	if err != nil {
@@ -42,7 +45,7 @@ type LoginUserForm struct {
 }
 
 // ToUser converts a LoginUserForm to models.User
-func (luf *LoginUserForm) ToUser() (*models.User, error) {
+func (luf *LoginUserForm) ToUser(_ repository.UserRepository) (*models.User, error) {
 	hashed, err := bcrypt.GenerateFromPassword([]byte(luf.Password), 8)
 
 	if err != nil {
@@ -61,25 +64,57 @@ func (luf *LoginUserForm) ToUser() (*models.User, error) {
 type UpdateUserForm struct {
 	WriteUserForm
 	ID              uint     `form:"required"`
-	RawKubeConfig   string   `json:"rawKubeConfig" form:"required"`
-	AllowedClusters []string `json:"allowedClusters" form:"required"`
+	RawKubeConfig   string   `json:"rawKubeConfig,omitempty"`
+	AllowedContexts []string `json:"allowedContexts,omitempty"`
 }
 
 // ToUser converts an UpdateUserForm to models.User by parsing the kubeconfig
 // and the allowed clusters to generate a list of ClusterConfigs.
-func (uuf *UpdateUserForm) ToUser() (*models.User, error) {
+func (uuf *UpdateUserForm) ToUser(repo repository.UserRepository) (*models.User, error) {
 	rawBytes := []byte(uuf.RawKubeConfig)
-	clusters, err := kubernetes.GetAllowedClusterConfigsFromBytes(rawBytes, uuf.AllowedClusters)
+	contexts := uuf.AllowedContexts
+
+	savedUser, err := repo.ReadUser(uuf.ID)
 
 	if err != nil {
 		return nil, err
 	}
 
+	// if the rawKubeConfig is empty, query the DB for a non-empty one
+	if uuf.RawKubeConfig == "" {
+		rawBytes = savedUser.RawKubeConfig
+	}
+
+	// if the allowedContexts is nil, query the DB for a non-nil one
+	if uuf.AllowedContexts == nil {
+		contexts = savedUser.ContextToSlice()
+	}
+
+	if len(rawBytes) > 0 {
+		// validate the kubeconfig
+		_contexts, err := kubernetes.GetContextsFromBytes(rawBytes, contexts)
+
+		if err != nil {
+			return nil, err
+		}
+
+		contexts = make([]string, 0)
+
+		// ensure only joined contexts get written
+		for _, context := range _contexts {
+			if context.Selected {
+				contexts = append(contexts, context.Name)
+			}
+		}
+	}
+
+	contextsJoin := strings.Join(contexts, ",")
+
 	return &models.User{
 		Model: gorm.Model{
 			ID: uuf.ID,
 		},
-		Clusters:      clusters,
+		Contexts:      contextsJoin,
 		RawKubeConfig: rawBytes,
 	}, nil
 }
@@ -92,7 +127,7 @@ type DeleteUserForm struct {
 }
 
 // ToUser converts a DeleteUserForm to models.User using the user ID
-func (uuf *DeleteUserForm) ToUser() (*models.User, error) {
+func (uuf *DeleteUserForm) ToUser(_ repository.UserRepository) (*models.User, error) {
 	return &models.User{
 		Model: gorm.Model{
 			ID: uuf.ID,

+ 50 - 0
internal/helm/action_config.go

@@ -0,0 +1,50 @@
+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
+}

+ 113 - 0
internal/helm/agent.go

@@ -0,0 +1,113 @@
+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
+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,
+	filter *ListFilter,
+) ([]*release.Release, error) {
+	cmd := action.NewList(a.ActionConfig)
+
+	filter.apply(cmd)
+
+	return cmd.Run()
+}
+
+// GetRelease returns the info of a release.
+func (a *Agent) GetRelease(
+	name string,
+) (*release.Release, error) {
+	// Namespace is already known by the RESTClientGetter.
+	cmd := action.NewGet(a.ActionConfig)
+
+	return cmd.Run(name)
+}

+ 175 - 0
internal/helm/agent_test.go

@@ -0,0 +1,175 @@
+package helm_test
+
+import (
+	"testing"
+
+	"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"
+
+	"helm.sh/helm/v3/pkg/chart"
+	"helm.sh/helm/v3/pkg/release"
+)
+
+func newAgentFixture(t *testing.T, namespace string) *helm.Agent {
+	t.Helper()
+
+	l := logger.NewConsole(true)
+	opts := &helm.Form{
+		Namespace: namespace,
+	}
+
+	agent, _ := opts.ToAgent(l, &config.HelmGlobalConf{
+		IsTesting: true,
+	}, nil)
+
+	return agent
+}
+
+type releaseStub struct {
+	name         string
+	namespace    string
+	version      int
+	chartVersion string
+	status       release.Status
+}
+
+// makeReleases adds a slice of releases to the configured storage.
+func makeReleases(t *testing.T, agent *helm.Agent, rels []releaseStub) {
+	t.Helper()
+	storage := agent.ActionConfig.Releases
+
+	for _, r := range rels {
+		rel := &release.Release{
+			Name:      r.name,
+			Namespace: r.namespace,
+			Version:   r.version,
+			Info: &release.Info{
+				Status: r.status,
+			},
+			Chart: &chart.Chart{
+				Metadata: &chart.Metadata{
+					Version: r.chartVersion,
+					Icon:    "https://example.com/icon.png",
+				},
+			},
+		}
+
+		err := storage.Create(rel)
+
+		if err != nil {
+			t.Fatal(err)
+		}
+	}
+}
+
+func compareReleaseToStubs(t *testing.T, releases []*release.Release, stubs []releaseStub) {
+	t.Helper()
+
+	if len(releases) != len(stubs) {
+		t.Fatalf("length of release %v doesn't match length of stub %v\n",
+			len(releases), len(stubs))
+	}
+
+	for i, r := range releases {
+		if r.Name != stubs[i].name {
+			t.Errorf("Release name %v doesn't match stub name %v\n",
+				r.Name, stubs[i].name)
+		}
+
+		if r.Namespace != stubs[i].namespace {
+			t.Errorf("Release namespace %v doesn't match stub namespace %v\n",
+				r.Namespace, stubs[i].namespace)
+		}
+
+		if r.Info.Status != stubs[i].status {
+			t.Errorf("Release namespace %v doesn't match stub namespace %v\n",
+				r.Info.Status, stubs[i].status)
+		}
+
+		if r.Version != stubs[i].version {
+			t.Errorf("Release version %v doesn't match stub version %v\n",
+				r.Version, stubs[i].version)
+		}
+
+		if r.Chart.Metadata.Version != stubs[i].chartVersion {
+			t.Errorf("Release metadata version %v doesn't match stub chart version %v\n",
+				r.Chart.Metadata.Version, stubs[i].chartVersion)
+		}
+	}
+
+	return
+}
+
+type listReleaseTest struct {
+	name      string
+	namespace string
+	filter    *helm.ListFilter
+	releases  []releaseStub
+	expRes    []releaseStub
+}
+
+var listReleaseTests = []listReleaseTest{
+	listReleaseTest{
+		name:      "simple test across namespaces, should sort by name",
+		namespace: "",
+		filter: &helm.ListFilter{
+			Namespace:    "",
+			Limit:        20,
+			Skip:         0,
+			ByDate:       false,
+			StatusFilter: []string{"deployed"},
+		},
+		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},
+		},
+		expRes: []releaseStub{
+			releaseStub{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
+			releaseStub{"not-in-default-namespace", "other", 1, "1.0.2", release.StatusDeployed},
+			releaseStub{"wordpress", "default", 1, "1.0.1", release.StatusDeployed},
+		},
+	},
+	listReleaseTest{
+		name:      "simple test limit",
+		namespace: "",
+		filter: &helm.ListFilter{
+			Namespace:    "",
+			Limit:        2,
+			Skip:         0,
+			ByDate:       false,
+			StatusFilter: []string{"deployed"},
+		},
+		releases: []releaseStub{
+			releaseStub{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
+			releaseStub{"not-in-default-namespace", "other", 1, "1.0.1", release.StatusDeployed},
+			releaseStub{"wordpress", "default", 1, "1.0.2", release.StatusDeployed},
+		},
+		expRes: []releaseStub{
+			releaseStub{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
+			releaseStub{"not-in-default-namespace", "other", 1, "1.0.1", release.StatusDeployed},
+		},
+	},
+}
+
+func TestListReleases(t *testing.T) {
+	for _, tc := range listReleaseTests {
+		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)
+
+		releases, err := agent.ListReleases(tc.namespace, tc.filter)
+
+		if err != nil {
+			t.Errorf("%v", err)
+		}
+
+		compareReleaseToStubs(t, releases, tc.expRes)
+	}
+}

+ 63 - 0
internal/helm/driver.go

@@ -0,0 +1,63 @@
+package helm
+
+// Helm contains support for several different storage drivers.
+//
+// This includes (as of October 2020):
+// - configmap
+// - secret
+// - memory
+// - postgres
+//
+// This file implements first-class support for each driver type, and integrates with the
+// logger.
+
+import (
+	"github.com/porter-dev/porter/internal/logger"
+
+	"helm.sh/helm/v3/pkg/storage"
+	"helm.sh/helm/v3/pkg/storage/driver"
+	"k8s.io/client-go/kubernetes"
+)
+
+// NewStorageDriver is a function type for returning a new storage driver
+type NewStorageDriver func(l *logger.Logger, namespace string, clientset *kubernetes.Clientset) *storage.Storage
+
+// StorageMap is a map from storage configuration env variables to a function
+// that initializes that Helm storage driver.
+var StorageMap map[string]NewStorageDriver = map[string]NewStorageDriver{
+	"secret":    newSecretStorageDriver,
+	"configmap": newConfigMapsStorageDriver,
+	"memory":    newMemoryStorageDriver,
+}
+
+// NewSecretStorageDriver returns a storage using the Secret driver.
+func newSecretStorageDriver(
+	l *logger.Logger,
+	namespace string,
+	clientset *kubernetes.Clientset,
+) *storage.Storage {
+	d := driver.NewSecrets(clientset.CoreV1().Secrets(namespace))
+	d.Log = l.Printf
+	return storage.Init(d)
+}
+
+// NewConfigMapsStorageDriver returns a storage using the ConfigMap driver.
+func newConfigMapsStorageDriver(
+	l *logger.Logger,
+	namespace string,
+	clientset *kubernetes.Clientset,
+) *storage.Storage {
+	d := driver.NewConfigMaps(clientset.CoreV1().ConfigMaps(namespace))
+	d.Log = l.Printf
+	return storage.Init(d)
+}
+
+// NewMemoryStorageDriver returns a storage using the In-Memory driver.
+func newMemoryStorageDriver(
+	_ *logger.Logger,
+	namespace string,
+	_ *kubernetes.Clientset,
+) *storage.Storage {
+	d := driver.NewMemory()
+	return storage.Init(d)
+}

+ 48 - 0
internal/helm/filter.go

@@ -0,0 +1,48 @@
+package helm
+
+import (
+	"helm.sh/helm/v3/pkg/action"
+)
+
+// ListFilter is a struct that represents the various filter options used for
+// retrieving the releases
+type ListFilter struct {
+	Namespace    string   `json:"namespace"`
+	Limit        int      `json:"limit"`
+	Skip         int      `json:"skip"`
+	ByDate       bool     `json:"byDate"`
+	StatusFilter []string `json:"statusFilter"`
+}
+
+// listStatesFromNames accepts the following list of names:
+//
+// "deployed", "uninstalled", "uninstalling", "pending", "pending_upgrade",
+// "pending_rollback", "superseded", "failed"
+//
+// It returns an action.ListStates to be used in an action.List as filters for
+// releases in a certain state.
+func (h *ListFilter) listStatesFromNames() action.ListStates {
+	var res action.ListStates = 0
+
+	for _, name := range h.StatusFilter {
+		res = res | res.FromName(name)
+	}
+
+	return res
+}
+
+// apply sets the ListFilter options for an action.List
+func (h *ListFilter) apply(list *action.List) {
+	if h.Namespace == "" {
+		list.AllNamespaces = true
+	}
+
+	list.Limit = h.Limit
+	list.Offset = h.Skip
+
+	list.StateMask = h.listStatesFromNames()
+
+	if h.ByDate {
+		list.ByDate = true
+	}
+}

+ 19 - 0
internal/kubernetes/agent.go

@@ -0,0 +1,19 @@
+package kubernetes
+
+import (
+	"context"
+
+	v1 "k8s.io/api/core/v1"
+	v1Machinery "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/client-go/kubernetes"
+)
+
+// ListNamespaces simply lists namespaces
+func ListNamespaces(clientset *kubernetes.Clientset) *v1.NamespaceList {
+	namespaces, _ := clientset.CoreV1().Namespaces().List(
+		context.TODO(),
+		v1Machinery.ListOptions{},
+	)
+
+	return namespaces
+}

+ 18 - 0
internal/kubernetes/client.go

@@ -0,0 +1,18 @@
+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)
+}

+ 91 - 131
internal/kubernetes/kubeconfig.go

@@ -2,179 +2,139 @@ package kubernetes
 
 import (
 	"github.com/porter-dev/porter/internal/models"
-	"gopkg.in/yaml.v2"
+	"k8s.io/client-go/tools/clientcmd"
+	"k8s.io/client-go/tools/clientcmd/api"
 )
 
-// KubeConfigCluster represents the cluster field in a kubeconfig
-type KubeConfigCluster struct {
-	Cluster struct {
-		Server string `yaml:"server"`
-	} `yaml:"cluster"`
-	Name string `yaml:"name"`
-}
-
-// KubeConfigContext represents the context field in a kubeconfig
-type KubeConfigContext struct {
-	Context struct {
-		Cluster string `yaml:"cluster"`
-		User    string `yaml:"user"`
-	} `yaml:"context"`
-	Name string `yaml:"name"`
-}
-
-// KubeConfigUser represents the user field in a kubeconfig
-type KubeConfigUser struct {
-	Name string `yaml:"name"`
-}
-
-// KubeConfig represents an unmarshaled kubeconfig
-type KubeConfig struct {
-	CurrentContext string              `yaml:"current-context"`
-	Clusters       []KubeConfigCluster `yaml:"clusters"`
-	Contexts       []KubeConfigContext `yaml:"contexts"`
-	Users          []KubeConfigUser    `yaml:"users"`
-}
-
-// GetAllowedClusterConfigsFromBytes converts a raw string to a set of ClusterConfigs
-// by unmarshaling and calling (*KubeConfig).ToAllowedClusterConfigs
-func GetAllowedClusterConfigsFromBytes(bytes []byte, allowedClusters []string) ([]models.ClusterConfig, error) {
-	conf := KubeConfig{}
-	err := yaml.Unmarshal(bytes, &conf)
+// GetRestrictedClientConfigFromBytes returns a clientcmd.ClientConfig from a raw kubeconfig,
+// a context name, and the set of allowed contexts.
+func GetRestrictedClientConfigFromBytes(
+	bytes []byte,
+	contextName string,
+	allowedContexts []string,
+) (clientcmd.ClientConfig, error) {
+	config, err := clientcmd.NewClientConfigFromBytes(bytes)
 
 	if err != nil {
 		return nil, err
 	}
 
-	clusters := conf.toAllowedClusterConfigs(allowedClusters)
-
-	return clusters, nil
-}
-
-// GetAllClusterConfigsFromBytes converts a raw string to a set of ClusterConfigs
-// by unmarshaling and calling (*KubeConfig).ToAllClusterConfigs
-func GetAllClusterConfigsFromBytes(bytes []byte) ([]models.ClusterConfig, error) {
-	conf := KubeConfig{}
-	err := yaml.Unmarshal(bytes, &conf)
+	rawConf, err := config.RawConfig()
 
 	if err != nil {
 		return nil, err
 	}
 
-	clusters := conf.toAllClusterConfigs()
-
-	return clusters, nil
-}
+	// grab a copy to get the pointer and set clusters, authinfos, and contexts to empty
+	copyConf := rawConf.DeepCopy()
 
-// toAllowedClusterConfigs converts a KubeConfig to a set of ClusterConfigs by
-// joining users and clusters on the context.
-//
-// It accepts a list of cluster names that the user wishes to connect to
-func (k *KubeConfig) toAllowedClusterConfigs(allowedClusters []string) []models.ClusterConfig {
-	clusters := make([]models.ClusterConfig, 0)
+	copyConf.Clusters = make(map[string]*api.Cluster)
+	copyConf.AuthInfos = make(map[string]*api.AuthInfo)
+	copyConf.Contexts = make(map[string]*api.Context)
+	copyConf.CurrentContext = contextName
 
-	// convert clusters, contexts, and users to maps for fast lookup
-	clusterMap := k.createClusterMap()
-	contextMap := k.createContextMap()
-	userMap := k.createUserMap()
-
-	// put allowed clusters in map
-	aClusterMap := createAllowedClusterMap(allowedClusters)
+	// put allowed clusters in a map
+	aContextMap := createAllowedContextMap(allowedContexts)
 
-	// iterate through context maps and link to a user-cluster pair
-	for contextName, context := range contextMap {
-		userName := context.Context.User
-		clusterName := context.Context.Cluster
-		_, userFound := userMap[userName]
-		cluster, clusterFound := clusterMap[clusterName]
+	// discover all allowed clusters
+	for name, context := range rawConf.Contexts {
+		userName := context.AuthInfo
+		clusterName := context.Cluster
+		authInfo, userFound := rawConf.AuthInfos[userName]
+		cluster, clusterFound := rawConf.Clusters[clusterName]
 
 		// make sure the cluster is "allowed"
-		_, aClusterFound := aClusterMap[clusterName]
-
-		if userFound && clusterFound && aClusterFound {
-			clusters = append(clusters, models.ClusterConfig{
-				Name:    clusterName,
-				Server:  cluster.Cluster.Server,
-				Context: contextName,
-				User:    userName,
-			})
+		_, isAllowed := aContextMap[name]
+
+		if userFound && clusterFound && isAllowed {
+			copyConf.Clusters[clusterName] = cluster
+			copyConf.AuthInfos[userName] = authInfo
+			copyConf.Contexts[contextName] = context
 		}
 	}
 
-	return clusters
-}
+	// validate the copyConf and create a ClientConfig
+	err = clientcmd.Validate(*copyConf)
 
-// toAllClusterConfigs converts a KubeConfig to a set of ClusterConfigs by
-// joining users and clusters on the context.
-func (k *KubeConfig) toAllClusterConfigs() []models.ClusterConfig {
-	clusters := make([]models.ClusterConfig, 0)
-
-	// convert clusters, contexts, and users to maps for fast lookup
-	clusterMap := k.createClusterMap()
-	contextMap := k.createContextMap()
-	userMap := k.createUserMap()
-
-	// iterate through context maps and link to a user-cluster pair
-	for contextName, context := range contextMap {
-		userName := context.Context.User
-		clusterName := context.Context.Cluster
-		_, userFound := userMap[userName]
-		cluster, clusterFound := clusterMap[clusterName]
-
-		if userFound && clusterFound {
-			clusters = append(clusters, models.ClusterConfig{
-				Name:    clusterName,
-				Server:  cluster.Cluster.Server,
-				Context: contextName,
-				User:    userName,
-			})
-		}
+	if err != nil {
+		return nil, err
 	}
 
-	return clusters
+	clientConf := clientcmd.NewDefaultClientConfig(*copyConf, &clientcmd.ConfigOverrides{})
+
+	return clientConf, nil
 }
 
-// createAllowedClusterMap creates a map from a cluster name to a KubeConfigCluster object
-func createAllowedClusterMap(clusters []string) map[string]string {
-	aClusterMap := make(map[string]string)
+// GetContextsFromBytes converts a raw string to a set of Contexts
+// by unmarshaling and calling toContexts
+func GetContextsFromBytes(bytes []byte, allowedContexts []string) ([]models.Context, error) {
+	config, err := clientcmd.NewClientConfigFromBytes(bytes)
 
-	for _, cluster := range clusters {
-		aClusterMap[cluster] = cluster
+	if err != nil {
+		return nil, err
 	}
 
-	return aClusterMap
-}
+	rawConf, err := config.RawConfig()
 
-// createClusterMap creates a map from a cluster name to a KubeConfigCluster object
-func (k *KubeConfig) createClusterMap() map[string]KubeConfigCluster {
-	clusterMap := make(map[string]KubeConfigCluster)
+	if err != nil {
+		return nil, err
+	}
 
-	for _, cluster := range k.Clusters {
-		clusterMap[cluster.Name] = cluster
+	err = clientcmd.Validate(rawConf)
+
+	if err != nil {
+		return nil, err
 	}
 
-	return clusterMap
+	contexts := toContexts(&rawConf, allowedContexts)
+
+	return contexts, nil
+
 }
 
-// createContextMap creates a map from a context name to a KubeConfigContext object
-func (k *KubeConfig) createContextMap() map[string]KubeConfigContext {
-	contextMap := make(map[string]KubeConfigContext)
+func toContexts(rawConf *api.Config, allowedContexts []string) []models.Context {
+	contexts := make([]models.Context, 0)
 
-	for _, context := range k.Contexts {
-		contextMap[context.Name] = context
+	// put allowed clusters in map
+	aContextMap := createAllowedContextMap(allowedContexts)
+
+	// iterate through contexts and switch on selected
+	for name, context := range rawConf.Contexts {
+		_, isAllowed := aContextMap[name]
+		_, userFound := rawConf.AuthInfos[context.AuthInfo]
+		cluster, clusterFound := rawConf.Clusters[context.Cluster]
+
+		if userFound && clusterFound && isAllowed {
+			contexts = append(contexts, models.Context{
+				Name:     name,
+				Server:   cluster.Server,
+				Cluster:  context.Cluster,
+				User:     context.AuthInfo,
+				Selected: true,
+			})
+		} else if userFound && clusterFound {
+			contexts = append(contexts, models.Context{
+				Name:     name,
+				Server:   cluster.Server,
+				Cluster:  context.Cluster,
+				User:     context.AuthInfo,
+				Selected: false,
+			})
+		}
 	}
 
-	return contextMap
+	return contexts
 }
 
-// createUserMap creates a map from a user name to a KubeConfigUser object
-func (k *KubeConfig) createUserMap() map[string]KubeConfigUser {
-	userMap := make(map[string]KubeConfigUser)
+// createAllowedContextMap creates a dummy map from context name to context name
+func createAllowedContextMap(contexts []string) map[string]string {
+	aContextMap := make(map[string]string)
 
-	for _, user := range k.Users {
-		userMap[user.Name] = user
+	for _, context := range contexts {
+		aContextMap[context] = context
 	}
 
-	return userMap
+	return aContextMap
 }
 
 // func ReadLocalKubeConfig()

+ 94 - 88
internal/kubernetes/kubeconfig_test.go

@@ -2,86 +2,94 @@ package kubernetes_test
 
 import (
 	"reflect"
+	"strings"
 	"testing"
 
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
 )
 
-type KubeConfigTest struct {
+type kubeConfigTest struct {
 	msg             string
 	raw             []byte
-	allowedClusters []string
-	expected        []models.ClusterConfig
+	allowedContexts []string
+	expected        []models.Context
 }
 
-var MissingFieldsTest = []KubeConfigTest{
-	KubeConfigTest{
-		msg:             "no fields at all",
+type kubeConfigTestValidateError struct {
+	msg             string
+	raw             []byte
+	allowedContexts []string
+	contextName     string
+	errorContains   string // a string that the error message should contain
+}
+
+var ValidateErrorTests = []kubeConfigTestValidateError{
+	kubeConfigTestValidateError{
+		msg:             "No configuration",
 		raw:             []byte(""),
-		allowedClusters: []string{},
-		expected:        []models.ClusterConfig{},
+		allowedContexts: []string{},
+		contextName:     "",
+		errorContains:   "invalid configuration: no configuration has been provided",
 	},
-	KubeConfigTest{
-		msg:             "no contexts to join",
+	kubeConfigTestValidateError{
+		msg:             "Context name does not exist",
 		raw:             []byte(noContexts),
-		allowedClusters: []string{},
-		expected:        []models.ClusterConfig{},
+		allowedContexts: []string{"porter-test-1"},
+		contextName:     "context-test",
+		errorContains:   "invalid configuration: context was not found for specified context: context-test",
 	},
-	KubeConfigTest{
-		msg:             "no clusters to join",
+	kubeConfigTestValidateError{
+		msg:             "Cluster to join does not exist",
 		raw:             []byte(noClusters),
-		allowedClusters: []string{},
-		expected:        []models.ClusterConfig{},
+		allowedContexts: []string{"porter-test-1"},
+		contextName:     "context-test",
+		errorContains:   "invalid configuration: context was not found for specified context: context-test",
 	},
-	KubeConfigTest{
-		msg:             "no users to join",
+	kubeConfigTestValidateError{
+		msg:             "User to join does not exist",
 		raw:             []byte(noUsers),
-		allowedClusters: []string{},
-		expected:        []models.ClusterConfig{},
-	},
-	KubeConfigTest{
-		msg:             "no cluster contexts to join",
-		raw:             []byte(noContextClusters),
-		allowedClusters: []string{},
-		expected:        []models.ClusterConfig{},
-	},
-	KubeConfigTest{
-		msg:             "no cluster users to join",
-		raw:             []byte(noContextUsers),
-		allowedClusters: []string{},
-		expected:        []models.ClusterConfig{},
+		allowedContexts: []string{"porter-test-1"},
+		contextName:     "context-test",
+		errorContains:   "invalid configuration: context was not found for specified context: context-test",
 	},
 }
 
-func TestMissingFields(t *testing.T) {
-	for _, c := range MissingFieldsTest {
-		res, err := kubernetes.GetAllowedClusterConfigsFromBytes(c.raw, c.allowedClusters)
+func TestValidateErrors(t *testing.T) {
+	for _, c := range ValidateErrorTests {
 
-		if err != nil {
-			t.Fatalf("Testing %s returned an error: %v\n", c.msg, err.Error())
-		}
+		_, err := kubernetes.GetRestrictedClientConfigFromBytes(c.raw, c.contextName, c.allowedContexts)
 
-		isEqual := reflect.DeepEqual(c.expected, res)
+		if err == nil {
+			t.Fatalf("Testing %s did not return an error\n", c.msg)
+		}
 
-		if !isEqual {
-			t.Errorf("Testing: %s, Expected: %v, Got: %v\n", c.msg, c.expected, res)
+		if !strings.Contains(err.Error(), c.errorContains) {
+			t.Errorf("Testing %s -- Error was:\n \"%s\" \n It did not contain string \"%s\"\n", c.msg, err.Error(), c.errorContains)
 		}
 	}
 }
 
-var NoAllowedClustersTests = []KubeConfigTest{
-	KubeConfigTest{
+var BasicContextAllowedTests = []kubeConfigTest{
+	kubeConfigTest{
 		msg:             "basic test",
 		raw:             []byte(basic),
-		allowedClusters: []string{},
-		expected:        []models.ClusterConfig{},
+		allowedContexts: []string{"context-test"},
+		expected: []models.Context{
+			models.Context{
+				Name:     "context-test",
+				Server:   "https://localhost",
+				Cluster:  "cluster-test",
+				User:     "test-admin",
+				Selected: true,
+			},
+		},
 	},
 }
 
-func TestNoAllowedClusters(t *testing.T) {
-	for _, c := range NoAllowedClustersTests {
-		res, err := kubernetes.GetAllowedClusterConfigsFromBytes(c.raw, c.allowedClusters)
+func TestBasicAllowed(t *testing.T) {
+	for _, c := range BasicContextAllowedTests {
+		res, err := kubernetes.GetContextsFromBytes(c.raw, c.allowedContexts)
 
 		if err != nil {
 			t.Fatalf("Testing %s returned an error: %v\n", c.msg, err.Error())
@@ -95,25 +103,26 @@ func TestNoAllowedClusters(t *testing.T) {
 	}
 }
 
-var BasicClustersAllowedTests = []KubeConfigTest{
-	KubeConfigTest{
+var BasicContextAllTests = []kubeConfigTest{
+	kubeConfigTest{
 		msg:             "basic test",
 		raw:             []byte(basic),
-		allowedClusters: []string{"cluster-test"},
-		expected: []models.ClusterConfig{
-			models.ClusterConfig{
-				Name:    "cluster-test",
-				Server:  "https://localhost",
-				Context: "context-test",
-				User:    "test-admin",
+		allowedContexts: []string{},
+		expected: []models.Context{
+			models.Context{
+				Name:     "context-test",
+				Server:   "https://localhost",
+				Cluster:  "cluster-test",
+				User:     "test-admin",
+				Selected: false,
 			},
 		},
 	},
 }
 
-func TestBasicAllowed(t *testing.T) {
-	for _, c := range BasicClustersAllowedTests {
-		res, err := kubernetes.GetAllowedClusterConfigsFromBytes(c.raw, c.allowedClusters)
+func TestBasicAll(t *testing.T) {
+	for _, c := range BasicContextAllTests {
+		res, err := kubernetes.GetContextsFromBytes(c.raw, c.allowedContexts)
 
 		if err != nil {
 			t.Fatalf("Testing %s returned an error: %v\n", c.msg, err.Error())
@@ -127,35 +136,32 @@ func TestBasicAllowed(t *testing.T) {
 	}
 }
 
-var BasicClustersAllTests = []KubeConfigTest{
-	KubeConfigTest{
-		msg:             "basic test",
-		raw:             []byte(basic),
-		allowedClusters: []string{"cluster-test"},
-		expected: []models.ClusterConfig{
-			models.ClusterConfig{
-				Name:    "cluster-test",
-				Server:  "https://localhost",
-				Context: "context-test",
-				User:    "test-admin",
-			},
-		},
-	},
-}
+func TestGetRestrictedClientConfig(t *testing.T) {
+	contexts := []string{"context-test"}
+	contextName := "context-test"
 
-func TestBasicAll(t *testing.T) {
-	for _, c := range BasicClustersAllTests {
-		res, err := kubernetes.GetAllClusterConfigsFromBytes(c.raw)
+	clientConf, err := kubernetes.GetRestrictedClientConfigFromBytes([]byte(basic), contextName, contexts)
 
-		if err != nil {
-			t.Fatalf("Testing %s returned an error: %v\n", c.msg, err.Error())
-		}
+	if err != nil {
+		t.Fatalf("Fatal error: %s\n", err.Error())
+	}
 
-		isEqual := reflect.DeepEqual(c.expected, res)
+	rawConf, err := clientConf.RawConfig()
 
-		if !isEqual {
-			t.Errorf("Testing: %s, Expected: %v, Got: %v\n", c.msg, c.expected, res)
-		}
+	if err != nil {
+		t.Fatalf("Fatal error: %s\n", err.Error())
+	}
+
+	if cluster, clusterFound := rawConf.Clusters["cluster-test"]; !clusterFound || cluster.Server != "https://localhost" {
+		t.Errorf("invalid cluster returned")
+	}
+
+	if _, contextFound := rawConf.Contexts["context-test"]; !contextFound {
+		t.Errorf("invalid context returned")
+	}
+
+	if _, authInfoFound := rawConf.AuthInfos["test-admin"]; !authInfoFound {
+		t.Errorf("invalid auth info returned")
 	}
 }
 
@@ -167,7 +173,7 @@ clusters:
 - cluster:
     server: https://localhost
   name: porter-test-1
-current-context: default
+current-context: context-test
 users:
 - name: test-admin
   user:
@@ -177,7 +183,7 @@ const noClusters string = `
 apiVersion: v1
 kind: Config
 preferences: {}
-current-context: default
+current-context: context-test
 contexts:
 - context:
     cluster: porter-test-1
@@ -246,7 +252,7 @@ const basic string = `
 apiVersion: v1
 kind: Config
 preferences: {}
-current-context: default
+current-context: context-test
 clusters:
 - cluster:
     server: https://localhost

+ 0 - 39
internal/models/cluster_configs.go

@@ -1,39 +0,0 @@
-package models
-
-import "gorm.io/gorm"
-
-// ClusterConfig that extends gorm.Model
-//
-// ClusterConfig represents the configuration for a single cluster-user pair. This gets
-// associated with a specific user, and is primarily used for simplicity.
-type ClusterConfig struct {
-	gorm.Model
-	// Name is the name of the cluster
-	Name,
-	// Server is the endpoint of the kube apiserver for a cluster
-	Server,
-	// Context is the name of the context
-	Context,
-	// User is the name of the user for a cluster
-	User string
-	// UserID is the foreign key of User, gorm creates by default
-	UserID uint
-}
-
-// ClusterConfigExternal is the ClusterConfig type sent over REST
-type ClusterConfigExternal struct {
-	Name    string `json:"name"`
-	Server  string `json:"server"`
-	Context string `json:"context"`
-	User    string `json:"user"`
-}
-
-// Externalize generates an external ClusterConfig to be shared over REST
-func (cc *ClusterConfig) Externalize() *ClusterConfigExternal {
-	return &ClusterConfigExternal{
-		Name:    cc.Name,
-		Server:  cc.Server,
-		Context: cc.Context,
-		User:    cc.User,
-	}
-}

+ 0 - 38
internal/models/cluster_configs_test.go

@@ -1,38 +0,0 @@
-package models_test
-
-import (
-	"testing"
-
-	"gorm.io/gorm"
-	"github.com/porter-dev/porter/internal/models"
-)
-
-func TestClusterConfigExternalize(t *testing.T) {
-	cc := &models.ClusterConfig{
-		Model: gorm.Model{
-			ID: 1,
-		},
-		Name:   "test",
-		Server: "localhost",
-		User:   "test",
-		UserID: 1,
-	}
-
-	extCC := *cc.Externalize()
-
-	if extCC.Name != cc.Name {
-		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "Name", extCC.Name, cc.Name)
-	}
-
-	if extCC.Server != cc.Server {
-		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "Server", extCC.Server, cc.Server)
-	}
-
-	if extCC.User != cc.User {
-		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "User", extCC.User, cc.User)
-	}
-
-	if extCC.Context != cc.Context {
-		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "Context", extCC.Context, cc.Context)
-	}
-}

+ 16 - 0
internal/models/context.go

@@ -0,0 +1,16 @@
+package models
+
+// Context represents the configuration for a single cluster-user pair
+type Context struct {
+	// Name is the name of the context
+	Name string `json:"name"`
+	// Server is the endpoint of the kube apiserver for a cluster
+	Server string `json:"server"`
+	// Cluster is the name of the cluster
+	Cluster string `json:"cluster"`
+	// User is the name of the user for a cluster
+	User string `json:"user"`
+	// Selected determines if the context has been selected for use in the
+	// dashboard
+	Selected bool `json:"selected"`
+}

+ 22 - 15
internal/models/user.go

@@ -1,6 +1,8 @@
 package models
 
 import (
+	"strings"
+
 	"gorm.io/gorm"
 )
 
@@ -8,32 +10,37 @@ import (
 type User struct {
 	gorm.Model
 
-	Email         string          `json:"email" gorm:"unique"`
-	Password      string          `json:"password"`
-	Clusters      []ClusterConfig `json:"clusters"`
-	RawKubeConfig []byte          `json:"rawKubeConfig"`
+	Email         string `json:"email" gorm:"unique"`
+	Password      string `json:"password"`
+	Contexts      string `json:"contexts"`
+	RawKubeConfig []byte `json:"rawKubeConfig"`
 }
 
 // UserExternal represents the User type that is sent over REST
 type UserExternal struct {
-	ID            uint                     `json:"id"`
-	Email         string                   `json:"email"`
-	Clusters      []*ClusterConfigExternal `json:"clusters"`
-	RawKubeConfig string                   `json:"rawKubeConfig"`
+	ID            uint     `json:"id"`
+	Email         string   `json:"email"`
+	Contexts      []string `json:"contexts"`
+	RawKubeConfig string   `json:"rawKubeConfig"`
 }
 
 // Externalize generates an external User to be shared over REST
 func (u *User) Externalize() *UserExternal {
-	clustersExt := make([]*ClusterConfigExternal, 0)
-
-	for _, cluster := range u.Clusters {
-		clustersExt = append(clustersExt, cluster.Externalize())
-	}
-
 	return &UserExternal{
 		ID:            u.ID,
 		Email:         u.Email,
-		Clusters:      clustersExt,
+		Contexts:      u.ContextToSlice(),
 		RawKubeConfig: string(u.RawKubeConfig),
 	}
 }
+
+// ContextToSlice converts the serialized context string to an array of strings
+func (u *User) ContextToSlice() []string {
+	contexts := strings.Split(u.Contexts, ",")
+
+	if u.Contexts == "" {
+		contexts = make([]string, 0)
+	}
+
+	return contexts
+}

+ 6 - 13
internal/models/user_test.go

@@ -3,8 +3,8 @@ package models_test
 import (
 	"testing"
 
-	"gorm.io/gorm"
 	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
 )
 
 func TestUserExternalize(t *testing.T) {
@@ -13,16 +13,9 @@ func TestUserExternalize(t *testing.T) {
 		Model: gorm.Model{
 			ID: 1,
 		},
-		Email:    "testing@testing.com",
-		Password: "testing123",
-		Clusters: []models.ClusterConfig{
-			models.ClusterConfig{
-				Name:   "test",
-				Server: "localhost",
-				User:   "test",
-				UserID: 1,
-			},
-		},
+		Email:         "testing@testing.com",
+		Password:      "testing123",
+		Contexts:      "test",
 		RawKubeConfig: []byte{},
 	}
 
@@ -36,8 +29,8 @@ func TestUserExternalize(t *testing.T) {
 		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "Email", user.Email, extUser.Email)
 	}
 
-	if len(extUser.Clusters) != 1 {
-		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "Length Clusters", len(extUser.Clusters), 1)
+	if len(extUser.Contexts) != 1 {
+		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "Length Contexts", len(extUser.Contexts), 1)
 	}
 
 	if len(extUser.RawKubeConfig) != 0 {

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

@@ -46,7 +46,7 @@ func (repo *UserRepository) ReadUserByEmail(email string) (*models.User, error)
 
 // UpdateUser modifies an existing User in the database
 func (repo *UserRepository) UpdateUser(user *models.User) (*models.User, error) {
-	if err := repo.db.First(&models.User{}, user.ID).Updates(user).Error; err != nil {
+	if err := repo.db.Save(user).Error; err != nil {
 		return nil, err
 	}
 

+ 9 - 1
server/api/api.go

@@ -6,8 +6,10 @@ import (
 	"github.com/go-playground/validator/v10"
 
 	"github.com/gorilla/sessions"
+	"github.com/porter-dev/porter/internal/config"
 	lr "github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/repository"
+	"helm.sh/helm/v3/pkg/storage"
 )
 
 // App represents an API instance with handler methods attached, a DB connection
@@ -18,7 +20,11 @@ type App struct {
 	validator  *validator.Validate
 	store      sessions.Store
 	translator *ut.Translator
-	cookieName string
+	helmConf   *config.HelmGlobalConf
+	// HelmTestStorageDriver is used by testing libraries to query the in-memory
+	// Helm storage driver
+	HelmTestStorageDriver *storage.Storage
+	cookieName            string
 }
 
 // New returns a new App instance
@@ -27,6 +33,7 @@ func New(
 	repo *repository.Repository,
 	validator *validator.Validate,
 	store sessions.Store,
+	helmConf *config.HelmGlobalConf,
 	cookieName string,
 ) *App {
 	// for now, will just support the english translator from the
@@ -41,6 +48,7 @@ func New(
 		validator:  validator,
 		store:      store,
 		translator: &trans,
+		helmConf:   helmConf,
 		cookieName: cookieName,
 	}
 }

+ 49 - 0
server/api/chart_handler.go

@@ -0,0 +1,49 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"github.com/porter-dev/porter/internal/forms"
+)
+
+// Enumeration of chart API error codes, represented as int64
+const (
+	ErrChartDecode ErrorCode = iota + 600
+	ErrChartValidateFields
+)
+
+// HandleListCharts retrieves a list of charts with various filter options
+func (app *App) HandleListCharts(w http.ResponseWriter, r *http.Request) {
+	// get the filter options
+	form := &forms.ListChartForm{}
+
+	// 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
+	agent, err := form.HelmOptions.ToAgent(app.logger, app.helmConf, app.HelmTestStorageDriver)
+
+	releases, err := agent.ListReleases(form.HelmOptions.Namespace, form.ListFilter)
+
+	if err != nil {
+		app.handleErrorFormValidation(err, ErrChartValidateFields, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(releases); err != nil {
+		app.handleErrorFormDecoding(err, ErrChartDecode, w)
+		return
+	}
+}

+ 230 - 0
server/api/chart_handler_test.go

@@ -0,0 +1,230 @@
+package api_test
+
+import (
+	"encoding/json"
+	"net/http"
+	"reflect"
+	"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"
+)
+
+type releaseStub struct {
+	name         string
+	namespace    string
+	version      int
+	chartVersion string
+	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 {
+	initializers []func(tester *tester)
+	msg          string
+	method       string
+	endpoint     string
+	body         string
+	expStatus    int
+	expBody      string
+	useCookie    bool
+	validators   []func(c *chartTest, tester *tester, t *testing.T)
+}
+
+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)
+
+		// 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 listChartsTests = []*chartTest{
+	&chartTest{
+		initializers: []func(tester *tester){
+			initDefaultCharts,
+		},
+		msg:      "List charts",
+		method:   "GET",
+		endpoint: "/api/charts",
+		body: `{
+			"user_id": 1,
+			"helm": {
+				"namespace": "",
+				"context": "context-test",
+				"storage": "memory"
+			},
+			"filter": {
+				"namespace": "",
+				"limit": 20,
+				"skip": 0,
+				"byDate": false,
+				"statusFilter": ["deployed"]
+			}
+		}`,
+		expStatus: http.StatusOK,
+		expBody:   releaseStubsToChartJSON(sampleReleaseStubs),
+		useCookie: true,
+		validators: []func(c *chartTest, tester *tester, t *testing.T){
+			chartReleaseBodyValidator,
+		},
+	},
+}
+
+func TestHandleListCharts(t *testing.T) {
+	testChartRequests(t, listChartsTests, true)
+}
+
+// ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
+
+func initDefaultCharts(tester *tester) {
+	initUserDefault(tester)
+
+	agent := newAgentFixture("default", tester.app.HelmTestStorageDriver)
+
+	makeReleases(agent, sampleReleaseStubs)
+
+	// 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("")
+}
+
+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},
+	releaseStub{"wordpress", "default", 1, "1.0.2", release.StatusDeployed},
+}
+
+func releaseStubsToChartJSON(rels []releaseStub) string {
+	releases := make([]*release.Release, 0)
+
+	for _, r := range rels {
+		rel := releaseStubToRelease(r)
+
+		releases = append(releases, rel)
+	}
+
+	str, _ := json.Marshal(releases)
+
+	return string(str)
+}
+
+func releaseStubToRelease(r releaseStub) *release.Release {
+	return &release.Release{
+		Name:      r.name,
+		Namespace: r.namespace,
+		Version:   r.version,
+		Info: &release.Info{
+			Status: r.status,
+		},
+		Chart: &chart.Chart{
+			Metadata: &chart.Metadata{
+				Version: r.chartVersion,
+				Icon:    "https://example.com/icon.png",
+			},
+		},
+	}
+}
+
+func makeReleases(agent *helm.Agent, rels []releaseStub) {
+	storage := agent.ActionConfig.Releases
+
+	for _, r := range rels {
+		rel := releaseStubToRelease(r)
+
+		storage.Create(rel)
+	}
+}
+
+func chartReleaseBodyValidator(c *chartTest, tester *tester, t *testing.T) {
+	gotBody := &[]release.Release{}
+	expBody := &[]release.Release{}
+
+	json.Unmarshal(tester.rr.Body.Bytes(), gotBody)
+	json.Unmarshal([]byte(c.expBody), expBody)
+
+	if !reflect.DeepEqual(gotBody, expBody) {
+		t.Errorf("%s, handler returned wrong body: got %v want %v",
+			c.msg, gotBody, expBody)
+	}
+}

+ 2 - 2
server/api/errors.go

@@ -120,7 +120,7 @@ func (app *App) handleErrorRead(err error, code ErrorCode, w http.ResponseWriter
 		return
 	}
 
-	app.handleErrorDataRead(err, code, w)
+	app.handleErrorDataRead(err, w)
 }
 
 // handleErrorDataWrite handles a database write error due to either a connection
@@ -131,7 +131,7 @@ func (app *App) handleErrorDataWrite(err error, w http.ResponseWriter) {
 
 // handleErrorDataRead handles a database read error due to an internal error, such as
 // the database connection or gorm internals
-func (app *App) handleErrorDataRead(err error, code ErrorCode, w http.ResponseWriter) {
+func (app *App) handleErrorDataRead(err error, w http.ResponseWriter) {
 	app.sendExternalError(err, http.StatusInternalServerError, ErrorDataRead, w)
 }
 

+ 96 - 0
server/api/helpers_test.go

@@ -0,0 +1,96 @@
+package api_test
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"time"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/config"
+	lr "github.com/porter-dev/porter/internal/logger"
+	"github.com/porter-dev/porter/internal/repository"
+	"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"
+)
+
+type tester struct {
+	app    *api.App
+	repo   *repository.Repository
+	store  *sessionstore.PGStore
+	router *chi.Mux
+	req    *http.Request
+	rr     *httptest.ResponseRecorder
+	cookie *http.Cookie
+}
+
+func (t *tester) execute() {
+	t.router.ServeHTTP(t.rr, t.req)
+}
+
+func (t *tester) reset() {
+	t.rr = httptest.NewRecorder()
+	t.req = nil
+}
+
+func (t *tester) createUserSession(email string, pw string) {
+	req, _ := http.NewRequest(
+		"POST",
+		"/api/users",
+		strings.NewReader(`{"email":"`+email+`","password":"`+pw+`"}`),
+	)
+
+	t.req = req
+	t.execute()
+
+	if cookies := t.rr.Result().Cookies(); len(cookies) > 0 {
+		t.cookie = cookies[0]
+	}
+
+	t.reset()
+}
+
+func newTester(canQuery bool, storage *storage.Storage) *tester {
+	appConf := config.Conf{
+		Debug: true,
+		Server: config.ServerConf{
+			Port:         8080,
+			CookieName:   "porter",
+			CookieSecret: []byte("secret"),
+			TimeoutRead:  time.Second * 5,
+			TimeoutWrite: time.Second * 10,
+			TimeoutIdle:  time.Second * 15,
+		},
+		// unimportant here
+		Db: config.DBConf{},
+		// set the helm config to testing
+		Helm: config.HelmGlobalConf{
+			IsTesting: true,
+		},
+	}
+
+	logger := lr.NewConsole(appConf.Debug)
+	validator := vr.New()
+
+	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
+	r := router.New(app, store, appConf.Server.CookieName)
+
+	return &tester{
+		app:    app,
+		repo:   repo,
+		store:  store,
+		router: r,
+		req:    nil,
+		rr:     httptest.NewRecorder(),
+		cookie: nil,
+	}
+}

+ 11 - 39
server/api/user_handler.go

@@ -31,7 +31,7 @@ func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
 	session, err := app.store.Get(r, app.cookieName)
 
 	if err != nil {
-		app.handleErrorDataRead(err, ErrUserDataRead, w)
+		app.handleErrorDataRead(err, w)
 	}
 
 	form := &forms.CreateUserForm{}
@@ -76,7 +76,7 @@ func (app *App) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
 	session, err := app.store.Get(r, app.cookieName)
 
 	if err != nil {
-		app.handleErrorDataRead(err, ErrUserDataRead, w)
+		app.handleErrorDataRead(err, w)
 	}
 
 	form := &forms.LoginUserForm{}
@@ -121,7 +121,7 @@ func (app *App) HandleLogoutUser(w http.ResponseWriter, r *http.Request) {
 	session, err := app.store.Get(r, app.cookieName)
 
 	if err != nil {
-		app.handleErrorDataRead(err, ErrUserDataRead, w)
+		app.handleErrorDataRead(err, w)
 	}
 
 	session.Values["authenticated"] = false
@@ -150,9 +150,9 @@ func (app *App) HandleReadUser(w http.ResponseWriter, r *http.Request) {
 	w.WriteHeader(http.StatusOK)
 }
 
-// HandleReadUserClusters returns the externalized User.Clusters (models.ClusterConfigs)
+// HandleReadUserContexts returns the externalized User.Contexts ([]models.Context)
 // based on a user ID
-func (app *App) HandleReadUserClusters(w http.ResponseWriter, r *http.Request) {
+func (app *App) HandleReadUserContexts(w http.ResponseWriter, r *http.Request) {
 	user, err := app.readUser(w, r)
 
 	// error already handled by helper
@@ -160,13 +160,14 @@ func (app *App) HandleReadUserClusters(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	extClusters := make([]models.ClusterConfigExternal, 0)
+	contexts, err := kubernetes.GetContextsFromBytes(user.RawKubeConfig, user.ContextToSlice())
 
-	for _, cluster := range user.Clusters {
-		extClusters = append(extClusters, *cluster.Externalize())
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
 	}
 
-	if err := json.NewEncoder(w).Encode(extClusters); err != nil {
+	if err := json.NewEncoder(w).Encode(contexts); err != nil {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		return
 	}
@@ -174,35 +175,6 @@ func (app *App) HandleReadUserClusters(w http.ResponseWriter, r *http.Request) {
 	w.WriteHeader(http.StatusOK)
 }
 
-// HandleReadUserClustersAll returns all models.ClusterConfigs parsed from a KubeConfig
-// that is attached to a specific user, identified through the user ID
-func (app *App) HandleReadUserClustersAll(w http.ResponseWriter, r *http.Request) {
-	user, err := app.readUser(w, r)
-
-	// if there is an error, it's already handled by helper
-	if err == nil {
-		clusters, err := kubernetes.GetAllClusterConfigsFromBytes(user.RawKubeConfig)
-
-		if err != nil {
-			app.handleErrorFormDecoding(err, ErrUserDecode, w)
-			return
-		}
-
-		extClusters := make([]models.ClusterConfigExternal, 0)
-
-		for _, cluster := range clusters {
-			extClusters = append(extClusters, *cluster.Externalize())
-		}
-
-		if err := json.NewEncoder(w).Encode(extClusters); err != nil {
-			app.handleErrorFormDecoding(err, ErrUserDecode, w)
-			return
-		}
-
-		w.WriteHeader(http.StatusOK)
-	}
-}
-
 // HandleUpdateUser validates an update user form entry, updates the user
 // in the database, and writes status accepted
 func (app *App) HandleUpdateUser(w http.ResponseWriter, r *http.Request) {
@@ -272,7 +244,7 @@ func (app *App) writeUser(
 	}
 
 	// convert the form to a user model -- WriteUserForm must implement ToUser
-	userModel, err := form.ToUser()
+	userModel, err := form.ToUser(app.repo.User)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)

+ 188 - 169
server/api/user_handler_test.go

@@ -2,35 +2,17 @@ package api_test
 
 import (
 	"encoding/json"
+	"fmt"
 	"net/http"
 	"net/http/httptest"
 	"reflect"
 	"strings"
 	"testing"
-	"time"
 
-	"github.com/go-chi/chi"
-	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/repository"
-	"github.com/porter-dev/porter/internal/repository/test"
-	"github.com/porter-dev/porter/server/api"
-	"github.com/porter-dev/porter/server/router"
-
-	sessionstore "github.com/porter-dev/porter/internal/auth"
-	lr "github.com/porter-dev/porter/internal/logger"
-	vr "github.com/porter-dev/porter/internal/validator"
 )
 
-type tester struct {
-	app    *api.App
-	repo   *repository.Repository
-	store  *sessionstore.PGStore
-	router *chi.Mux
-	req    *http.Request
-	rr     *httptest.ResponseRecorder
-	cookie *http.Cookie
-}
+// ------------------------- TEST TYPES AND MAIN LOOP ------------------------- //
 
 type userTest struct {
 	initializers []func(t *tester)
@@ -44,92 +26,10 @@ type userTest struct {
 	validators   []func(c *userTest, tester *tester, t *testing.T)
 }
 
-func (t *tester) execute() {
-	t.router.ServeHTTP(t.rr, t.req)
-}
-
-func (t *tester) reset() {
-	t.rr = httptest.NewRecorder()
-	t.req = nil
-}
-
-func (t *tester) createUserSession(email string, pw string) {
-	req, _ := http.NewRequest(
-		"POST",
-		"/api/users",
-		strings.NewReader(`{"email":"`+email+`","password":"`+pw+`"}`),
-	)
-
-	t.req = req
-	t.execute()
-
-	if cookies := t.rr.Result().Cookies(); len(cookies) > 0 {
-		t.cookie = cookies[0]
-	}
-
-	t.reset()
-}
-
-func initUserDefault(tester *tester) {
-	tester.createUserSession("belanger@getporter.dev", "hello")
-}
-
-func initUserWithClusters(tester *tester) {
-	initUserDefault(tester)
-
-	user, _ := tester.repo.User.ReadUserByEmail("belanger@getporter.dev")
-	user.Clusters = []models.ClusterConfig{
-		models.ClusterConfig{
-			Name:    "cluster-test",
-			Server:  "https://localhost",
-			Context: "context-test",
-			User:    "test-admin",
-		},
-	}
-	user.RawKubeConfig = []byte("apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: default\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin")
-
-	tester.repo.User.UpdateUser(user)
-}
-
-func newTester(canQuery bool) *tester {
-	appConf := config.Conf{
-		Debug: true,
-		Server: config.ServerConf{
-			Port:          8080,
-			CookieName:    "porter",
-			CookieSecrets: [][]byte{[]byte("secret")},
-			TimeoutRead:   time.Second * 5,
-			TimeoutWrite:  time.Second * 10,
-			TimeoutIdle:   time.Second * 15,
-		},
-		// unimportant here
-		Db: config.DBConf{},
-	}
-
-	logger := lr.NewConsole(appConf.Debug)
-	validator := vr.New()
-
-	repo := test.NewRepository(canQuery)
-
-	store, _ := sessionstore.NewStore(repo, appConf.Server)
-	app := api.New(logger, repo, validator, store, appConf.Server.CookieName)
-	r := router.New(app, store, appConf.Server.CookieName)
-
-	return &tester{
-		app:    app,
-		repo:   repo,
-		store:  store,
-		router: r,
-		req:    nil,
-		rr:     httptest.NewRecorder(),
-		cookie: nil,
-	}
-}
-
 func testUserRequests(t *testing.T, tests []*userTest, canQuery bool) {
 	for _, c := range tests {
 		// create a new tester
-		tester := newTester(canQuery)
+		tester := newTester(canQuery, nil)
 
 		// if there's an initializer, call it
 		for _, init := range c.initializers {
@@ -168,6 +68,8 @@ func testUserRequests(t *testing.T, tests []*userTest, canQuery bool) {
 	}
 }
 
+// ------------------------- TEST FIXTURES AND FUNCTIONS  ------------------------- //
+
 var createUserTests = []*userTest{
 	&userTest{
 		msg:      "Create user",
@@ -191,7 +93,7 @@ var createUserTests = []*userTest{
 		expStatus: http.StatusUnprocessableEntity,
 		expBody:   `{"code":601,"errors":["email validation failed"]}`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -204,7 +106,7 @@ var createUserTests = []*userTest{
 		expStatus: http.StatusUnprocessableEntity,
 		expBody:   `{"code":601,"errors":["required validation failed"]}`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -221,7 +123,7 @@ var createUserTests = []*userTest{
 		expStatus: http.StatusUnprocessableEntity,
 		expBody:   `{"code":601,"errors":["email already taken"]}`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -235,7 +137,7 @@ var createUserTests = []*userTest{
 		expStatus: http.StatusBadRequest,
 		expBody:   `{"code":600,"errors":["could not process request"]}`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 }
@@ -256,7 +158,7 @@ var createUserTestsWriteFail = []*userTest{
 		expStatus: http.StatusInternalServerError,
 		expBody:   `{"code":500,"errors":["could not read from database"]}`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 }
@@ -280,7 +182,7 @@ var loginUserTests = []*userTest{
 		expStatus: http.StatusOK,
 		expBody:   ``,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -298,7 +200,7 @@ var loginUserTests = []*userTest{
 		expBody:   ``,
 		useCookie: true,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -312,7 +214,7 @@ var loginUserTests = []*userTest{
 		expStatus: http.StatusUnauthorized,
 		expBody:   `{"code":401,"errors":["email not registered"]}`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -330,7 +232,7 @@ var loginUserTests = []*userTest{
 		expBody:   `{"code":401,"errors":["incorrect password"]}`,
 		useCookie: true,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 }
@@ -387,17 +289,17 @@ func TestHandleLogoutUser(t *testing.T) {
 var readUserTests = []*userTest{
 	&userTest{
 		initializers: []func(tester *tester){
-			initUserWithClusters,
+			initUserWithContexts,
 		},
 		msg:       "Read user successful",
 		method:    "GET",
 		endpoint:  "/api/users/1",
 		body:      "",
 		expStatus: http.StatusOK,
-		expBody:   `{"id":1,"email":"belanger@getporter.dev","clusters":[{"name":"cluster-test","server":"https://localhost","context":"context-test","user":"test-admin"}],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: default\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`,
+		expBody:   `{"id":1,"email":"belanger@getporter.dev","contexts":["context-test"],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`,
 		useCookie: true,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			UserModelBodyValidator,
+			userModelBodyValidator,
 		},
 	},
 	&userTest{
@@ -411,7 +313,7 @@ var readUserTests = []*userTest{
 		expStatus: http.StatusForbidden,
 		expBody:   http.StatusText(http.StatusForbidden) + "\n",
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 }
@@ -420,73 +322,49 @@ func TestHandleReadUser(t *testing.T) {
 	testUserRequests(t, readUserTests, true)
 }
 
-var readUserClustersTests = []*userTest{
-	&userTest{
-		initializers: []func(tester *tester){
-			initUserWithClusters,
-		},
-		msg:       "Read user successful",
-		method:    "GET",
-		endpoint:  "/api/users/1/clusters",
-		body:      "",
-		expStatus: http.StatusOK,
-		useCookie: true,
-		expBody:   `[{"name":"cluster-test","server":"https://localhost","context":"context-test","user":"test-admin"}]`,
-		validators: []func(c *userTest, tester *tester, t *testing.T){
-			ClusterBodyValidator,
-		},
-	},
-}
-
-func TestHandleReadUserClusters(t *testing.T) {
-	testUserRequests(t, readUserClustersTests, true)
-}
-
-var readUserClustersAllTests = []*userTest{
+var readUserContextsTests = []*userTest{
 	&userTest{
 		initializers: []func(tester *tester){
-			initUserWithClusters,
+			initUserWithContexts,
 		},
-		msg:       "Read user successful",
+		msg:       "Read user context selected successful",
 		method:    "GET",
-		endpoint:  "/api/users/1/clusters/all",
+		endpoint:  "/api/users/1/contexts",
 		body:      "",
 		expStatus: http.StatusOK,
 		useCookie: true,
-		expBody:   `[{"name":"cluster-test","server":"https://localhost","context":"context-test","user":"test-admin"}]`,
+		expBody:   `[{"name":"context-test","server":"https://localhost","cluster":"cluster-test","user":"test-admin","selected":true}]`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			ClusterBodyValidator,
+			userContextBodyValidator,
 		},
 	},
 	&userTest{
 		initializers: []func(tester *tester){
-			initUserWithClusters,
 			func(tester *tester) {
 				initUserDefault(tester)
 
 				user, _ := tester.repo.User.ReadUserByEmail("belanger@getporter.dev")
-				user.Clusters = []models.ClusterConfig{}
-				user.RawKubeConfig = []byte("apiVersion: \xc5\n")
+				user.Contexts = ""
+				user.RawKubeConfig = []byte("apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin")
 
 				tester.repo.User.UpdateUser(user)
-
 			},
 		},
-		msg:       "Read user with invalid utf-8 \xc5 in kubeconfig",
+		msg:       "Read user context not selected successful",
 		method:    "GET",
-		endpoint:  "/api/users/1/clusters/all",
+		endpoint:  "/api/users/1/contexts",
 		body:      "",
-		expStatus: http.StatusBadRequest,
+		expStatus: http.StatusOK,
 		useCookie: true,
-		expBody:   `{"code":600,"errors":["could not process request"]}`,
+		expBody:   `[{"name":"context-test","server":"https://localhost","cluster":"cluster-test","user":"test-admin","selected":false}]`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			ClusterBodyValidator,
+			userContextBodyValidator,
 		},
 	},
 }
 
-func TestHandleReadUserClustersAll(t *testing.T) {
-	testUserRequests(t, readUserClustersAllTests, true)
+func TestHandleReadUserContexts(t *testing.T) {
+	testUserRequests(t, readUserContextsTests, true)
 }
 
 var updateUserTests = []*userTest{
@@ -497,7 +375,132 @@ var updateUserTests = []*userTest{
 		msg:       "Update user successful",
 		method:    "PUT",
 		endpoint:  "/api/users/1",
-		body:      `{"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: default\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin", "allowedClusters":[]}`,
+		body:      `{"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin", "allowedContexts":[]}`,
+		expStatus: http.StatusNoContent,
+		expBody:   "",
+		useCookie: true,
+		validators: []func(c *userTest, tester *tester, t *testing.T){
+			func(c *userTest, tester *tester, t *testing.T) {
+				req, err := http.NewRequest(
+					"GET",
+					"/api/users/1",
+					strings.NewReader(""),
+				)
+
+				req.AddCookie(tester.cookie)
+
+				if err != nil {
+					t.Fatal(err)
+				}
+
+				rr2 := httptest.NewRecorder()
+				tester.router.ServeHTTP(rr2, req)
+
+				gotBody := &models.UserExternal{}
+				expBody := &models.UserExternal{}
+
+				json.Unmarshal(rr2.Body.Bytes(), gotBody)
+				json.Unmarshal([]byte(`{"id":1,"email":"belanger@getporter.dev","contexts":[],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`), expBody)
+
+				fmt.Println(rr2.Body.String())
+
+				if !reflect.DeepEqual(gotBody, expBody) {
+					t.Errorf("%s, handler returned wrong body: got %v want %v",
+						"validator failed", gotBody, expBody)
+				}
+			},
+		},
+	},
+	&userTest{
+		initializers: []func(tester *tester){
+			initUserDefault,
+		},
+		msg:       "Update user successful without allowedContexts parameter",
+		method:    "PUT",
+		endpoint:  "/api/users/1",
+		body:      `{"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`,
+		expStatus: http.StatusNoContent,
+		expBody:   "",
+		useCookie: true,
+		validators: []func(c *userTest, tester *tester, t *testing.T){
+			func(c *userTest, tester *tester, t *testing.T) {
+				req, err := http.NewRequest(
+					"GET",
+					"/api/users/1",
+					strings.NewReader(""),
+				)
+
+				req.AddCookie(tester.cookie)
+
+				if err != nil {
+					t.Fatal(err)
+				}
+
+				rr2 := httptest.NewRecorder()
+				tester.router.ServeHTTP(rr2, req)
+
+				gotBody := &models.UserExternal{}
+				expBody := &models.UserExternal{}
+
+				json.Unmarshal(rr2.Body.Bytes(), gotBody)
+				json.Unmarshal([]byte(`{"id":1,"email":"belanger@getporter.dev","contexts":[],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`), expBody)
+
+				if !reflect.DeepEqual(gotBody, expBody) {
+					t.Errorf("%s, handler returned wrong body: got %v want %v",
+						"validator failed", gotBody, expBody)
+				}
+			},
+		},
+	},
+	&userTest{
+		initializers: []func(tester *tester){
+			initUserDefault,
+		},
+		msg:       "Update user successful with allowedContexts",
+		method:    "PUT",
+		endpoint:  "/api/users/1",
+		body:      `{"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin", "allowedContexts":["context-test"]}`,
+		expStatus: http.StatusNoContent,
+		expBody:   "",
+		useCookie: true,
+		validators: []func(c *userTest, tester *tester, t *testing.T){
+			func(c *userTest, tester *tester, t *testing.T) {
+				req, err := http.NewRequest(
+					"GET",
+					"/api/users/1",
+					strings.NewReader(""),
+				)
+
+				req.AddCookie(tester.cookie)
+
+				if err != nil {
+					t.Fatal(err)
+				}
+
+				rr2 := httptest.NewRecorder()
+				tester.router.ServeHTTP(rr2, req)
+
+				gotBody := &models.UserExternal{}
+				expBody := &models.UserExternal{}
+
+				json.Unmarshal(rr2.Body.Bytes(), gotBody)
+				json.Unmarshal([]byte(`{"id":1,"email":"belanger@getporter.dev","contexts":["context-test"],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`), expBody)
+
+				if !reflect.DeepEqual(gotBody, expBody) {
+					t.Errorf("%s, handler returned wrong body: got %v want %v",
+						"validator failed", gotBody, expBody)
+				}
+			},
+		},
+	},
+	&userTest{
+		initializers: []func(tester *tester){
+			initUserWithContexts,
+		},
+		msg:       "Update user successful without rawKubeConfig",
+		method:    "PUT",
+		endpoint:  "/api/users/1",
+		body:      `{"allowedContexts":[]}`,
 		expStatus: http.StatusNoContent,
 		expBody:   "",
 		useCookie: true,
@@ -522,7 +525,7 @@ var updateUserTests = []*userTest{
 				expBody := &models.UserExternal{}
 
 				json.Unmarshal(rr2.Body.Bytes(), gotBody)
-				json.Unmarshal([]byte(`{"id":1,"email":"belanger@getporter.dev","clusters":[],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: default\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`), expBody)
+				json.Unmarshal([]byte(`{"id":1,"email":"belanger@getporter.dev","contexts":[],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`), expBody)
 
 				if !reflect.DeepEqual(gotBody, expBody) {
 					t.Errorf("%s, handler returned wrong body: got %v want %v",
@@ -538,11 +541,11 @@ var updateUserTests = []*userTest{
 		msg:       "Update user invalid id",
 		method:    "PUT",
 		endpoint:  "/api/users/alsdfjk",
-		body:      `{"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: default\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin", "allowedClusters":[]}`,
+		body:      `{"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin", "allowedContexts":[]}`,
 		expStatus: http.StatusForbidden,
 		expBody:   http.StatusText(http.StatusForbidden) + "\n",
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -552,12 +555,12 @@ var updateUserTests = []*userTest{
 		msg:       "Update user bad kubeconfig",
 		method:    "PUT",
 		endpoint:  "/api/users/1",
-		body:      `{"rawKubeConfig":"notvalidyaml", "allowedClusters":[]}`,
+		body:      `{"rawKubeConfig":"notvalidyaml", "allowedContexts":[]}`,
 		expStatus: http.StatusBadRequest,
 		expBody:   `{"code":600,"errors":["could not process request"]}`,
 		useCookie: true,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 }
@@ -625,7 +628,7 @@ var deleteUserTests = []*userTest{
 		expStatus: http.StatusForbidden,
 		expBody:   http.StatusText(http.StatusForbidden) + "\n",
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -640,7 +643,7 @@ var deleteUserTests = []*userTest{
 		expBody:   `{"code":601,"errors":["required validation failed"]}`,
 		useCookie: true,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 }
@@ -649,14 +652,31 @@ func TestHandleDeleteUser(t *testing.T) {
 	testUserRequests(t, deleteUserTests, true)
 }
 
-func BasicBodyValidator(c *userTest, tester *tester, t *testing.T) {
+// ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
+
+func initUserDefault(tester *tester) {
+	tester.createUserSession("belanger@getporter.dev", "hello")
+}
+
+func initUserWithContexts(tester *tester) {
+	initUserDefault(tester)
+
+	user, _ := tester.repo.User.ReadUserByEmail("belanger@getporter.dev")
+	user.Contexts = "context-test"
+
+	user.RawKubeConfig = []byte("apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin")
+
+	tester.repo.User.UpdateUser(user)
+}
+
+func userBasicBodyValidator(c *userTest, tester *tester, t *testing.T) {
 	if body := tester.rr.Body.String(); body != c.expBody {
 		t.Errorf("%s, handler returned wrong body: got %v want %v",
 			c.msg, body, c.expBody)
 	}
 }
 
-func UserModelBodyValidator(c *userTest, tester *tester, t *testing.T) {
+func userModelBodyValidator(c *userTest, tester *tester, t *testing.T) {
 	gotBody := &models.UserExternal{}
 	expBody := &models.UserExternal{}
 
@@ -669,10 +689,9 @@ func UserModelBodyValidator(c *userTest, tester *tester, t *testing.T) {
 	}
 }
 
-func ClusterBodyValidator(c *userTest, tester *tester, t *testing.T) {
-	// if status is expected to be 200, parse the body for UserExternal
-	gotBody := &[]models.ClusterConfigExternal{}
-	expBody := &[]models.ClusterConfigExternal{}
+func userContextBodyValidator(c *userTest, tester *tester, t *testing.T) {
+	gotBody := &[]models.Context{}
+	expBody := &[]models.Context{}
 
 	json.Unmarshal(tester.rr.Body.Bytes(), gotBody)
 	json.Unmarshal([]byte(c.expBody), expBody)

+ 34 - 2
server/router/middleware/auth.go

@@ -1,6 +1,9 @@
 package middleware
 
 import (
+	"bytes"
+	"encoding/json"
+	"io/ioutil"
 	"net/http"
 	"strconv"
 
@@ -8,11 +11,13 @@ import (
 	"github.com/gorilla/sessions"
 )
 
+// Auth implements the authorization functions
 type Auth struct {
 	store      sessions.Store
 	cookieName string
 }
 
+// NewAuth returns a new Auth instance
 func NewAuth(
 	store sessions.Store,
 	cookieName string,
@@ -34,11 +39,38 @@ func (auth *Auth) BasicAuthenticate(next http.Handler) http.Handler {
 	})
 }
 
+// IDLocation represents the location of the ID to use for authentication
+type IDLocation uint
+
+const (
+	// URLParam location looks for {id} in the URL
+	URLParam IDLocation = iota
+	// BodyParam location looks for user_id in the body
+	BodyParam
+)
+
+type bodyID struct {
+	UserID uint64 `json:"user_id"`
+}
+
 // DoesUserIDMatch checks the id URL parameter and verifies that it matches
 // the one stored in the session
-func (auth *Auth) DoesUserIDMatch(next http.Handler) http.Handler {
+func (auth *Auth) DoesUserIDMatch(next http.Handler, loc IDLocation) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		id, err := strconv.ParseUint(chi.URLParam(r, "id"), 0, 64)
+		var id uint64
+		var err error
+
+		if loc == URLParam {
+			id, err = strconv.ParseUint(chi.URLParam(r, "id"), 0, 64)
+		} else if loc == BodyParam {
+			form := &bodyID{}
+			body, _ := ioutil.ReadAll(r.Body)
+			err = json.Unmarshal(body, form)
+			id = form.UserID
+
+			// need to create a new stream for the body
+			r.Body = ioutil.NopCloser(bytes.NewReader(body))
+		}
 
 		if err == nil && auth.doesSessionMatchID(r, uint(id)) {
 			next.ServeHTTP(w, r)

+ 10 - 8
server/router/router.go

@@ -5,28 +5,30 @@ import (
 	"github.com/gorilla/sessions"
 	"github.com/porter-dev/porter/server/api"
 	"github.com/porter-dev/porter/server/requestlog"
-	"github.com/porter-dev/porter/server/router/middleware"
+	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) *chi.Mux {
 	l := a.Logger()
 	r := chi.NewRouter()
-	auth := middleware.NewAuth(store, cookieName)
+	auth := mw.NewAuth(store, cookieName)
 
 	r.Route("/api", func(r chi.Router) {
-		r.Use(middleware.ContentTypeJSON)
+		r.Use(mw.ContentTypeJSON)
 
 		// /api/users routes
-		r.Method("GET", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleReadUser, l)))
-		r.Method("GET", "/users/{id}/clusters", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleReadUserClusters, l)))
-		r.Method("GET", "/users/{id}/clusters/all", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleReadUserClustersAll, l)))
+		r.Method("GET", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleReadUser, l), mw.URLParam))
+		r.Method("GET", "/users/{id}/contexts", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleReadUserContexts, l), mw.URLParam))
 		r.Method("POST", "/users", requestlog.NewHandler(a.HandleCreateUser, l))
-		r.Method("PUT", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleUpdateUser, l)))
-		r.Method("DELETE", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleDeleteUser, l)))
+		r.Method("PUT", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleUpdateUser, l), mw.URLParam))
+		r.Method("DELETE", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleDeleteUser, l), mw.URLParam))
 		r.Method("POST", "/login", requestlog.NewHandler(a.HandleLoginUser, l))
 		r.Method("GET", "/auth/check", requestlog.NewHandler(a.HandleAuthCheck, l))
 		r.Method("POST", "/logout", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleLogoutUser, l)))
+
+		// /api/charts routes
+		r.Method("GET", "/charts", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleListCharts, l), mw.BodyParam))
 	})
 
 	return r

Некоторые файлы не были показаны из-за большого количества измененных файлов