Sfoglia il codice sorgente

Merge branch 'frontend-integration' into helm-manifest

sunguroku 5 anni fa
parent
commit
3232f6a33c
39 ha cambiato i file con 2901 aggiunte e 767 eliminazioni
  1. 0 15
      INT_TEST.md
  2. 1 0
      dashboard/.dockerignore
  3. 32 0
      dashboard/package-lock.json
  4. 4 0
      dashboard/package.json
  5. 101 0
      dashboard/src/components/TabSelector.tsx
  6. 18 8
      dashboard/src/components/YamlEditor.tsx
  7. 1 1
      dashboard/src/main/home/Home.tsx
  8. 57 8
      dashboard/src/main/home/dashboard/Dashboard.tsx
  9. 3 2
      dashboard/src/main/home/dashboard/NamespaceSelector.tsx
  10. 44 25
      dashboard/src/main/home/dashboard/chart/Chart.tsx
  11. 59 12
      dashboard/src/main/home/dashboard/chart/ChartList.tsx
  12. 342 0
      dashboard/src/main/home/dashboard/expanded-chart/ExpandedChart.tsx
  13. 184 0
      dashboard/src/main/home/dashboard/expanded-chart/OverviewSection.tsx
  14. 169 0
      dashboard/src/main/home/dashboard/expanded-chart/ResourceItem.tsx
  15. 362 0
      dashboard/src/main/home/dashboard/expanded-chart/RevisionSection.tsx
  16. 96 0
      dashboard/src/main/home/dashboard/expanded-chart/ValuesYaml.tsx
  17. 48 64
      dashboard/src/main/home/modals/ClusterConfigModal.tsx
  18. 41 3
      dashboard/src/shared/api.tsx
  19. 2 0
      dashboard/src/shared/baseApi.tsx
  20. 8 1
      dashboard/src/shared/types.tsx
  21. 1 0
      docker-compose.dev.yaml
  22. 172 20
      docs/API.md
  23. 2 1
      go.mod
  24. 7 0
      go.sum
  25. 0 96
      internal/forms/chart.go
  26. 1 1
      internal/forms/k8s.go
  27. 107 0
      internal/forms/release.go
  28. 43 0
      internal/helm/agent.go
  29. 77 1
      internal/helm/agent_test.go
  30. 3 3
      internal/helm/config.go
  31. 13 9
      internal/helm/storage.go
  32. 82 0
      internal/helm/storage_test.go
  33. 1 1
      server/api/api.go
  34. 0 197
      server/api/chart_handler.go
  35. 0 293
      server/api/chart_handler_test.go
  36. 2 2
      server/api/k8s_handler.go
  37. 285 0
      server/api/release_handler.go
  38. 527 0
      server/api/release_handler_test.go
  39. 6 4
      server/router/router.go

+ 0 - 15
INT_TEST.md

@@ -1,15 +0,0 @@
-Just storing the command-line "integration tests" I've done, which we can use later:
-
-```sh
-# should fail on form decoding
-curl -X POST localhost:8080/api/users 
-
-# should fail on email validation
-curl -d "{\"email\":\"hello\",\"password\":\"hello\"}" -H 'Content-Type: application/json' -X POST localhost:8080/api/users
-
-# should pass (without authentication)
-curl -d "{\"email\":\"belanger@getporter.dev\",\"password\":\"hello\"}" -H 'Content-Type: application/json' -X POST localhost:8080/api/users
-
-# should pass
-curl -X DELETE localhost:8080/api/users/1 -d "{\"password\":\"hello\"}"
-```

+ 1 - 0
dashboard/.dockerignore

@@ -0,0 +1 @@
+node_modules

+ 32 - 0
dashboard/package-lock.json

@@ -396,6 +396,11 @@
         "jest-diff": "^24.3.0"
       }
     },
+    "@types/js-yaml": {
+      "version": "3.12.5",
+      "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-3.12.5.tgz",
+      "integrity": "sha512-JCcp6J0GV66Y4ZMDAQCXot4xprYB+Zfd3meK9+INSJeVZwJmHAW30BBEEkPzXswMXuiyReUGOP3GxrADc9wPww=="
+    },
     "@types/json-schema": {
       "version": "7.0.6",
       "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz",
@@ -973,6 +978,14 @@
       "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
       "dev": true
     },
+    "argparse": {
+      "version": "1.0.10",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+      "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+      "requires": {
+        "sprintf-js": "~1.0.2"
+      }
+    },
     "aria-query": {
       "version": "4.2.2",
       "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz",
@@ -2491,6 +2504,11 @@
         "estraverse": "^4.1.1"
       }
     },
+    "esprima": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+      "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
+    },
     "esrecurse": {
       "version": "4.3.0",
       "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
@@ -4129,6 +4147,15 @@
       "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
       "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
     },
+    "js-yaml": {
+      "version": "3.14.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz",
+      "integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==",
+      "requires": {
+        "argparse": "^1.0.7",
+        "esprima": "^4.0.0"
+      }
+    },
     "jsesc": {
       "version": "2.5.2",
       "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
@@ -6190,6 +6217,11 @@
         "extend-shallow": "^3.0.0"
       }
     },
+    "sprintf-js": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+      "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
+    },
     "ssri": {
       "version": "6.0.1",
       "resolved": "https://registry.npmjs.org/ssri/-/ssri-6.0.1.tgz",

+ 4 - 0
dashboard/package.json

@@ -3,9 +3,13 @@
   "version": "0.1.0",
   "private": true,
   "dependencies": {
+    "@types/js-yaml": "^3.12.5",
+    "@types/qs": "^6.9.5",
     "ace-builds": "^1.4.12",
     "axios": "^0.20.0",
     "dotenv": "^8.2.0",
+    "js-yaml": "^3.14.0",
+    "qs": "^6.9.4",
     "react": "^16.13.1",
     "react-ace": "^9.1.3",
     "react-dom": "^16.13.1",

+ 101 - 0
dashboard/src/components/TabSelector.tsx

@@ -0,0 +1,101 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+export interface selectOption {
+  value: string,
+  label: string
+}
+
+type PropsType = {
+  options: selectOption[],
+  setCurrentTab: (value: string) => void,
+  tabWidth?: string  
+};
+
+type StateType = {
+  currentTab: string
+};
+
+export default class TabSelector extends Component<PropsType, StateType> {
+  state = {
+    currentTab: 'overview', 
+  }
+
+  renderLine = (tab: string): JSX.Element | undefined => {
+    if (this.state.currentTab === tab) {
+      return <Highlight />
+    }
+  };
+
+  handleTabClick = (value: string) => {
+    this.setState({ currentTab: value });
+    this.props.setCurrentTab(value);
+  }
+
+  renderTabList = () => {
+    return (
+      this.props.options.map((option: selectOption, i: number) => {
+        return (
+          <Tab
+            key={i}
+            onClick={() => this.handleTabClick(option.value)}
+            tabWidth={this.props.tabWidth}
+          >
+            {option.label}
+            {this.renderLine(option.value)}
+          </Tab>
+        );
+      })
+    );
+  }
+
+  render() {
+    return (
+      <StyledTabSelector>
+        {this.renderTabList()}
+      </StyledTabSelector>
+    );
+  }
+}
+
+const Highlight = styled.div`
+  width: 80%;
+  height: 1px;
+  margin-top: 5px;
+  background: #949EFFcc;
+
+  opacity: 0;
+  animation: lineEnter 0.5s 0s;
+  animation-fill-mode: forwards;
+  @keyframes lineEnter {
+    from { width: 0%; opacity: 0; }
+    to   { width: 80%; opacity: 1; }
+  }
+`; 
+
+const Tab = styled.div`
+  height: 30px;
+  width: ${(props: { tabWidth: string }) => props.tabWidth ? props.tabWidth : ''};
+  padding: 0 10px;
+  margin-right: 12px;
+  display: flex;
+  font-family: 'Work Sans', sans-serif;
+  font-size: 13px;
+  user-select: none;
+  color: #949effcc;
+  flex-direction: column;
+  padding-top: 7px;
+  align-items: center;
+  cursor: pointer;
+  white-space: nowrap;
+  border-radius: 5px;
+  
+  :hover {
+    background: #949EFF22;
+  }
+`;
+
+const StyledTabSelector = styled.div`
+  display: flex;
+  align-items: center;
+`;

+ 18 - 8
dashboard/src/components/YamlEditor.tsx

@@ -8,6 +8,8 @@ import 'ace-builds/src-noconflict/theme-terminal';
 type PropsType = {
   value: string,
   onChange: (e: any) => void,
+  height?: string,
+  border?: boolean
 }
 
 type StateType = {
@@ -40,7 +42,10 @@ class YamlEditor extends Component<PropsType, StateType> {
   render() {
     return (
       <Holder>
-        <Editor onSubmit={this.handleSubmit}>
+        <Editor
+          onSubmit={this.handleSubmit}
+          border={this.props.border}
+        >
           <AceEditor
             mode='yaml'
             value={this.props.value}
@@ -48,6 +53,7 @@ class YamlEditor extends Component<PropsType, StateType> {
             onChange={this.props.onChange}
             name='codeEditor'
             editorProps={{ $blockScrolling: true }}
+            height={this.props.height}
             width='100%'
             style={{ borderRadius: '5px' }}
           />
@@ -60,14 +66,18 @@ class YamlEditor extends Component<PropsType, StateType> {
 export default YamlEditor;
 
 const Editor = styled.form`
-  margin-top: 0px;
-  margin-bottom: 12px;
-  width: calc(100% - 0px);
-  border-radius: 5px;
-  border: 1px solid #ffffff22;
-  height: 295px;
-  overflow: auto;
+  border-radius: ${(props: { border: boolean }) => props.border ? '5px' : ''};
+  border: ${(props: { border: boolean }) => props.border ? '1px solid #ffffff22' : ''};
 `;
 
 const Holder = styled.div`
+  .ace_scrollbar {
+    display: none;
+  }
+  .ace_editor, .ace_editor * {
+    font-family: "Monaco", "Menlo", "Ubuntu Mono", "Droid Sans Mono", "Consolas", monospace !important;
+    font-size: 12px !important;
+    font-weight: 400 !important;
+    letter-spacing: 0 !important;
+  }
 `;

+ 1 - 1
dashboard/src/main/home/Home.tsx

@@ -41,7 +41,7 @@ export default class Home extends Component<PropsType, StateType> {
       return <Loading />
     }
 
-    return <DashboardWrapper><Dashboard /></DashboardWrapper>
+    return <DashboardWrapper><Dashboard currentCluster={currentCluster} /></DashboardWrapper>
   }
 
   render() {

+ 57 - 8
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -3,26 +3,66 @@ import styled from 'styled-components';
 import gradient from '../../../assets/gradient.jpg';
 
 import { Context } from '../../../shared/Context';
+import { ChartType, StorageType } from '../../../shared/types';
+import api from '../../../shared/api';
 
 import ChartList from './chart/ChartList';
 import NamespaceSelector from './NamespaceSelector';
+import ExpandedChart from './expanded-chart/ExpandedChart';
 
 type PropsType = {
+  currentCluster: string
 };
 
 type StateType = {
-  namespace: string
+  namespace: string,
+  currentChart: ChartType | null
 };
 
 export default class Dashboard extends Component<PropsType, StateType> {
   state = {
-    namespace: ''
+    namespace: '',
+    currentChart: null as (ChartType | null)
   }
 
-  render() {
-    let { currentCluster } = this.context;
+  componentDidUpdate(prevProps: PropsType) {
 
-    return ( 
+    // Reset namespace filter and close expanded chart on cluster change
+    if (prevProps.currentCluster !== this.props.currentCluster) {
+      this.setState({ namespace: '', currentChart: null });
+    }
+  }
+
+  // Allows rollback to update the top-level chart
+  refreshChart = () => {
+    let { currentCluster } = this.props;
+    api.getChart('<token>', {
+      namespace: this.state.namespace,
+      context: currentCluster,
+      storage: StorageType.Secret
+    }, { name: this.state.currentChart.name, revision: 0 }, (err: any, res: any) => {
+      if (err) {
+        console.log(err)
+      } else {
+        this.setState({ currentChart: res.data });
+      }
+    });
+  }
+
+  renderContents = () => {
+    let { currentCluster } = this.props;
+
+    if (this.state.currentChart) {
+      return (
+        <ExpandedChart
+          currentChart={this.state.currentChart}
+          refreshChart={this.refreshChart}
+          setCurrentChart={(x: ChartType | null) => this.setState({ currentChart: x })}
+        />
+      );
+    }
+
+    return (
       <div>
         <TitleSection>
           <ProjectIcon>
@@ -39,14 +79,14 @@ export default class Dashboard extends Component<PropsType, StateType> {
               <i className="material-icons">info</i> Info
             </InfoLabel>
           </TopRow>
-            <Description>Porter dashboard for {currentCluster}.</Description>
+          <Description>Porter dashboard for {currentCluster}.</Description>
         </InfoSection>
 
         <LineBreak />
         
         <ControlRow>
           <Button disabled={true}>
-            <i className="material-icons">add</i> Add a Chart
+            <i className="material-icons">add</i> Deploy a Chart
           </Button>
           <NamespaceSelector
             setNamespace={(namespace) => this.setState({ namespace })}
@@ -58,10 +98,19 @@ export default class Dashboard extends Component<PropsType, StateType> {
         <ChartList
           currentCluster={currentCluster}
           namespace={this.state.namespace}
+          setCurrentChart={(x: ChartType) => this.setState({ currentChart: x })}
         />
       </div>
     );
   }
+
+  render() {
+    return (
+      <div>
+        {this.renderContents()}
+      </div>
+    );
+  }
 }
 
 Dashboard.contextType = Context;
@@ -142,7 +191,7 @@ const Button = styled.div`
     border-radius: 20px;
     display: flex;
     align-items: center;
-    margin-right: 8px;
+    margin-right: 5px;
     justify-content: center;
   }
 `;

+ 3 - 2
dashboard/src/main/home/dashboard/NamespaceSelector.tsx

@@ -29,9 +29,10 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
 
     api.getNamespaces('<token>', { context: currentCluster }, {}, (err: any, res: any) => {
       if (err) {
-        setCurrentError('Could not read clusters: ' + JSON.stringify(err));
+        // setCurrentError('Could not read clusters: ' + JSON.stringify(err));
+        this.setState({ namespaceOptions: [{ label: 'All', value: '' }] });
       } else {
-        let namespaceOptions: { label: string, value: string }[] = [];
+        let namespaceOptions: { label: string, value: string }[] = [{ label: 'All', value: '' }];
         res.data.items.forEach((x: { metadata: { name: string }}, i: number) => {
           namespaceOptions.push({ label: x.metadata.name, value: x.metadata.name });
         })

+ 44 - 25
dashboard/src/main/home/dashboard/chart/Chart.tsx

@@ -2,9 +2,11 @@ import React, { Component } from 'react';
 import styled from 'styled-components';
 
 import { ChartType } from '../../../../shared/types';
+import { Context } from '../../../../shared/Context';
 
 type PropsType = {
-  chart: ChartType
+  chart: ChartType,
+  setCurrentChart: (c: ChartType) => void
 };
 
 type StateType = {
@@ -33,12 +35,14 @@ export default class Chart extends Component<PropsType, StateType> {
   }
 
   render() {
-    let { chart } = this.props;
+    let { chart, setCurrentChart } = this.props;
+
     return ( 
       <StyledChart
         onMouseEnter={() => this.setState({ expand: true })}
         onMouseLeave={() => this.setState({ expand: false })}
         expand={this.state.expand}
+        onClick={() => setCurrentChart(chart)}
       >
         <Title>
           <IconWrapper>
@@ -47,30 +51,42 @@ export default class Chart extends Component<PropsType, StateType> {
           {chart.name}
         </Title>
 
-        <InfoWrapper>
-          <StatusIndicator>
-            <StatusColor status={chart.info.status} />
-            {chart.info.status}
-          </StatusIndicator>
+        <BottomWrapper>
+          <InfoWrapper>
+            <StatusIndicator>
+              <StatusColor status={chart.info.status} />
+              {chart.info.status}
+            </StatusIndicator>
 
-          <LastDeployed>
-            <Dot>•</Dot> Last deployed {this.readableDate(chart.info.last_deployed)}
-          </LastDeployed>
-        </InfoWrapper>
+            <LastDeployed>
+              <Dot>•</Dot> Last deployed {this.readableDate(chart.info.last_deployed)}
+            </LastDeployed>
+          </InfoWrapper>
 
-        <Version>v{chart.version}</Version>
+          <TagWrapper>
+            Namespace
+            <NamespaceTag>
+              {chart.namespace}
+            </NamespaceTag>
+          </TagWrapper>
+        </BottomWrapper>
 
-        <TagWrapper>
-          Namespace
-          <NamespaceTag>
-            {chart.namespace}
-          </NamespaceTag>
-        </TagWrapper>
+        <Version>v{chart.version}</Version>
       </StyledChart>
     );
   }
 }
 
+Chart.contextType = Context;
+
+const BottomWrapper = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding-right: 11px;
+  margin-top: 12px;
+`;
+
 const Version = styled.div`
   position: absolute;
   top: 12px;
@@ -86,7 +102,10 @@ const Dot = styled.div`
 const InfoWrapper = styled.div`
   display: flex;
   align-items: center;
-  margin-top: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  margin-right: 8px;
 `;
 
 const LastDeployed = styled.div`
@@ -99,9 +118,6 @@ const LastDeployed = styled.div`
 `;
 
 const TagWrapper = styled.div`
-  position: absolute;
-  bottom: 12px;
-  right: 12px;
   height: 20px;
   font-size: 12px;
   display: flex;
@@ -127,6 +143,9 @@ const NamespaceTag = styled.div`
   padding-left: 7px;
   border-top-left-radius: 0px;
   border-bottom-left-radius: 0px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
 `;
 
 const Icon = styled.img`
@@ -219,13 +238,13 @@ const StyledChart = styled.div`
 
   animation: ${(props: { expand: boolean }) => props.expand ? 'expand' : 'shrink'} 0.12s;
   animation-fill-mode: forwards;
-  animation-timing-function: ease;
+  animation-timing-function: ease-out;
 
   @keyframes expand {
     from { 
       width: calc(100% + 2px); 
       padding-top: 4px;
-      padding-bottom: 15px;
+      padding-bottom: 14px;
       margin-left: 0px;
       box-shadow: 0 5px 8px 0px #00000033;
       padding-left: 1px;
@@ -258,7 +277,7 @@ const StyledChart = styled.div`
     to {
       width: calc(100% + 2px); 
       padding-top: 4px;
-      padding-bottom: 15px;
+      padding-bottom: 14px;
       margin-left: 0px; 
       box-shadow: 0 5px 8px 0px #00000033;
       padding-left: 1px;

+ 59 - 12
dashboard/src/main/home/dashboard/chart/ChartList.tsx

@@ -3,48 +3,59 @@ import styled from 'styled-components';
 
 import { Context } from '../../../../shared/Context';
 import api from '../../../../shared/api';
-import { ChartType } from '../../../../shared/types';
+import { ChartType, StorageType } from '../../../../shared/types';
 
 import Chart from './Chart';
 import Loading from '../../../../components/Loading';
 
 type PropsType = {
   currentCluster: string,
-  namespace: string
+  namespace: string,
+  setCurrentChart: (c: ChartType) => void
 };
 
 type StateType = {
   charts: ChartType[],
-  loading: boolean
+  loading: boolean,
+  error: boolean
 };
 
 export default class ChartList extends Component<PropsType, StateType> {
   state = {
     charts: [] as ChartType[],
     loading: false,
+    error: false,
   }
 
   updateCharts = () => {
-    let { setCurrentError, currentCluster } = this.context;
-    
+    let { currentCluster } = this.context;
+
     this.setState({ loading: true });
+    setTimeout(() => {
+      if (this.state.loading) {
+        this.setState({ loading: false, error: true });
+      }
+    }, 1000);
+
     api.getCharts('<token>', {
       namespace: this.props.namespace,
       context: currentCluster,
-      storage: 'secret',
+      storage: StorageType.Secret,
       limit: 20,
       skip: 0,
       byDate: false,
       statusFilter: ['deployed']
     }, {}, (err: any, res: any) => {
       if (err) {
-        setCurrentError(JSON.stringify(err));
-        this.setState({ loading: false });
+        // setCurrentError(JSON.stringify(err));
+        this.setState({ loading: false, error: true });
       } else {
         if (res.data) {
           this.setState({ charts: res.data });
+        } else {
+          this.setState({ charts: [] });
         }
-        this.setState({ loading: false });
+        this.setState({ loading: false, error: false });
       }
     });
   }
@@ -54,19 +65,40 @@ export default class ChartList extends Component<PropsType, StateType> {
   }
 
   componentDidUpdate(prevProps: PropsType) {
-    if (prevProps !== this.props) {
+
+    // Ret2: Prevents reload when opening ClusterConfigModal
+    if (prevProps.currentCluster !== this.props.currentCluster || 
+      prevProps.namespace !== this.props.namespace) {
       this.updateCharts();
     }
   }
 
   renderChartList = () => {
-    if (this.state.loading) {
+    let { loading, error, charts } = this.state;
+
+    if (loading) {
       return <LoadingWrapper><Loading /></LoadingWrapper>
+    } else if (error) {
+      return (
+        <Placeholder>
+          <i className="material-icons">error</i> Error connecting to cluster.
+        </Placeholder>
+      );
+    } else if (charts.length === 0) {
+      return (
+        <Placeholder>
+          <i className="material-icons">category</i> No charts found in this namespace.
+        </Placeholder>
+      );
     }
 
     return this.state.charts.map((x: ChartType, i: number) => {
       return (
-        <Chart key={i} chart={x} />
+        <Chart
+          key={i}
+          chart={x}
+          setCurrentChart={this.props.setCurrentChart}
+        />
       )
     })
   }
@@ -83,6 +115,21 @@ export default class ChartList extends Component<PropsType, StateType> {
 
 ChartList.contextType = Context;
 
+const Placeholder = styled.div`
+  padding-top: 100px;
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: #ffffff44;
+  font-size: 14px;
+
+  > i {
+    font-size: 18px;
+    margin-right: 12px;
+  }
+`;
+
 const LoadingWrapper = styled.div`
   padding-top: 100px;
 `;

+ 342 - 0
dashboard/src/main/home/dashboard/expanded-chart/ExpandedChart.tsx

@@ -0,0 +1,342 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import close from '../../../../assets/close.png';
+
+import { ChartType } from '../../../../shared/types';
+import { Context } from '../../../../shared/Context';
+
+import TabSelector from '../../../../components/TabSelector';
+import RevisionSection from './RevisionSection';
+import ValuesYaml from './ValuesYaml';
+import OverviewSection from './OverviewSection';
+
+type PropsType = {
+  currentChart: ChartType,
+  setCurrentChart: (x: ChartType | null) => void,
+  refreshChart: () => void
+};
+
+type StateType = {
+  showRevisions: boolean,
+  currentTab: string,
+  isExpanded: boolean
+};
+
+const tabOptions = [
+  { label: 'Chart Overview', value: 'overview' },
+  { label: 'Values Editor', value: 'values' }
+]
+
+export default class ExpandedChart extends Component<PropsType, StateType> {
+  state = {
+    showRevisions: false,
+    currentTab: 'overview',
+    isExpanded: false,
+  }
+
+  renderIcon = () => {
+    let { currentChart } = this.props;
+
+    if (currentChart.chart.metadata.icon && currentChart.chart.metadata.icon !== '') {
+      return <Icon src={currentChart.chart.metadata.icon} />
+    } else {
+      return <i className="material-icons">tonality</i>
+    }
+  }
+
+  readableDate = (s: string) => {
+    let ts = new Date(s);
+    let date = ts.toLocaleDateString();
+    let time = ts.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
+    return `${time} on ${date}`;
+  }
+
+  renderTabContents = () => {
+    let { currentChart, refreshChart } = this.props;
+
+    if (this.state.currentTab === 'overview') {
+      return (
+        <OverviewSection
+          toggleExpanded={() => this.setState({ isExpanded: !this.state.isExpanded })}
+          isExpanded={this.state.isExpanded}
+        />
+      );
+    }
+
+    return (
+      <ValuesYaml
+        currentChart={currentChart}
+        refreshChart={refreshChart}
+      />
+    );
+  }
+
+  renderInfo = () => {
+    let { currentChart, setCurrentChart, refreshChart } = this.props;
+    let chart = currentChart;
+
+    if (!this.state.isExpanded) {
+      return (
+        <HeaderWrapper>
+          <TitleSection>
+            <Title>
+              <IconWrapper>
+                {this.renderIcon()}
+              </IconWrapper>
+              {chart.name}
+            </Title>
+            <InfoWrapper>
+              <StatusIndicator>
+                <StatusColor status={chart.info.status} />
+                {chart.info.status}
+              </StatusIndicator>
+
+              <LastDeployed>
+                <Dot>•</Dot>Last deployed {this.readableDate(chart.info.last_deployed)}
+              </LastDeployed>
+            </InfoWrapper>
+
+            <TagWrapper>
+              Namespace
+            <NamespaceTag>
+                {chart.namespace}
+              </NamespaceTag>
+            </TagWrapper>
+          </TitleSection>
+
+          <CloseButton onClick={() => setCurrentChart(null)}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
+
+          <RevisionSection
+            showRevisions={this.state.showRevisions}
+            toggleShowRevisions={() => this.setState({ showRevisions: !this.state.showRevisions })}
+            chart={chart}
+            refreshChart={refreshChart}
+          />
+
+          <TabSelector
+            options={tabOptions}
+            setCurrentTab={(value: string) => this.setState({ currentTab: value })}
+            tabWidth='120px'
+          />
+        </HeaderWrapper>
+      );
+    }
+
+    return (
+      <HeaderWrapper>
+        <TitleSection>
+          <Title>
+            <IconWrapper>
+              {this.renderIcon()}
+            </IconWrapper>
+            {chart.name}
+          </Title>
+        </TitleSection>
+
+        <CloseButton onClick={() => setCurrentChart(null)}>
+          <CloseButtonImg src={close} />
+        </CloseButton>
+      </HeaderWrapper>
+    );
+  }
+
+  render() {
+    let { currentChart, setCurrentChart, refreshChart } = this.props;
+    let chart = currentChart;
+
+    return ( 
+      <div>
+        <CloseOverlay onClick={() => setCurrentChart(null)}/>
+        <StyledExpandedChart>
+          {this.renderInfo()}
+          <ContentSection>
+            {this.renderTabContents()}
+          </ContentSection>
+        </StyledExpandedChart>
+      </div>
+    );
+  }
+}
+
+ExpandedChart.contextType = Context;
+
+const CloseOverlay = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+`;
+
+const HeaderWrapper = styled.div`
+  margin-bottom: 20px;
+`;
+
+const ContentSection = styled.div`
+  display: flex;
+  border-radius: 5px;
+  flex: 1;
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  font-size: 13px;
+  overflow-y: auto;
+`;
+
+const StatusColor = styled.div`
+  margin-bottom: 1px;
+  width: 8px;
+  height: 8px;
+  background: ${(props: { status: string }) => (props.status === 'deployed' ? '#4797ff' : props.status === 'failed' ? "#ed5f85" : "#f5cb42")};
+  border-radius: 20px;
+  margin-right: 16px;
+`;
+
+const Dot = styled.div`
+  margin-right: 9px;
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-left: 6px;
+  margin-top: 22px;
+`;
+
+const LastDeployed = styled.div`
+  font-size: 13px;
+  margin-left: 10px;
+  margin-top: -1px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb66;
+`;
+
+const TagWrapper = styled.div`
+  position: absolute;
+  bottom: 0px;
+  right: 0px;
+  height: 20px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  border: 1px solid #ffffff44;
+  border-radius: 3px;
+  padding-left: 5px;
+  background: #26282E;
+`;
+
+const NamespaceTag = styled.div`
+  height: 20px;
+  margin-left: 6px;
+  color: #aaaabb;
+  background: #43454A;
+  border-radius: 3px;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0px 6px;
+  padding-left: 7px;
+  border-top-left-radius: 0px;
+  border-bottom-left-radius: 0px;
+`;
+
+const StatusIndicator = styled.div`
+  display: flex;
+  height: 20px;
+  font-size: 13px;
+  flex-direction: row;
+  text-transform: capitalize;
+  align-items: center;
+  font-family: 'Hind Siliguri', sans-serif;
+  color: #aaaabb;
+  animation: fadeIn 0.5s;
+
+  @keyframes fadeIn {
+    from { opacity: 0 }
+    to { opacity: 1 }
+  }
+`;
+
+const Icon = styled.img`
+  width: 100%;
+`;
+
+const IconWrapper = styled.div`
+  color: #efefef;
+  font-size: 16px;
+  height: 20px;
+  width: 20px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 3px;
+  margin-right: 12px;
+
+  > i {
+    font-size: 20px;
+  }
+`;
+
+const Title = styled.div`
+  font-size: 18px;
+  font-weight: 500;
+  display: flex;
+  align-items: center;
+`;
+
+const TitleSection = styled.div`
+  width: 100%;
+  position: relative;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const StyledExpandedChart = styled.div`
+  width: calc(100% - 50px);
+  height: calc(100% - 50px);
+  background: red;
+  z-index: 0;
+  position: absolute;
+  top: 25px;
+  left: 25px;;
+  border-radius: 10px;
+  background: #26282f;
+  box-shadow: 0 5px 12px 4px #00000033;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  padding: 25px; 
+  display: flex;
+  flex-direction: column;
+
+  @keyframes floatIn {
+    from { opacity: 0; transform: translateY(30px) }
+    to { opacity: 1; transform: translateY(0px) }
+  }
+`;

+ 184 - 0
dashboard/src/main/home/dashboard/expanded-chart/OverviewSection.tsx

@@ -0,0 +1,184 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { ResourceType } from '../../../../shared/types';
+
+import ResourceItem from './ResourceItem';
+
+type PropsType = {
+  toggleExpanded: () => void,
+  isExpanded: boolean
+};
+
+type StateType = {
+  viewMode: string,
+  showKindLabels: boolean
+};
+
+const dummyObjects = [
+  {
+    kind: 'deployment', name: 'radical-conspirator', rawYaml: {
+      stuff: {
+        idk: 'test'
+      }
+    }
+  },
+  { kind: 'service', name: 'fawkes-guy', rawYaml: {
+      stuff: {
+        idk: 'test'
+      }
+    }
+  },
+  { kind: 'ingress', name: 'bellion-john', rawYaml: {
+      stuff: {
+        idk: 'test'
+      }
+    }
+  },
+  { kind: 'pod', name: 'tenet-tenet', rawYaml: {
+      stuff: {
+        idk: 'test'
+      }
+    }
+  },
+  { kind: 'statefulset', name: 'brokerage-farm', rawYaml: {
+      stuff: {
+        idk: 'test'
+      }
+    }
+  }
+];
+
+export default class OverviewSection extends Component<PropsType, StateType> {
+  state = {
+    viewMode: 'graph',
+    showKindLabels: true
+  }
+
+  renderResourceList = () => {
+    return dummyObjects.map((resource: ResourceType, i: number) => {
+      return (
+        <ResourceItem
+          key={i}
+          resource={resource}
+          toggleKindLabels={() => this.setState({ showKindLabels: !this.state.showKindLabels })}
+          showKindLabels={this.state.showKindLabels}
+        />
+      );
+    });
+  }
+
+  renderContents = () => {
+    if (this.state.viewMode === 'list') {
+      return (
+        <ResourceList>
+          {this.renderResourceList()}
+        </ResourceList>
+      )
+    }
+  }
+
+  render() {
+    return (
+      <StyledOverviewSection>
+        {this.renderContents()}
+
+        <ButtonSection>
+          <RadioButtons>
+            <RadioOption
+              nudge={true}
+              selected={this.state.viewMode === 'graph'}
+              onClick={() => this.setState({ viewMode: 'graph' })}
+            >
+              <i className="material-icons">device_hub</i> Graph
+            </RadioOption>
+            <RadioOption
+              selected={this.state.viewMode === 'list'}
+              onClick={() => this.setState({ viewMode: 'list' })}
+            >
+              <i className="material-icons">dehaze</i> List
+            </RadioOption>
+          </RadioButtons>
+          <ExpandButton
+            onClick={this.props.toggleExpanded}
+            isExpanded={this.props.isExpanded}
+          >
+            <i className="material-icons">
+              {this.props.isExpanded ? 'close_fullscreen' : 'open_in_full'}
+            </i>
+          </ExpandButton>
+        </ButtonSection>
+      </StyledOverviewSection>
+    );
+  }
+}
+
+const ResourceList = styled.div`
+  width: 100%;
+  overflow-y: auto;
+  padding-bottom: 150px;
+`;
+
+const RadioOption = styled.div`
+  width: 80px;
+  padding-right: 5px;
+  height: 22px;
+  background: ${(props: { selected: boolean, nudge?: boolean }) => props.selected ? '#6A6C70' : '#424349'};
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  justify-content: center;
+  
+  > i {
+    margin-top: ${(props: { nudge?: boolean, selected: boolean }) => props.nudge ? '-1px' : ''};
+    font-size: 15px;
+    margin-right: 8px;
+  }
+`;
+
+const RadioButtons = styled.div`
+  display: flex;
+  align-items: center;
+  border-radius: 3px;
+  border: 1px solid #ffffff44;
+  font-size: 12px;
+  font-family: 'Works Sans', sans-serif;
+  overflow: hidden;
+`;
+
+const ButtonSection = styled.div`
+  position: absolute;
+  top: 17px;
+  right: 15px;
+  display: flex;
+  align-items: center;
+`;
+
+const ExpandButton = styled.div`
+  width: 24px;
+  height: 24px;
+  cursor: pointer;
+  margin-left: 10px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 3px;
+  border: 1px solid #ffffff44;
+  background: ${(props: { isExpanded: boolean }) => props.isExpanded ? '#ffffff44' : ''};
+
+  :hover {
+    background: #ffffff44; 
+  }
+
+  > i {
+    font-size: 14px;
+  }
+`;
+
+const StyledOverviewSection = styled.div`
+  width: 100%;
+  height: 100%;
+  background: #ffffff11;
+  display: flex;
+  position: relative;
+`;

+ 169 - 0
dashboard/src/main/home/dashboard/expanded-chart/ResourceItem.tsx

@@ -0,0 +1,169 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { ResourceType } from '../../../../shared/types';
+import YamlEditor from '../../../../components/YamlEditor';
+
+const kindToIcon: any = {
+  'deployment': 'category',
+  'pod': 'fiber_manual_record',
+  'service': 'alt_route',
+  'ingress': 'sensor_door',
+  'statefulset': 'location_city',
+  'secret': 'vpn_key',
+}
+
+type PropsType = {
+  resource: ResourceType,
+  toggleKindLabels: () => void,
+  showKindLabels: boolean
+};
+
+type StateType = {
+  expanded: boolean,
+  rawYaml: string
+};
+
+// A single resource block in the expanded chart list view
+export default class ResourceItem extends Component<PropsType, StateType> {
+  state = {
+    expanded: false,
+    rawYaml: '# this is placeholder yaml'
+  }
+
+  renderIcon = (kind: string) => {
+
+    let icon = 'tonality';
+    if (Object.keys(kindToIcon).includes(kind)) {
+      icon = kindToIcon[kind]; 
+    }
+    
+    return (
+      <IconWrapper>
+        <i className="material-icons">{icon}</i>
+      </IconWrapper>
+    );
+  }
+
+  renderExpanded = () => {
+    if (this.state.expanded) {
+      return (
+        <ExpandWrapper>
+          <YamlEditor
+            value={this.state.rawYaml}
+            onChange={(e: any) => this.setState({ rawYaml: e })}
+            height='300px'
+          />
+        </ExpandWrapper>
+      );
+    }
+  }
+
+  render() {
+    let { resource, showKindLabels, toggleKindLabels } = this.props;
+    return (
+      <StyledResourceItem>
+        <ResourceHeader
+          expanded={this.state.expanded}
+          onClick={() => this.setState({ expanded: !this.state.expanded })}
+        >
+          <i className="material-icons">arrow_right</i>
+
+          <ClickWrapper onClick={toggleKindLabels}>
+            {this.renderIcon(resource.kind)}
+            {showKindLabels ? `${resource.kind}` : null}
+          </ClickWrapper>
+
+          <ResourceName
+            showKindLabels={showKindLabels}
+          >
+            {resource.name}
+          </ResourceName>
+        </ResourceHeader>
+        {this.renderExpanded()}
+      </StyledResourceItem>
+    );
+  }
+}
+
+const ExpandWrapper = styled.div`
+  padding: 12px;
+  animation: expandResource 0.3s;
+  animation-timing-function: ease-out;
+  overflow: hidden;
+  @keyframes expandResource {
+    from { height: 0px }
+    to { height: 300px }
+  }
+`;
+
+const StyledResourceItem = styled.div`
+  border-bottom: 1px solid #606166;
+`;
+
+const BigPlaceholder = styled.div`
+  height: 200px;
+  width: 100%;
+  background: blue;
+`;
+
+const ClickWrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const ResourceName = styled.div`
+  color: #ffffff;
+  margin-left: ${(props: { showKindLabels: boolean }) => props.showKindLabels ? '10px' : ''};
+  text-transform: none;
+`;
+
+const IconWrapper = styled.div`
+  width: 25px;
+  height: 25px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  > i {
+    font-size: 16px;
+    color: #ffffff;
+    margin-right: 14px;
+  }
+`;
+
+const ResourceHeader = styled.div`
+  width: 100%;
+  height: 60px;
+  display: flex;
+  color: #ffffff66;
+  align-items: center;
+  padding: 15px 13px;
+  text-transform: capitalize;
+  cursor: pointer;
+  background: ${(props: { expanded: boolean }) => props.expanded ? '#ffffff11' : ''};
+  :hover {
+    background: #ffffff18;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > i {
+    margin-right: 13px;
+    font-size: 20px;
+    color: #ffffff66;
+    cursor: pointer;
+    border-radius: 20px;
+    background: ${(props: { expanded: boolean }) => props.expanded ? '#ffffff18' : ''};
+    transform: ${(props: { expanded: boolean }) => props.expanded ? 'rotate(180deg)' : ''};
+    animation: ${(props: { expanded: boolean }) => props.expanded ? 'quarterTurn 0.3s' : ''};
+    animation-fill-mode: forwards;
+
+    @keyframes quarterTurn {
+      from { transform: rotate(0deg) }
+      to { transform: rotate(90deg) }
+    }
+  }
+`;

+ 362 - 0
dashboard/src/main/home/dashboard/expanded-chart/RevisionSection.tsx

@@ -0,0 +1,362 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import loading from '../../../../assets/loading.gif';
+
+import api from '../../../../shared/api';
+import { Context } from '../../../../shared/Context';
+import { ChartType, StorageType } from '../../../../shared/types';
+import Chart from '../chart/Chart';
+
+type PropsType = {
+  showRevisions: boolean,
+  toggleShowRevisions: () => void,
+  chart: ChartType,
+  refreshChart: () => void
+};
+
+type StateType = {
+  revisions: ChartType[],
+  rollbackRevision: number | null,
+  loading: boolean
+};
+
+export default class RevisionSection extends Component<PropsType, StateType> {
+  state = {
+    revisions: [] as ChartType[],
+    rollbackRevision: null as (number | null),
+    loading: false
+  }
+
+  refreshHistory = () => {
+    let { chart } = this.props;
+
+    api.getRevisions('<token>', {
+      namespace: chart.namespace,
+      context: this.context.currentCluster,
+      storage: StorageType.Secret
+    }, { name: chart.name }, (err: any, res: any) => {
+      if (err) {
+        console.log(err)
+      } else {
+        this.setState({ revisions: res.data.reverse() });
+      }
+    });
+  }
+
+  componentDidMount() {
+    this.refreshHistory();
+  }
+
+  // Handle update of values.yaml
+  componentDidUpdate(prevProps: PropsType) {
+    if (this.props.chart !== prevProps.chart) {
+      this.refreshHistory();
+    }
+  }
+
+  readableDate = (s: string) => {
+    let ts = new Date(s);
+    let date = ts.toLocaleDateString();
+    let time = ts.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
+    return `${time} on ${date}`;
+  }
+
+  handleRollback = () => {
+    let { setCurrentError, currentCluster } = this.context;
+
+    let revisionNumber = this.state.rollbackRevision;
+    this.setState({ loading: true, rollbackRevision: null });
+
+    api.rollbackChart('<token>', {
+      namespace: this.props.chart.namespace,
+      context: currentCluster,
+      storage: StorageType.Secret,
+      revision: revisionNumber
+    }, {
+      name: this.props.chart.name
+    }, (err: any, res: any) => {
+      if (err) {
+        setCurrentError(err.response.data.errors[0]);
+        this.setState({ loading: false });
+      } else {
+        this.setState({ loading: false });
+        this.props.refreshChart();
+        this.refreshHistory();
+      }
+    });
+  }
+
+  renderRevisionList = () => {
+    return this.state.revisions.map((revision: any, i: number) => {
+      return (
+        <Tr key={i}>
+          <Td>{revision.version}</Td>
+          <Td>{this.readableDate(revision.info.last_deployed)}</Td>
+          <Td>{revision.info.status}</Td>
+          <Td>
+            <RollbackButton
+              disabled={revision.version === this.props.chart.version}
+              onClick={() => this.setState({ rollbackRevision: revision.version })}
+            >
+              {revision.version === this.props.chart.version ? 'Current' : 'Revert'}
+            </RollbackButton>
+          </Td>
+        </Tr>
+      );
+    });
+  }
+
+  renderExpanded = () => {
+    if (this.props.showRevisions) {
+      return (
+        <RevisionsTable>
+          <tbody>
+            <Tr>
+              <Th>Revision No.</Th>
+              <Th>Timestamp</Th>
+              <Th>Status</Th>
+              <Th>Rollback</Th>
+            </Tr>
+            {this.renderRevisionList()}
+          </tbody>
+        </RevisionsTable>
+      )
+    }
+  }
+
+  renderConfirmOverlay = () => {
+    if (this.state.rollbackRevision) {
+      return (
+        <ConfirmOverlay>
+          {`Are you sure you want to revert to version ${this.state.rollbackRevision}?`}
+          <ButtonRow>
+            <ConfirmButton
+              onClick={() => this.handleRollback()}
+            >
+              Yes
+            </ConfirmButton>
+            <ConfirmButton
+              onClick={() => this.setState({ rollbackRevision: null })}
+            >
+              No
+            </ConfirmButton>
+          </ButtonRow>
+        </ConfirmOverlay>
+      );
+    }
+  }
+
+  renderContents = () => {
+    if (this.state.loading) {
+      return (
+        <LoadingPlaceholder>
+          <StatusWrapper>
+            <LoadingGif src={loading} /> Updating . . .
+          </StatusWrapper>
+        </LoadingPlaceholder>
+      )
+    }
+
+    return (
+      <div>
+        <RevisionHeader
+          showRevisions={this.props.showRevisions}
+          onClick={this.props.toggleShowRevisions}
+        >
+          Current Revision - <Revision>No. {this.props.chart.version}</Revision>
+          <i className="material-icons">expand_more</i>
+        </RevisionHeader>
+
+        <RevisionList>
+          {this.renderExpanded()}
+        </RevisionList>
+      </div>
+    );
+  }
+
+  render() {
+    return (
+      <StyledRevisionSection showRevisions={this.props.showRevisions}>
+        {this.renderContents()}
+        {this.renderConfirmOverlay()}
+      </StyledRevisionSection>
+    );
+  }
+}
+
+RevisionSection.contextType = Context;
+
+const LoadingPlaceholder = styled.div`
+  height: 40px;
+  display: flex;
+  align-items: center;
+  padding-left: 20px;
+`;
+
+const LoadingGif = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 9px;
+  margin-bottom: 0px;
+`;
+
+const StatusWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  font-family: 'Work Sans', sans-serif;
+  font-size: 13px;
+  color: #ffffff55;
+  margin-right: 25px;
+`;
+
+const ConfirmOverlay = styled.div`
+  position: absolute;
+  top: 0px;
+  opacity: 100%;
+  left: 0px;
+  width: 100%;
+  height: 100%;
+  z-index: 999;
+  display: flex;
+  padding-bottom: 30px;
+  align-items: center;
+  justify-content: center;
+  font-family: 'Work Sans', sans-serif;
+  font-size: 18px;
+  font-weight: 500;
+  color: white;
+  flex-direction: column;
+  background: rgb(0,0,0,0.73);
+  opacity: 0;
+  animation: lindEnter 0.2s;
+  animation-fill-mode: forwards;
+
+  @keyframes lindEnter {
+    from { opacity: 0; }
+    to   { opacity: 1; }
+  }
+`;
+
+const ButtonRow = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 180px;
+  margin-top: 30px;
+`;
+
+const ConfirmButton = styled.div`
+  font-size: 18px;
+  padding: 10px 15px;
+  outline: none; 
+  border: 1px solid white;
+  border-radius: 10px; 
+  text-align: center; 
+  width: 80px;
+  cursor: pointer;
+  opacity: 0;
+  font-family: 'Work Sans', sans-serif;
+  font-size: 18px;
+  font-weight: 500;
+  animation: linEnter 0.3s 0.1s;
+  animation-fill-mode: forwards;
+  @keyframes linEnter {
+    from { transform: translateY(20px); opacity: 0; }
+    to   { transform: translateY(0px); opacity: 1; }
+  }
+  :hover {
+    background: white;
+    color: #232323;
+  }
+`;
+
+const RevisionList = styled.div`
+  overflow-y: auto;
+  max-height: 215px;
+`;
+
+const RollbackButton = styled.div`
+  cursor: ${(props: { disabled: boolean }) => props.disabled ? 'not-allowed' :'pointer'};
+  display: flex;
+  border-radius: 3px;
+  align-items: center;
+  justify-content: center;
+  font-weight: 500;
+  height: 21px;
+  font-size: 13px;
+  width: 70px;
+  background: ${(props: { disabled: boolean }) => props.disabled ? '#aaaabbee' :'#616FEEcc'};
+  :hover {
+    background: ${(props: { disabled: boolean }) => props.disabled ? '' : '#405eddbb'};
+  }
+`;
+
+const Tr = styled.tr`
+  line-height: 1.8em;
+`;
+
+const Td = styled.td`
+  font-size: 13px;
+  color: #ffffff;
+`;
+
+const Th = styled.td`
+  font-size: 13px;
+  font-weight: 500;
+  color: #aaaabb;
+`;
+
+const RevisionsTable = styled.table`
+  width: 100%;
+  margin-top: 5px;
+  padding-left: 32px;
+  padding-bottom: 20px;
+  min-width: 500px;
+`;
+
+const Revision = styled.div`
+  color: #ffffff;
+  margin-left: 5px;
+`;
+
+const RevisionHeader = styled.div`
+  color: #ffffff66;
+  display: flex;
+  align-items: center;
+  height: 40px;
+  font-size: 14px;
+  width: 100%;
+  padding-left: 15px;
+  cursor: pointer;
+  background: ${(props: { showRevisions: boolean }) => props.showRevisions ? '#ffffff11' : ''};
+  :hover {
+    background: #ffffff18;
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > i {
+    margin-left: 12px;
+    font-size: 20px;
+    cursor: pointer;
+    border-radius: 20px;
+    background: ${(props: { showRevisions: boolean }) => props.showRevisions ? '#ffffff18' : ''};
+    transform: ${(props: { showRevisions: boolean }) => props.showRevisions ? 'rotate(180deg)' : ''};
+  }
+`;
+
+const StyledRevisionSection = styled.div`
+  width: 100%;
+  max-height: ${(props: { showRevisions: boolean }) => props.showRevisions ? '255px' : '40px'};
+  background: #ffffff11;
+  margin: 25px 0px;
+  overflow: hidden;
+  border-radius: 5px;
+  animation: ${(props: { showRevisions: boolean }) => props.showRevisions ? 'expandRevisions 0.3s' : ''};
+  animation-timing-function: ease-out;
+  @keyframes expandRevisions {
+    from { max-height: 40px }
+    to { max-height: 250px }
+  }
+`;

+ 96 - 0
dashboard/src/main/home/dashboard/expanded-chart/ValuesYaml.tsx

@@ -0,0 +1,96 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import yaml from 'js-yaml';
+
+import { ChartType, StorageType } from '../../../../shared/types';
+import api from '../../../../shared/api';
+import { Context } from '../../../../shared/Context';
+
+import YamlEditor from '../../../../components/YamlEditor';
+import SaveButton from '../../../../components/SaveButton';
+
+type PropsType = {
+  currentChart: ChartType
+  refreshChart: () => void
+};
+
+type StateType = {
+  values: string,
+  saveValuesStatus: string | null
+};
+
+export default class ValuesYaml extends Component<PropsType, StateType> {
+  state = {
+    values: '',
+    saveValuesStatus: null as (string | null)
+  }
+
+  updateValues() {
+    let values = yaml.dump(this.props.currentChart.config);
+    this.setState({ values });
+  }
+
+  componentDidMount() {
+    this.updateValues();
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    if (this.props.currentChart !== prevProps.currentChart) {
+      this.updateValues();
+    }
+  }
+
+  handleSaveValues = () => {
+    let { currentCluster, setCurrentError } = this.context;
+    this.setState({ saveValuesStatus: 'loading' });
+
+    api.upgradeChartValues('<token>', {
+      namespace: this.props.currentChart.namespace,
+      context: currentCluster,
+      storage: StorageType.Secret,
+      values: this.state.values
+    }, { name: this.props.currentChart.name }, (err: any, res: any) => {
+      if (err) {
+        setCurrentError(err.response.data.errors[0]);
+        this.setState({ saveValuesStatus: 'error ' });
+      } else {
+        this.setState({ saveValuesStatus: 'successful' });
+        this.props.refreshChart();
+      }
+    });
+  }
+
+  render() {
+    return (
+      <StyledValuesYaml>
+        <Wrapper>
+          <YamlEditor
+            value={this.state.values}
+            onChange={(e: any) => this.setState({ values: e })}
+          />
+        </Wrapper>
+        <SaveButton
+          text='Update Values'
+          onClick={this.handleSaveValues}
+          status={this.state.saveValuesStatus}
+        />
+      </StyledValuesYaml>
+    );
+  }
+}
+
+ValuesYaml.contextType = Context;
+
+const Wrapper = styled.div`
+  overflow: auto;
+  height: calc(100% - 60px);
+  border-radius: 5px;
+  border: 1px solid #ffffff22;
+`;
+
+const StyledValuesYaml = styled.div`
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  height: 100%;
+`;

+ 48 - 64
dashboard/src/main/home/modals/ClusterConfigModal.tsx

@@ -8,6 +8,7 @@ import { KubeContextConfig } from '../../../shared/types';
 
 import YamlEditor from '../../../components/YamlEditor';
 import SaveButton from '../../../components/SaveButton';
+import TabSelector from '../../../components/TabSelector';
 
 type PropsType = {
 };
@@ -20,6 +21,11 @@ type StateType = {
   saveSelectedStatus: string | null
 };
 
+const tabOptions = [
+  { label: 'Raw Kubeconfig', value: 'kubeconfig' },
+  { label: 'Select Clusters', value: 'select' }
+]
+
 export default class ClusterConfigModal extends Component<PropsType, StateType> {
   state = {
     currentTab: 'kubeconfig',
@@ -60,12 +66,6 @@ export default class ClusterConfigModal extends Component<PropsType, StateType>
     this.updateChecklist();
   }
 
-  renderLine = (tab: string): JSX.Element | undefined => {
-    if (this.state.currentTab === tab) {
-      return <Highlight />
-    }
-  };
-
   toggleCluster = (i: number): void => {
     let newKubeContexts = this.state.kubeContexts;
     newKubeContexts[i].selected = !newKubeContexts[i].selected;
@@ -160,7 +160,12 @@ export default class ClusterConfigModal extends Component<PropsType, StateType>
           <YamlEditor 
             value={this.state.rawKubeconfig}
             onChange={(e: any) => this.setState({ rawKubeconfig: e })}
+            height='295px'
+            border={true}
           />
+          <UploadButton>
+            <i className="material-icons">cloud_upload</i> Upload Kubeconfig
+          </UploadButton>
           <SaveButton
             text='Save Kubeconfig'
             onClick={this.handleSaveKubeconfig}
@@ -201,16 +206,11 @@ export default class ClusterConfigModal extends Component<PropsType, StateType>
           Manage Clusters
         </Header>
         <ModalTitle>Connect from Kubeconfig</ModalTitle>
-        <TabSelector>
-          <Tab onClick={() => this.setState({ currentTab: 'kubeconfig' })}>
-            Raw Kubeconfig
-            {this.renderLine('kubeconfig')}
-          </Tab>
-          <Tab onClick={() => this.setState({ currentTab: 'select' })}>
-            Select Clusters
-            {this.renderLine('select')}
-          </Tab>
-        </TabSelector>
+        <TabSelector
+          options={tabOptions}
+          setCurrentTab={(value: string) => this.setState({ currentTab: value })}
+          tabWidth='120px'
+        />
         {this.renderTabContents()}
       </StyledClusterConfigModal>
     );
@@ -219,6 +219,36 @@ export default class ClusterConfigModal extends Component<PropsType, StateType>
 
 ClusterConfigModal.contextType = Context;
 
+const UploadButton = styled.button`
+  display: flex;
+  align-items: center;
+  position: absolute;
+  bottom: 25px;
+  left: 30px;
+  height: 40px;
+  font-size: 13px;
+  font-weight: 500;
+  font-family: 'Work Sans', sans-serif;
+  color: white;
+  padding: 6px 20px 7px 20px;
+  text-align: left;
+  border: 0;
+  border-radius: 5px;
+  background: #ffffff11;
+  box-shadow: 0 2px 5px 0 #00000030;
+  cursor: not-allowed;
+  user-select: none;
+  :focus { outline: 0 }
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    font-size: 18px;
+    margin-right: 12px;
+  }
+`;
+
 const Checkbox = styled.div`
   width: 15px;
   height: 15px;
@@ -286,61 +316,15 @@ const Subtitle = styled.div`
   padding: 15px 0px;
   font-family: 'Work Sans', sans-serif;
   font-size: 13px;
-  color: #aaa;
+  color: #aaaabb;
   margin-top: 8px;
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
 `;
 
-const Highlight = styled.div`
-  width: 80%;
-  height: 1px;
-  margin-top: 5px;
-  background: #949EFFcc;
-
-  opacity: 0;
-  animation: lineEnter 0.5s 0s;
-  animation-fill-mode: forwards;
-  @keyframes lineEnter {
-    from { width: 0%; opacity: 0; }
-    to   { width: 80%; opacity: 1; }
-  }
-`; 
-
-const Tab = styled.div`
-  width: 180px;
-  height: 30px;
-  padding: 0 10px;
-  margin-right: 15px;
-  display: flex;
-  font-family: 'Work Sans', sans-serif;
-  font-size: 13px;
-  user-select: none;
-  color: #949effcc;
-  flex-direction: column;
-  padding-top: 7px;
-  align-items: center;
-  cursor: pointer;
-  white-space: nowrap;
-  
-  :hover {
-    background: #949EFF22;
-    border-radius: 5px;
-  }
-`;
-
-const TabSelector = styled.div`
-  display: flex;
-  width: 260px;
-  max-width: 100%;
-  margin-left: 0px;
-  justify-content: space-between;
-  margin-top: 23px;
-`;
-
 const ModalTitle = styled.div`
-  margin-top: 21px;
+  margin: 21px 2px 23px;
   display: flex;
   flex: 1;
   font-family: 'Assistant';

+ 41 - 3
dashboard/src/shared/api.tsx

@@ -43,17 +43,51 @@ const getContexts = baseApi<{}, { id: number }>('GET', pathParams => {
 const getCharts = baseApi<{
   namespace: string,
   context: string,
-  storage: string
+  storage: StorageType,
   limit: number,
   skip: number,
   byDate: boolean,
   statusFilter: string[]
-}>('GET', '/api/charts');
+}>('GET', '/api/releases');
+
+const getChart = baseApi<{
+  namespace: string,
+  context: string,
+  storage: StorageType
+}, { name: string, revision: number }>('GET', pathParams => {
+  return `/api/releases/${pathParams.name}/${pathParams.revision}`;
+});
 
 const getNamespaces = baseApi<{
   context: string
 }>('GET', '/api/k8s/namespaces');
 
+const getRevisions = baseApi<{
+  namespace: string,
+  context: string,
+  storage: StorageType
+}, { name: string }>('GET', pathParams => {
+  return `/api/releases/${pathParams.name}/history`;
+});
+
+const rollbackChart = baseApi<{
+  namespace: string,
+  context: string,
+  storage: StorageType,
+  revision: number
+}, { name: string }>('POST', pathParams => {
+  return `/api/releases/${pathParams.name}/rollback`;
+});
+
+const upgradeChartValues = baseApi<{
+  namespace: string,
+  context: string,
+  storage: StorageType,
+  values: string
+}, { name: string }>('POST', pathParams => {
+  return `/api/releases/${pathParams.name}/upgrade`;
+});
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -64,5 +98,9 @@ export default {
   updateUser,
   getContexts,
   getCharts,
-  getNamespaces
+  getChart,
+  getNamespaces,
+  getRevisions,
+  rollbackChart,
+  upgradeChartValues
 }

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

@@ -1,6 +1,8 @@
 import axios from 'axios';
 import qs from 'qs';
 
+// axios.defaults.timeout = 2500;
+
 // Partial function that accepts a generic params type and returns an api method
 export const baseApi = <T extends {}, S = {}>(requestType: string, endpoint: ((pathParams: S) => string) | string) => {
   return (token: string, params: T, pathParams: S, callback?: (err: any, res: any) => void) => {

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

@@ -25,12 +25,19 @@ export interface ChartType {
       apiVersion: string
     },
   },
+  config: string,
   version: number,
   namespace: string
 }
 
+export interface ResourceType {
+  kind: string,
+  name: string,
+  rawYaml: Object
+}
+
 export enum StorageType {
   Secret = 'secret',
   ConfigMap = 'configmap',
   Memory = 'memory'
-}
+}

+ 1 - 0
docker-compose.dev.yaml

@@ -7,6 +7,7 @@ services:
     restart: on-failure
     volumes:
       - ./dashboard:/webpack:rw,cached
+      - /webpack/node_modules
   porter:
     build:
       context: .

+ 172 - 20
docs/API.md

@@ -13,10 +13,12 @@
   - [`POST /api/logout`](#post-apilogout)
   - [`PUT /api/users/{id}`](#put-apiusersid)
   - [`DELETE /api/users/{id}`](#delete-apiusersid)
-- [`/api/charts`](#apicharts)
-  - [`GET /api/charts`](#get-apicharts)
-  - [`GET /api/charts/{name}/history`](#get-apichartsnamehistory)
-  - [`GET /api/charts/{name}/{revision}`](#get-apichartsnamerevision)
+- [`/api/releases`](#apireleases)
+  - [`GET /api/releases`](#get-apireleases)
+  - [`GET /api/releases/{name}/history`](#get-apireleasesnamehistory)
+  - [`GET /api/releases/{name}/{revision}`](#get-apireleasesnamerevision)
+  - [`POST /api/releases/{name}/rollback`](#post-apireleasesnamerollback)
+  - [`POST /api/releases/{name}/upgrade`](#post-apireleasesnameupgrade)
 - [`/api/k8s`](#apik8s)
   - [`GET /api/k8s/namespaces`](#get-apik8snamespaces)
 
@@ -417,11 +419,11 @@ User object with only the id field. Other fields are empty - with values in para
     }
     ```
 
-### `/api/charts`
+### `/api/releases`
 
-#### `GET /api/charts`
+#### `GET /api/releases`
 
-**Description:** Gets a list of charts for a current context and a kubeconfig retrieved from the user's ID. 
+**Description:** Gets a list of releases for a current context and a kubeconfig retrieved from the user's ID. 
 
 **URL parameters:** N/A
 
@@ -444,7 +446,7 @@ User object with only the id field. Other fields are empty - with values in para
 **Successful Response Body**: the full body is determined by the [release specification](https://pkg.go.dev/helm.sh/helm/v3@v3.3.4/pkg/release#Release): listed here is a subset of fields deemed to be most relevant. Note that all of the top-level fields are optional.
 
 ```js
-[]Chart{
+[]Release{
   // Name is the name of the release
   "name": String,
   "info": Info{
@@ -483,7 +485,7 @@ User object with only the id field. Other fields are empty - with values in para
     // Values are default config for this chart.
     "values": Map[String]{}
   },
-  // The set of extra Values added to the chart, which override the 
+  // The set of extra Values added to the release, which override the 
   // default values inside of the chart
   "config": Map[String]{},
   // Manifest is the string representation of the rendered template
@@ -497,11 +499,21 @@ User object with only the id field. Other fields are empty - with values in para
 
 **Successful Status Code**: `200`
 
-**Errors:** TBD
+**Errors:** 
+
+- Missing required field
+  - Status Code: `422`
+  - Request Body:
+    ```json
+    {
+      "code":601,
+      "errors":["required validation failed"]
+    }
+    ```
 
-#### `GET /api/charts/{name}/history`
+#### `GET /api/releases/{name}/history`
 
-**Description:** Gets a history of revisions for a given deployed chart based on the release `name`.
+**Description:** Gets a history of revisions for a given deployed release based on the release `name`.
 
 **URL parameters:** 
 
@@ -525,7 +537,7 @@ User object with only the id field. Other fields are empty - with values in para
 **Successful Response Body**: the full body is determined by the [release specification](https://pkg.go.dev/helm.sh/helm/v3@v3.3.4/pkg/release#Release): listed here is a subset of fields deemed to be most relevant. Note that all of the top-level fields are optional.
 
 ```js
-[]Chart{
+[]Release{
   // Name is the name of the release
   "name": String,
   "info": Info{
@@ -564,7 +576,7 @@ User object with only the id field. Other fields are empty - with values in para
     // Values are default config for this chart.
     "values": Map[String]{}
   },
-  // The set of extra Values added to the chart, which override the 
+  // The set of extra Values added to the release, which override the 
   // default values inside of the chart
   "config": Map[String]{},
   // Manifest is the string representation of the rendered template
@@ -578,11 +590,31 @@ User object with only the id field. Other fields are empty - with values in para
 
 **Successful Status Code**: `200`
 
-**Errors:** TBD
+**Errors:** 
+
+- Release not found
+  - Status Code: `404`
+  - Request Body:
+    ```json
+    {
+      "code":602,
+      "errors":["release not found"]
+    }
+    ```
+- Missing required field
+  - Status Code: `422`
+  - Request Body:
+    ```json
+    {
+      "code":601,
+      "errors":["required validation failed"]
+    }
+    ```
 
-#### `GET /api/charts/{name}/{revision}`
 
-**Description:** Gets a single chart for a current context and a kubeconfig retrieved from the user's ID based on a **name** and **revision**. To retrieve the latest deployed chart, set **revision** to 0. 
+#### `GET /api/releases/{name}/{revision}`
+
+**Description:** Gets a single release for a current context and a kubeconfig retrieved from the user's ID based on a **name** and **revision**. To retrieve the latest deployed release, set **revision** to 0. 
 
 **URL parameters:** 
 
@@ -607,7 +639,7 @@ User object with only the id field. Other fields are empty - with values in para
 **Successful Response Body**: the full body is determined by the [release specification](https://pkg.go.dev/helm.sh/helm/v3@v3.3.4/pkg/release#Release): listed here is a subset of fields deemed to be most relevant. Note that all of the top-level fields are optional.
 
 ```js
-Chart{
+[]Release{
   // Name is the name of the release
   "name": String,
   "info": Info{
@@ -646,7 +678,7 @@ Chart{
     // Values are default config for this chart.
     "values": Map[String]{}
   },
-  // The set of extra Values added to the chart, which override the 
+  // The set of extra Values added to the release, which override the 
   // default values inside of the chart
   "config": Map[String]{},
   // Manifest is the string representation of the rendered template
@@ -660,7 +692,127 @@ Chart{
 
 **Successful Status Code**: `200`
 
-**Errors:** TBD
+**Errors:** 
+
+- Release not found
+  - Status Code: `404`
+  - Request Body:
+    ```json
+    {
+      "code":602,
+      "errors":["release not found"]
+    }
+    ```
+- Missing required field
+  - Status Code: `422`
+  - Request Body:
+    ```json
+    {
+      "code":601,
+      "errors":["required validation failed"]
+    }
+    ```
+
+#### `POST /api/releases/{name}/rollback`
+
+**Description:** Rolls a release back to a specified revision. 
+
+**URL parameters:** 
+
+- `name` The name of the release.
+
+**Query parameters:** N/A
+
+**Request Body**:
+
+```js
+{
+  // The namespace of the cluster to be used
+  "namespace": String,
+  // The name of the context in the kubeconfig being used
+  "context": String,
+  // The Helm storage option to use
+  "storage": String("secret"|"configmap"|"memory"),
+  // The revision number of the desired rollback target
+  "revision": Number
+}
+```
+
+
+**Successful Response Body**: N/A
+
+**Successful Status Code**: `200`
+
+**Errors:**
+
+- Rollback failed
+  - Status Code: `500`
+  - Request Body:
+    ```json
+    {
+      "code":603,
+      "errors":["rollback failed: <error>"]
+    }
+    ```
+- Missing required field
+  - Status Code: `422`
+  - Request Body:
+    ```json
+    {
+      "code":601,
+      "errors":["required validation failed"]
+    }
+    ```
+
+#### `POST /api/releases/{name}/upgrade`
+
+**Description:** Upgrades a release with new `values.yaml`. 
+
+**URL parameters:** 
+
+- `name` The name of the release.
+
+**Query parameters:** N/A
+
+**Request Body**:
+
+```js
+{
+  // The namespace of the cluster to be used
+  "namespace": String,
+  // The name of the context in the kubeconfig being used
+  "context": String,
+  // The Helm storage option to use
+  "storage": String("secret"|"configmap"|"memory"),
+  // The string of values to use
+  "values": String
+}
+```
+
+**Successful Response Body**: N/A
+
+**Successful Status Code**: `200`
+
+**Errors:** 
+
+- Upgrade failed
+  - Status Code: `500`
+  - Request Body:
+    ```json
+    {
+      "code":603,
+      "errors":["upgrade failed: <error>"]
+    }
+    ```
+- Missing required field
+  - Status Code: `422`
+  - Request Body:
+    ```json
+    {
+      "code":601,
+      "errors":["required validation failed"]
+    }
+    ```
 
 ### `/api/k8s`
 

+ 2 - 1
go.mod

@@ -5,6 +5,7 @@ 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/Masterminds/semver v1.5.0 // indirect
 	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
@@ -49,7 +50,7 @@ require (
 	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/helm v2.16.12+incompatible
 	k8s.io/klog v1.0.0 // indirect
 	k8s.io/klog/v2 v2.2.0 // indirect
 	k8s.io/utils v0.0.0-20200912215256-4140de9c8800 // indirect

+ 7 - 0
go.sum

@@ -65,6 +65,8 @@ github.com/MakeNowJust/heredoc v0.0.0-20170808103936-bb23615498cd h1:sjQovDkwrZp
 github.com/MakeNowJust/heredoc v0.0.0-20170808103936-bb23615498cd/go.mod h1:64YHyfSL2R96J44Nlwm39UHepQbyR5q10x7iYa1ks2E=
 github.com/Masterminds/goutils v1.1.0 h1:zukEsf/1JZwCMgHiK3GZftabmxiCw4apj3a28RPBiVg=
 github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
+github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
+github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
 github.com/Masterminds/semver/v3 v3.1.0 h1:Y2lUDsFKVRSYGojLJ1yLxSXdMmMYTYls0rCvoqmMUQk=
 github.com/Masterminds/semver/v3 v3.1.0/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
 github.com/Masterminds/sprig/v3 v3.1.0 h1:j7GpgZ7PdFqNsmncycTHsLmVPf5/3wJtlgW9TNDYD9Y=
@@ -1247,6 +1249,7 @@ gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
 gorm.io/gorm v1.20.2 h1:bZzSEnq7NDGsrd+n3evOOedDrY5oLM5QPlCjZJUK2ro=
 gorm.io/gorm v1.20.2/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
 gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
+helm.sh/helm v1.2.1 h1:Jrn7kKQqQ/hnFWZEX+9pMFvYqFexkzrBnGqYBmIph7c=
 helm.sh/helm v2.16.12+incompatible h1:nQfifk10KcpAGD1RJaNZVW/fWiqluV0JMuuDwdba4rw=
 helm.sh/helm v2.16.12+incompatible/go.mod h1:0Xbc6ErzwWH9qC55X1+hE3ZwhM3atbhCm/NbFZw5i+4=
 helm.sh/helm/v3 v3.3.4 h1:tbad6WQVMxEw1HlVBvI2rQqOblmI5lgXOrWAMwJ198M=
@@ -1270,12 +1273,16 @@ k8s.io/cli-runtime v0.18.8 h1:ycmbN3hs7CfkJIYxJAOB10iW7BVPmXGXkfEyiV9NJ+k=
 k8s.io/cli-runtime v0.18.8/go.mod h1:7EzWiDbS9PFd0hamHHVoCY4GrokSTPSL32MA4rzIu0M=
 k8s.io/client-go v0.18.8 h1:SdbLpIxk5j5YbFr1b7fq8S7mDgDjYmUxSbszyoesoDM=
 k8s.io/client-go v0.18.8/go.mod h1:HqFqMllQ5NnQJNwjro9k5zMyfhZlOwpuTLVrxjkYSxU=
+k8s.io/client-go v1.5.1 h1:XaX/lo2/u3/pmFau8HN+sB5C/b4dc4Dmm2eXjBH4p1E=
+k8s.io/client-go v11.0.0+incompatible h1:LBbX2+lOwY9flffWlJM7f1Ct8V2SRNiMRDFeiwnJo9o=
 k8s.io/code-generator v0.18.8/go.mod h1:TgNEVx9hCyPGpdtCWA34olQYLkh3ok9ar7XfSsr8b6c=
 k8s.io/component-base v0.18.8 h1:BW5CORobxb6q5mb+YvdwQlyXXS6NVH5fDXWbU7tf2L8=
 k8s.io/component-base v0.18.8/go.mod h1:00frPRDas29rx58pPCxNkhUfPbwajlyyvu8ruNgSErU=
 k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0=
 k8s.io/gengo v0.0.0-20200114144118-36b2048a9120/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0=
 k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0=
+k8s.io/helm v1.2.1 h1:Ny4wgW4p7X3tFXR34PziNkUxw2pV0G1DIFmI1QRDdo0=
+k8s.io/helm v2.16.12+incompatible h1:K2zhF8+B85Ya1n7n3eH34xwwp5qNUM42TBFENDZJT7w=
 k8s.io/helm v2.16.12+incompatible/go.mod h1:LZzlS4LQBHfciFOurYBFkCMTaZ0D1l+p0teMg7TSULI=
 k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=
 k8s.io/klog v0.3.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=

+ 0 - 96
internal/forms/chart.go

@@ -1,96 +0,0 @@
-package forms
-
-import (
-	"net/url"
-	"strconv"
-
-	"github.com/porter-dev/porter/internal/helm"
-	"github.com/porter-dev/porter/internal/repository"
-)
-
-// ChartForm is the generic base type for CRUD operations on charts
-type ChartForm struct {
-	*helm.Form
-}
-
-// PopulateHelmOptionsFromQueryParams populates fields in the ChartForm using the passed
-// url.Values (the parsed query params)
-func (cf *ChartForm) PopulateHelmOptionsFromQueryParams(vals url.Values) {
-	if context, ok := vals["context"]; ok && len(context) == 1 {
-		cf.Context = context[0]
-	}
-
-	if namespace, ok := vals["namespace"]; ok && len(namespace) == 1 {
-		cf.Namespace = namespace[0]
-	}
-
-	if storage, ok := vals["storage"]; ok && len(storage) == 1 {
-		cf.Storage = storage[0]
-	}
-}
-
-// PopulateHelmOptionsFromUserID uses the passed user ID to populate the HelmOptions object
-func (cf *ChartForm) PopulateHelmOptionsFromUserID(userID uint, repo repository.UserRepository) error {
-	user, err := repo.ReadUser(userID)
-
-	if err != nil {
-		return err
-	}
-
-	cf.AllowedContexts = user.ContextToSlice()
-	cf.KubeConfig = user.RawKubeConfig
-
-	return nil
-}
-
-// ListChartForm represents the accepted values for listing Helm charts
-type ListChartForm struct {
-	*ChartForm
-	*helm.ListFilter
-}
-
-// PopulateListFromQueryParams populates fields in the ListChartForm using the passed
-// url.Values (the parsed query params). It calls the underlying
-// PopulateHelmOptionsFromQueryParams
-func (lcf *ListChartForm) PopulateListFromQueryParams(vals url.Values) {
-	lcf.ChartForm.PopulateHelmOptionsFromQueryParams(vals)
-
-	if namespace, ok := vals["namespace"]; ok && len(namespace) == 1 {
-		lcf.ListFilter.Namespace = namespace[0]
-	}
-
-	if limit, ok := vals["limit"]; ok && len(limit) == 1 {
-		if limitInt, err := strconv.ParseInt(limit[0], 10, 64); err == nil {
-			lcf.ListFilter.Limit = int(limitInt)
-		}
-	}
-
-	if skip, ok := vals["skip"]; ok && len(skip) == 1 {
-		if skipInt, err := strconv.ParseInt(skip[0], 10, 64); err == nil {
-			lcf.ListFilter.Skip = int(skipInt)
-		}
-	}
-
-	if byDate, ok := vals["byDate"]; ok && len(byDate) == 1 {
-		if byDateBool, err := strconv.ParseBool(byDate[0]); err == nil {
-			lcf.ListFilter.ByDate = byDateBool
-		}
-	}
-
-	if statusFilter, ok := vals["statusFilter"]; ok {
-		lcf.ListFilter.StatusFilter = statusFilter
-	}
-}
-
-// GetChartForm represents the accepted values for getting a single Helm chart
-type GetChartForm struct {
-	*ChartForm
-	Name     string `json:"name" form:"required"`
-	Revision int    `json:"revision"`
-}
-
-// ListChartHistoryForm represents the accepted values for getting a single Helm chart
-type ListChartHistoryForm struct {
-	*ChartForm
-	Name string `json:"name" form:"required"`
-}

+ 1 - 1
internal/forms/k8s.go

@@ -12,7 +12,7 @@ type K8sForm struct {
 	*kubernetes.OutOfClusterConfig
 }
 
-// PopulateK8sOptionsFromQueryParams populates fields in the ChartForm using the passed
+// PopulateK8sOptionsFromQueryParams populates fields in the ReleaseForm using the passed
 // url.Values (the parsed query params)
 func (kf *K8sForm) PopulateK8sOptionsFromQueryParams(vals url.Values) {
 	if context, ok := vals["context"]; ok && len(context) == 1 {

+ 107 - 0
internal/forms/release.go

@@ -0,0 +1,107 @@
+package forms
+
+import (
+	"net/url"
+	"strconv"
+
+	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+// ReleaseForm is the generic base type for CRUD operations on releases
+type ReleaseForm struct {
+	*helm.Form
+}
+
+// PopulateHelmOptionsFromQueryParams populates fields in the ReleaseForm using the passed
+// url.Values (the parsed query params)
+func (rf *ReleaseForm) PopulateHelmOptionsFromQueryParams(vals url.Values) {
+	if context, ok := vals["context"]; ok && len(context) == 1 {
+		rf.Context = context[0]
+	}
+
+	if namespace, ok := vals["namespace"]; ok && len(namespace) == 1 {
+		rf.Namespace = namespace[0]
+	}
+
+	if storage, ok := vals["storage"]; ok && len(storage) == 1 {
+		rf.Storage = storage[0]
+	}
+}
+
+// PopulateHelmOptionsFromUserID uses the passed user ID to populate the HelmOptions object
+func (rf *ReleaseForm) PopulateHelmOptionsFromUserID(userID uint, repo repository.UserRepository) error {
+	user, err := repo.ReadUser(userID)
+
+	if err != nil {
+		return err
+	}
+
+	rf.AllowedContexts = user.ContextToSlice()
+	rf.KubeConfig = user.RawKubeConfig
+
+	return nil
+}
+
+// ListReleaseForm represents the accepted values for listing Helm releases
+type ListReleaseForm struct {
+	*ReleaseForm
+	*helm.ListFilter
+}
+
+// PopulateListFromQueryParams populates fields in the ListReleaseForm using the passed
+// url.Values (the parsed query params)
+func (lrf *ListReleaseForm) PopulateListFromQueryParams(vals url.Values) {
+	if namespace, ok := vals["namespace"]; ok && len(namespace) == 1 {
+		lrf.ListFilter.Namespace = namespace[0]
+	}
+
+	if limit, ok := vals["limit"]; ok && len(limit) == 1 {
+		if limitInt, err := strconv.ParseInt(limit[0], 10, 64); err == nil {
+			lrf.ListFilter.Limit = int(limitInt)
+		}
+	}
+
+	if skip, ok := vals["skip"]; ok && len(skip) == 1 {
+		if skipInt, err := strconv.ParseInt(skip[0], 10, 64); err == nil {
+			lrf.ListFilter.Skip = int(skipInt)
+		}
+	}
+
+	if byDate, ok := vals["byDate"]; ok && len(byDate) == 1 {
+		if byDateBool, err := strconv.ParseBool(byDate[0]); err == nil {
+			lrf.ListFilter.ByDate = byDateBool
+		}
+	}
+
+	if statusFilter, ok := vals["statusFilter"]; ok {
+		lrf.ListFilter.StatusFilter = statusFilter
+	}
+}
+
+// GetReleaseForm represents the accepted values for getting a single Helm release
+type GetReleaseForm struct {
+	*ReleaseForm
+	Name     string `json:"name" form:"required"`
+	Revision int    `json:"revision"`
+}
+
+// ListReleaseHistoryForm represents the accepted values for getting a single Helm release
+type ListReleaseHistoryForm struct {
+	*ReleaseForm
+	Name string `json:"name" form:"required"`
+}
+
+// RollbackReleaseForm represents the accepted values for getting a single Helm release
+type RollbackReleaseForm struct {
+	*ReleaseForm
+	Name     string `json:"name" form:"required"`
+	Revision int    `json:"revision" form:"required"`
+}
+
+// UpgradeReleaseForm represents the accepted values for updating a Helm release
+type UpgradeReleaseForm struct {
+	*ReleaseForm
+	Name   string `json:"name" form:"required"`
+	Values string `json:"values" form:"required"`
+}

+ 43 - 0
internal/helm/agent.go

@@ -1,8 +1,11 @@
 package helm
 
 import (
+	"fmt"
+
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/release"
+	"k8s.io/helm/pkg/chartutil"
 )
 
 // Agent is a Helm agent for performing helm operations
@@ -43,3 +46,43 @@ func (a *Agent) GetReleaseHistory(
 
 	return cmd.Run(name)
 }
+
+// UpgradeRelease upgrades a specific release with new values.yaml
+func (a *Agent) UpgradeRelease(
+	name string,
+	values string,
+) (*release.Release, error) {
+	// grab the latest release
+	rel, err := a.GetRelease(name, 0)
+
+	if err != nil {
+		return nil, fmt.Errorf("Could not get release to be upgraded: %v", err)
+	}
+
+	ch := rel.Chart
+
+	cmd := action.NewUpgrade(a.ActionConfig)
+	valuesYaml, err := chartutil.ReadValues([]byte(values))
+
+	if err != nil {
+		return nil, fmt.Errorf("Values could not be parsed: %v", err)
+	}
+
+	res, err := cmd.Run(name, ch, valuesYaml)
+
+	if err != nil {
+		return nil, fmt.Errorf("Upgrade failed: %v", err)
+	}
+
+	return res, nil
+}
+
+// RollbackRelease rolls a release back to a specified revision/version
+func (a *Agent) RollbackRelease(
+	name string,
+	version int,
+) error {
+	cmd := action.NewRollback(a.ActionConfig)
+	cmd.Version = version
+	return cmd.Run(name)
+}

+ 77 - 1
internal/helm/agent_test.go

@@ -209,7 +209,7 @@ var getReleaseTests = []getReleaseTest{
 			releaseStub{"not-in-default-namespace", "other", 1, "1.0.2", release.StatusDeployed},
 		},
 		getName:    "airwatch",
-		getVersion: 0,
+		getVersion: 1,
 		expRes:     releaseStub{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
 	},
 }
@@ -266,3 +266,79 @@ func TestListReleaseHistory(t *testing.T) {
 		compareReleaseToStubs(t, releases, tc.expRes)
 	}
 }
+
+var upgradeTests = []listReleaseTest{
+	listReleaseTest{
+		name:      "simple history test",
+		namespace: "default",
+		releases: []releaseStub{
+			releaseStub{"wordpress", "default", 2, "1.0.2", release.StatusDeployed},
+			releaseStub{"wordpress", "default", 1, "1.0.1", release.StatusSuperseded},
+		},
+		expRes: []releaseStub{
+			releaseStub{"wordpress", "default", 1, "1.0.1", release.StatusSuperseded},
+			releaseStub{"wordpress", "default", 2, "1.0.2", release.StatusSuperseded},
+			releaseStub{"wordpress", "default", 3, "1.0.2", release.StatusDeployed},
+		},
+	},
+}
+
+func TestUpgradeRelease(t *testing.T) {
+	for _, tc := range upgradeTests {
+		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)
+
+		agent.UpgradeRelease("wordpress", "")
+
+		releases, err := agent.GetReleaseHistory("wordpress")
+
+		if err != nil {
+			t.Errorf("%v", err)
+		}
+
+		compareReleaseToStubs(t, releases, tc.expRes)
+	}
+}
+
+var rollbackReleaseTests = []getReleaseTest{
+	getReleaseTest{
+		name:      "simple rollback test",
+		namespace: "default",
+		releases: []releaseStub{
+			releaseStub{"wordpress", "default", 2, "1.0.2", release.StatusDeployed},
+			releaseStub{"wordpress", "default", 1, "1.0.1", release.StatusSuperseded},
+		},
+		getName:    "wordpress",
+		getVersion: 3,
+		expRes:     releaseStub{"wordpress", "default", 3, "1.0.1", release.StatusDeployed},
+	},
+}
+
+func TestRollbackRelease(t *testing.T) {
+	for _, tc := range rollbackReleaseTests {
+		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)
+
+		err := agent.RollbackRelease("wordpress", 1)
+
+		if err != nil {
+			t.Errorf("%v", err)
+		}
+
+		rel, err := agent.GetRelease(tc.getName, tc.getVersion)
+
+		if err != nil {
+			t.Errorf("%v", err)
+		}
+
+		compareReleaseToStubs(t, []*release.Release{rel}, []releaseStub{tc.expRes})
+	}
+}

+ 3 - 3
internal/helm/config.go

@@ -50,7 +50,7 @@ func GetAgentOutOfClusterConfig(form *Form, l *logger.Logger) (*Agent, error) {
 	return &Agent{&action.Configuration{
 		RESTClientGetter: k8sAgent.RESTClientGetter,
 		KubeClient:       kube.New(k8sAgent.RESTClientGetter),
-		Releases:         StorageMap[form.Storage](l, form.Namespace, clientset),
+		Releases:         StorageMap[form.Storage](l, clientset.CoreV1(), form.Namespace),
 		Log:              l.Printf,
 	}}, nil
 }
@@ -75,7 +75,7 @@ func GetAgentInClusterConfig(form *Form, l *logger.Logger) (*Agent, error) {
 	return &Agent{&action.Configuration{
 		RESTClientGetter: k8sAgent.RESTClientGetter,
 		KubeClient:       kube.New(k8sAgent.RESTClientGetter),
-		Releases:         StorageMap[form.Storage](l, form.Namespace, clientset),
+		Releases:         StorageMap[form.Storage](l, clientset.CoreV1(), form.Namespace),
 		Log:              l.Printf,
 	}}, nil
 }
@@ -85,7 +85,7 @@ func GetAgentTesting(form *Form, storage *storage.Storage, l *logger.Logger) *Ag
 	testStorage := storage
 
 	if testStorage == nil {
-		testStorage = StorageMap["memory"](nil, form.Namespace, nil)
+		testStorage = StorageMap["memory"](nil, nil, "")
 	}
 
 	return &Agent{&action.Configuration{

+ 13 - 9
internal/helm/storage.go

@@ -8,7 +8,7 @@ package helm
 // - memory
 // - postgres
 //
-// This file implements first-class support for the first three driver types,
+// This file implements first-class support for the first three driver types
 // and integrates with the logger.
 //
 // TODO -- include support for SQL storage...
@@ -18,11 +18,15 @@ import (
 
 	"helm.sh/helm/v3/pkg/storage"
 	"helm.sh/helm/v3/pkg/storage/driver"
-	"k8s.io/client-go/kubernetes"
+	corev1 "k8s.io/client-go/kubernetes/typed/core/v1"
 )
 
 // NewStorageDriver is a function type for returning a new storage driver
-type NewStorageDriver func(l *logger.Logger, namespace string, clientset *kubernetes.Clientset) *storage.Storage
+type NewStorageDriver func(
+	l *logger.Logger,
+	v1Interface corev1.CoreV1Interface,
+	namespace string,
+) *storage.Storage
 
 // StorageMap is a map from storage configuration env variables to a function
 // that initializes that Helm storage driver.
@@ -35,10 +39,10 @@ var StorageMap map[string]NewStorageDriver = map[string]NewStorageDriver{
 // NewSecretStorageDriver returns a storage using the Secret driver.
 func newSecretStorageDriver(
 	l *logger.Logger,
+	v1Interface corev1.CoreV1Interface,
 	namespace string,
-	clientset *kubernetes.Clientset,
 ) *storage.Storage {
-	d := driver.NewSecrets(clientset.CoreV1().Secrets(namespace))
+	d := driver.NewSecrets(v1Interface.Secrets(namespace))
 	d.Log = l.Printf
 	return storage.Init(d)
 }
@@ -46,10 +50,10 @@ func newSecretStorageDriver(
 // NewConfigMapsStorageDriver returns a storage using the ConfigMap driver.
 func newConfigMapsStorageDriver(
 	l *logger.Logger,
+	v1Interface corev1.CoreV1Interface,
 	namespace string,
-	clientset *kubernetes.Clientset,
 ) *storage.Storage {
-	d := driver.NewConfigMaps(clientset.CoreV1().ConfigMaps(namespace))
+	d := driver.NewConfigMaps(v1Interface.ConfigMaps(namespace))
 	d.Log = l.Printf
 	return storage.Init(d)
 }
@@ -57,8 +61,8 @@ func newConfigMapsStorageDriver(
 // NewMemoryStorageDriver returns a storage using the In-Memory driver.
 func newMemoryStorageDriver(
 	_ *logger.Logger,
-	namespace string,
-	_ *kubernetes.Clientset,
+	_ corev1.CoreV1Interface,
+	_ string,
 ) *storage.Storage {
 	d := driver.NewMemory()
 	return storage.Init(d)

+ 82 - 0
internal/helm/storage_test.go

@@ -0,0 +1,82 @@
+package helm_test
+
+import (
+	"reflect"
+	"testing"
+
+	"helm.sh/helm/v3/pkg/chart"
+	"helm.sh/helm/v3/pkg/release"
+
+	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/logger"
+	"helm.sh/helm/v3/pkg/storage"
+	"k8s.io/client-go/kubernetes/fake"
+)
+
+func testDriver(t *testing.T, storage *storage.Storage) {
+	t.Helper()
+
+	rel := &release.Release{
+		Name:      "porter",
+		Namespace: "default",
+		Version:   1,
+		Info: &release.Info{
+			Status: release.StatusDeployed,
+		},
+		Chart: &chart.Chart{
+			Metadata: &chart.Metadata{
+				Version: "1.0.0",
+				Icon:    "https://example.com/icon.png",
+			},
+		},
+	}
+
+	err := storage.Create(rel)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	gotRel, err := storage.Get("porter", 1)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if !reflect.DeepEqual(rel, gotRel) {
+		t.Fatalf("Objects not equal: expected %v, got %v\n", rel, gotRel)
+	}
+}
+
+func testStorageDriver(t *testing.T, name string) {
+	t.Helper()
+
+	k8sAgent := kubernetes.GetAgentTesting()
+
+	newDriver := helm.StorageMap[name]
+
+	l := logger.NewConsole(true)
+
+	clientset, ok := k8sAgent.Clientset.(*fake.Clientset)
+
+	if !ok {
+		t.Fatal("Agent Clientset was not of type *(k8s.io/client-go/kubernetes/fake).Clientset")
+	}
+
+	driver := newDriver(l, clientset.CoreV1(), "default")
+
+	testDriver(t, driver)
+}
+
+func TestNewSecretStorageDriver(t *testing.T) {
+	testStorageDriver(t, "secret")
+}
+
+func TestNewConfigMapStorageDriver(t *testing.T) {
+	testStorageDriver(t, "configmap")
+}
+
+func TestNewMemoryStorageDriver(t *testing.T) {
+	testStorageDriver(t, "memory")
+}

+ 1 - 1
server/api/api.go

@@ -51,7 +51,7 @@ func New(
 	var testAgents *TestAgents = nil
 
 	if testing {
-		memStorage := helm.StorageMap["memory"](nil, "", nil)
+		memStorage := helm.StorageMap["memory"](nil, nil, "")
 
 		testAgents = &TestAgents{
 			HelmAgent:             helm.GetAgentTesting(&helm.Form{}, nil, logger),

+ 0 - 197
server/api/chart_handler.go

@@ -1,197 +0,0 @@
-package api
-
-import (
-	"encoding/json"
-	"net/http"
-	"net/url"
-	"strconv"
-
-	"github.com/go-chi/chi"
-	"github.com/porter-dev/porter/internal/forms"
-	"github.com/porter-dev/porter/internal/helm"
-)
-
-// Enumeration of chart API error codes, represented as int64
-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) {
-	session, err := app.store.Get(r, app.cookieName)
-
-	if err != nil {
-		app.handleErrorFormDecoding(err, ErrChartDecode, w)
-		return
-	}
-
-	vals, err := url.ParseQuery(r.URL.RawQuery)
-
-	if err != nil {
-		app.handleErrorFormDecoding(err, ErrChartDecode, w)
-		return
-	}
-
-	// get the filter options
-	form := &forms.ListChartForm{
-		ChartForm: &forms.ChartForm{
-			Form: &helm.Form{},
-		},
-		ListFilter: &helm.ListFilter{},
-	}
-	form.PopulateListFromQueryParams(vals)
-
-	if sessID, ok := session.Values["user_id"].(uint); ok {
-		form.PopulateHelmOptionsFromUserID(sessID, app.repo.User)
-	}
-
-	// validate the form
-	if err := app.validator.Struct(form); err != nil {
-		app.handleErrorFormValidation(err, ErrChartValidateFields, w)
-		return
-	}
-
-	// create a new agent
-	var agent *helm.Agent
-
-	if app.testing {
-		agent = app.TestAgents.HelmAgent
-	} else {
-		agent, err = helm.GetAgentOutOfClusterConfig(form.ChartForm.Form, app.logger)
-	}
-
-	releases, err := agent.ListReleases(form.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
-	}
-}
-
-// HandleGetChart retrieves a single chart based on a name and revision
-func (app *App) HandleGetChart(w http.ResponseWriter, r *http.Request) {
-	session, err := app.store.Get(r, app.cookieName)
-
-	if err != nil {
-		app.handleErrorFormDecoding(err, ErrChartDecode, w)
-		return
-	}
-
-	name := chi.URLParam(r, "name")
-	revision, err := strconv.ParseUint(chi.URLParam(r, "revision"), 0, 64)
-
-	// get the filter options
-	form := &forms.GetChartForm{
-		ChartForm: &forms.ChartForm{
-			Form: &helm.Form{},
-		},
-		Name:     name,
-		Revision: int(revision),
-	}
-
-	vals, err := url.ParseQuery(r.URL.RawQuery)
-
-	if err != nil {
-		app.handleErrorFormDecoding(err, ErrChartDecode, w)
-		return
-	}
-
-	form.PopulateHelmOptionsFromQueryParams(vals)
-
-	if sessID, ok := session.Values["user_id"].(uint); ok {
-		form.PopulateHelmOptionsFromUserID(sessID, app.repo.User)
-	}
-
-	// validate the form
-	if err := app.validator.Struct(form); err != nil {
-		app.handleErrorFormValidation(err, ErrChartValidateFields, w)
-		return
-	}
-
-	// create a new agent
-	var agent *helm.Agent
-
-	if app.testing {
-		agent = app.TestAgents.HelmAgent
-	} else {
-		agent, err = helm.GetAgentOutOfClusterConfig(form.ChartForm.Form, app.logger)
-	}
-
-	release, err := agent.GetRelease(form.Name, form.Revision)
-
-	if err != nil {
-		app.handleErrorFormValidation(err, ErrChartValidateFields, w)
-		return
-	}
-
-	if err := json.NewEncoder(w).Encode(release); err != nil {
-		app.handleErrorFormDecoding(err, ErrChartDecode, w)
-		return
-	}
-}
-
-// HandleListChartHistory retrieves a history of charts based on a chart name
-func (app *App) HandleListChartHistory(w http.ResponseWriter, r *http.Request) {
-	session, err := app.store.Get(r, app.cookieName)
-
-	if err != nil {
-		app.handleErrorFormDecoding(err, ErrChartDecode, w)
-		return
-	}
-
-	name := chi.URLParam(r, "name")
-
-	// get the filter options
-	form := &forms.ListChartHistoryForm{
-		ChartForm: &forms.ChartForm{
-			Form: &helm.Form{},
-		},
-		Name: name,
-	}
-
-	vals, err := url.ParseQuery(r.URL.RawQuery)
-
-	if err != nil {
-		app.handleErrorFormDecoding(err, ErrChartDecode, w)
-		return
-	}
-
-	form.PopulateHelmOptionsFromQueryParams(vals)
-
-	if sessID, ok := session.Values["user_id"].(uint); ok {
-		form.PopulateHelmOptionsFromUserID(sessID, app.repo.User)
-	}
-
-	// validate the form
-	if err := app.validator.Struct(form); err != nil {
-		app.handleErrorFormValidation(err, ErrChartValidateFields, w)
-		return
-	}
-
-	// create a new agent
-	var agent *helm.Agent
-
-	if app.testing {
-		agent = app.TestAgents.HelmAgent
-	} else {
-		agent, err = helm.GetAgentOutOfClusterConfig(form.ChartForm.Form, app.logger)
-	}
-
-	release, err := agent.GetReleaseHistory(form.Name)
-
-	if err != nil {
-		app.handleErrorFormValidation(err, ErrChartValidateFields, w)
-		return
-	}
-
-	if err := json.NewEncoder(w).Encode(release); err != nil {
-		app.handleErrorFormDecoding(err, ErrChartDecode, w)
-		return
-	}
-}

+ 0 - 293
server/api/chart_handler_test.go

@@ -1,293 +0,0 @@
-package api_test
-
-import (
-	"encoding/json"
-	"net/http"
-	"net/url"
-	"reflect"
-	"strings"
-	"testing"
-
-	"github.com/porter-dev/porter/internal/helm"
-
-	"helm.sh/helm/v3/pkg/chart"
-	"helm.sh/helm/v3/pkg/release"
-	"helm.sh/helm/v3/pkg/storage/driver"
-)
-
-type releaseStub struct {
-	name         string
-	namespace    string
-	version      int
-	chartVersion string
-	status       release.Status
-}
-
-// ------------------------- TEST TYPES AND MAIN LOOP ------------------------- //
-
-type chartTest struct {
-	initializers []func(tester *tester)
-	namespace    string
-	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
-		tester := newTester(canQuery)
-
-		// if there's an initializer, call it
-		for _, init := range c.initializers {
-			init(tester)
-		}
-
-		tester.app.TestAgents.HelmAgent.ActionConfig.Releases.Driver.(*driver.Memory).SetNamespace(c.namespace)
-
-		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?" + url.Values{
-			"namespace":    []string{""},
-			"context":      []string{"context-test"},
-			"storage":      []string{"memory"},
-			"limit":        []string{"20"},
-			"skip":         []string{"0"},
-			"byDate":       []string{"false"},
-			"statusFilter": []string{"deployed"},
-		}.Encode(),
-		body:      "",
-		expStatus: http.StatusOK,
-		expBody:   releaseStubsToChartJSON(sampleReleaseStubs),
-		useCookie: true,
-		validators: []func(c *chartTest, tester *tester, t *testing.T){
-			chartReleaseBodyValidator,
-		},
-	},
-	&chartTest{
-		initializers: []func(tester *tester){
-			initDefaultCharts,
-		},
-		msg:       "List charts",
-		method:    "GET",
-		namespace: "default",
-		endpoint: "/api/charts?" + url.Values{
-			"namespace":    []string{"default"},
-			"context":      []string{"context-test"},
-			"storage":      []string{"memory"},
-			"limit":        []string{"20"},
-			"skip":         []string{"0"},
-			"byDate":       []string{"false"},
-			"statusFilter": []string{"deployed"},
-		}.Encode(),
-		body:      "",
-		expStatus: http.StatusOK,
-		expBody: releaseStubsToChartJSON([]releaseStub{
-			sampleReleaseStubs[0],
-			sampleReleaseStubs[2],
-		}),
-		useCookie: true,
-		validators: []func(c *chartTest, tester *tester, t *testing.T){
-			chartReleaseBodyValidator,
-		},
-	},
-}
-
-func TestHandleListCharts(t *testing.T) {
-	testChartRequests(t, listChartsTests, true)
-}
-
-var getChartTests = []*chartTest{
-	&chartTest{
-		initializers: []func(tester *tester){
-			initDefaultCharts,
-		},
-		msg:    "Get charts",
-		method: "GET",
-		endpoint: "/api/charts/airwatch/0?" + url.Values{
-			"namespace": []string{""},
-			"context":   []string{"context-test"},
-			"storage":   []string{"memory"},
-		}.Encode(),
-		body:      "",
-		expStatus: http.StatusOK,
-		expBody:   releaseStubToChartJSON(sampleReleaseStubs[0]),
-		useCookie: true,
-		validators: []func(c *chartTest, tester *tester, t *testing.T){
-			chartReleaseBodyValidator,
-		},
-	},
-}
-
-func TestHandleGetChart(t *testing.T) {
-	testChartRequests(t, getChartTests, true)
-}
-
-var listChartHistoryTests = []*chartTest{
-	&chartTest{
-		initializers: []func(tester *tester){
-			initHistoryCharts,
-		},
-		msg:    "List chart history",
-		method: "GET",
-		endpoint: "/api/charts/wordpress/history?" + url.Values{
-			"namespace": []string{""},
-			"context":   []string{"context-test"},
-			"storage":   []string{"memory"},
-		}.Encode(),
-		body:      "",
-		expStatus: http.StatusOK,
-		expBody:   releaseStubsToChartJSON(historyReleaseStubs),
-		useCookie: true,
-		validators: []func(c *chartTest, tester *tester, t *testing.T){
-			chartReleaseBodyValidator,
-		},
-	},
-}
-
-func TestHandleListChartHistory(t *testing.T) {
-	testChartRequests(t, listChartHistoryTests, true)
-}
-
-// ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
-
-func initDefaultCharts(tester *tester) {
-	initUserDefault(tester)
-
-	agent := tester.app.TestAgents.HelmAgent
-
-	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 initHistoryCharts(tester *tester) {
-	initUserDefault(tester)
-
-	agent := tester.app.TestAgents.HelmAgent
-
-	makeReleases(agent, historyReleaseStubs)
-
-	// 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("")
-}
-
-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},
-}
-
-var historyReleaseStubs = []releaseStub{
-	releaseStub{"wordpress", "default", 1, "1.0.1", release.StatusSuperseded},
-	releaseStub{"wordpress", "default", 2, "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 releaseStubToChartJSON(r releaseStub) string {
-	rel := releaseStubToRelease(r)
-
-	str, _ := json.Marshal(rel)
-
-	return string(str)
-}
-
-func releaseStubToRelease(r releaseStub) *release.Release {
-	return &release.Release{
-		Name:      r.name,
-		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/k8s_handler.go

@@ -21,14 +21,14 @@ func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
 	session, err := app.store.Get(r, app.cookieName)
 
 	if err != nil {
-		app.handleErrorFormDecoding(err, ErrChartDecode, w)
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
 		return
 	}
 
 	vals, err := url.ParseQuery(r.URL.RawQuery)
 
 	if err != nil {
-		app.handleErrorFormDecoding(err, ErrChartDecode, w)
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
 		return
 	}
 

+ 285 - 0
server/api/release_handler.go

@@ -0,0 +1,285 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/url"
+	"strconv"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/helm"
+)
+
+// Enumeration of release API error codes, represented as int64
+const (
+	ErrReleaseDecode ErrorCode = iota + 600
+	ErrReleaseValidateFields
+	ErrReleaseReadData
+	ErrReleaseDeploy
+)
+
+// HandleListReleases retrieves a list of releases for a cluster
+// with various filter options
+func (app *App) HandleListReleases(w http.ResponseWriter, r *http.Request) {
+	form := &forms.ListReleaseForm{
+		ReleaseForm: &forms.ReleaseForm{
+			Form: &helm.Form{},
+		},
+		ListFilter: &helm.ListFilter{},
+	}
+
+	agent, err := app.getAgentFromQueryParams(
+		w,
+		r,
+		form.ReleaseForm,
+		form.ReleaseForm.PopulateHelmOptionsFromQueryParams,
+		form.PopulateListFromQueryParams,
+	)
+
+	// errors are handled in app.getAgentFromQueryParams
+	if err != nil {
+		return
+	}
+
+	releases, err := agent.ListReleases(form.Namespace, form.ListFilter)
+
+	if err != nil {
+		app.handleErrorRead(err, ErrReleaseReadData, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(releases); err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+}
+
+// HandleGetRelease retrieves a single release based on a name and revision
+func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
+	name := chi.URLParam(r, "name")
+	revision, err := strconv.ParseUint(chi.URLParam(r, "revision"), 0, 64)
+
+	form := &forms.GetReleaseForm{
+		ReleaseForm: &forms.ReleaseForm{
+			Form: &helm.Form{},
+		},
+		Name:     name,
+		Revision: int(revision),
+	}
+
+	agent, err := app.getAgentFromQueryParams(
+		w,
+		r,
+		form.ReleaseForm,
+		form.ReleaseForm.PopulateHelmOptionsFromQueryParams,
+	)
+
+	// errors are handled in app.getAgentFromQueryParams
+	if err != nil {
+		return
+	}
+
+	release, err := agent.GetRelease(form.Name, form.Revision)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusNotFound, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"release not found"},
+		}, w)
+
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(release); err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+}
+
+// HandleListReleaseHistory retrieves a history of releases based on a release name
+func (app *App) HandleListReleaseHistory(w http.ResponseWriter, r *http.Request) {
+	name := chi.URLParam(r, "name")
+
+	form := &forms.ListReleaseHistoryForm{
+		ReleaseForm: &forms.ReleaseForm{
+			Form: &helm.Form{},
+		},
+		Name: name,
+	}
+
+	agent, err := app.getAgentFromQueryParams(
+		w,
+		r,
+		form.ReleaseForm,
+		form.ReleaseForm.PopulateHelmOptionsFromQueryParams,
+	)
+
+	// errors are handled in app.getAgentFromQueryParams
+	if err != nil {
+		return
+	}
+
+	release, err := agent.GetReleaseHistory(form.Name)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusNotFound, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"release not found"},
+		}, w)
+
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(release); err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+}
+
+// HandleUpgradeRelease upgrades a release with new values.yaml
+func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
+	name := chi.URLParam(r, "name")
+
+	form := &forms.UpgradeReleaseForm{
+		ReleaseForm: &forms.ReleaseForm{
+			Form: &helm.Form{},
+		},
+		Name: name,
+	}
+
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	agent, err := app.getAgentFromReleaseForm(
+		w,
+		r,
+		form.ReleaseForm,
+	)
+
+	// errors are handled in app.getAgentFromBodyParams
+	if err != nil {
+		return
+	}
+
+	_, err = agent.UpgradeRelease(form.Name, form.Values)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseDeploy,
+			Errors: []string{"error upgrading release " + err.Error()},
+		}, w)
+
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}
+
+// HandleRollbackRelease rolls a release back to a specified revision
+func (app *App) HandleRollbackRelease(w http.ResponseWriter, r *http.Request) {
+	name := chi.URLParam(r, "name")
+
+	form := &forms.RollbackReleaseForm{
+		ReleaseForm: &forms.ReleaseForm{
+			Form: &helm.Form{},
+		},
+		Name: name,
+	}
+
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	agent, err := app.getAgentFromReleaseForm(
+		w,
+		r,
+		form.ReleaseForm,
+	)
+
+	// errors are handled in app.getAgentFromBodyParams
+	if err != nil {
+		return
+	}
+
+	err = agent.RollbackRelease(form.Name, form.Revision)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseDeploy,
+			Errors: []string{"error rolling back release " + err.Error()},
+		}, w)
+
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}
+
+// ------------------------ Release handler helper functions ------------------------ //
+
+// getAgentFromQueryParams uses the query params to populate a form, and then
+// passes that form to the underlying app.getAgentFromReleaseForm to create a new
+// Helm agent.
+func (app *App) getAgentFromQueryParams(
+	w http.ResponseWriter,
+	r *http.Request,
+	form *forms.ReleaseForm,
+	// populate uses the query params to populate a form
+	populate ...func(vals url.Values),
+) (*helm.Agent, error) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return nil, err
+	}
+
+	for _, f := range populate {
+		f(vals)
+	}
+
+	return app.getAgentFromReleaseForm(w, r, form)
+}
+
+// getAgentFromReleaseForm uses a non-validated form to construct a new Helm agent based on
+// the userID found in the session and the options required by the Helm agent.
+func (app *App) getAgentFromReleaseForm(
+	w http.ResponseWriter,
+	r *http.Request,
+	form *forms.ReleaseForm,
+) (*helm.Agent, error) {
+	// read the session in order to generate the Helm agent
+	session, err := app.store.Get(r, app.cookieName)
+
+	// since we have already authenticated the user, throw a data read error if the session
+	// cannot be found
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return nil, err
+	}
+
+	if userID, ok := session.Values["user_id"].(uint); ok {
+		form.PopulateHelmOptionsFromUserID(userID, app.repo.User)
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrReleaseValidateFields, w)
+		return nil, err
+	}
+
+	// create a new agent
+	var agent *helm.Agent
+
+	if app.testing {
+		agent = app.TestAgents.HelmAgent
+	} else {
+		agent, err = helm.GetAgentOutOfClusterConfig(form.Form, app.logger)
+	}
+
+	return agent, err
+}

+ 527 - 0
server/api/release_handler_test.go

@@ -0,0 +1,527 @@
+package api_test
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/porter-dev/porter/internal/helm"
+
+	"helm.sh/helm/v3/pkg/chart"
+	"helm.sh/helm/v3/pkg/release"
+	"helm.sh/helm/v3/pkg/storage/driver"
+)
+
+type releaseStub struct {
+	name           string
+	namespace      string
+	version        int
+	releaseVersion string
+	status         release.Status
+}
+
+// ------------------------- TEST TYPES AND MAIN LOOP ------------------------- //
+
+type releaseTest struct {
+	initializers []func(tester *tester)
+	namespace    string
+	msg          string
+	method       string
+	endpoint     string
+	body         string
+	expStatus    int
+	expBody      string
+	useCookie    bool
+	validators   []func(c *releaseTest, tester *tester, t *testing.T)
+}
+
+func testReleaseRequests(t *testing.T, tests []*releaseTest, canQuery bool) {
+	for _, c := range tests {
+		// create a new tester
+		tester := newTester(canQuery)
+
+		// if there's an initializer, call it
+		for _, init := range c.initializers {
+			init(tester)
+		}
+
+		tester.app.TestAgents.HelmAgent.ActionConfig.Releases.Driver.(*driver.Memory).SetNamespace(c.namespace)
+
+		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 listReleasesTests = []*releaseTest{
+	&releaseTest{
+		initializers: []func(tester *tester){
+			initDefaultReleases,
+		},
+		msg:    "List releases no namespace",
+		method: "GET",
+		endpoint: "/api/releases?" + url.Values{
+			"namespace":    []string{""},
+			"context":      []string{"context-test"},
+			"storage":      []string{"memory"},
+			"limit":        []string{"20"},
+			"skip":         []string{"0"},
+			"byDate":       []string{"false"},
+			"statusFilter": []string{"deployed"},
+		}.Encode(),
+		body:      "",
+		expStatus: http.StatusOK,
+		expBody:   releaseStubsToReleaseJSON(sampleReleaseStubs),
+		useCookie: true,
+		validators: []func(c *releaseTest, tester *tester, t *testing.T){
+			releaseReleaseArrBodyValidator,
+		},
+	},
+	&releaseTest{
+		initializers: []func(tester *tester){
+			initDefaultReleases,
+		},
+		msg:       "List releases with namespace",
+		method:    "GET",
+		namespace: "default",
+		endpoint: "/api/releases?" + url.Values{
+			"namespace":    []string{"default"},
+			"context":      []string{"context-test"},
+			"storage":      []string{"memory"},
+			"limit":        []string{"20"},
+			"skip":         []string{"0"},
+			"byDate":       []string{"false"},
+			"statusFilter": []string{"deployed"},
+		}.Encode(),
+		body:      "",
+		expStatus: http.StatusOK,
+		expBody: releaseStubsToReleaseJSON([]releaseStub{
+			sampleReleaseStubs[0],
+			sampleReleaseStubs[2],
+		}),
+		useCookie: true,
+		validators: []func(c *releaseTest, tester *tester, t *testing.T){
+			releaseReleaseArrBodyValidator,
+		},
+	},
+	&releaseTest{
+		initializers: []func(tester *tester){
+			initDefaultReleases,
+		},
+		msg:       "List releases missing required",
+		method:    "GET",
+		namespace: "default",
+		endpoint: "/api/releases?" + url.Values{
+			"namespace":    []string{"default"},
+			"storage":      []string{"memory"},
+			"limit":        []string{"20"},
+			"skip":         []string{"0"},
+			"byDate":       []string{"false"},
+			"statusFilter": []string{"deployed"},
+		}.Encode(),
+		body:      "",
+		expStatus: http.StatusUnprocessableEntity,
+		expBody:   `{"code":601,"errors":["required validation failed"]}`,
+		useCookie: true,
+		validators: []func(c *releaseTest, tester *tester, t *testing.T){
+			releaseBasicBodyValidator,
+		},
+	},
+}
+
+func TestHandleListReleases(t *testing.T) {
+	testReleaseRequests(t, listReleasesTests, true)
+}
+
+var getReleaseTests = []*releaseTest{
+	&releaseTest{
+		initializers: []func(tester *tester){
+			initDefaultReleases,
+		},
+		msg:       "Get releases",
+		method:    "GET",
+		namespace: "default",
+		endpoint: "/api/releases/airwatch/1?" + url.Values{
+			"namespace": []string{""},
+			"context":   []string{"context-test"},
+			"storage":   []string{"memory"},
+		}.Encode(),
+		body:      "",
+		expStatus: http.StatusOK,
+		expBody:   releaseStubToReleaseJSON(sampleReleaseStubs[0]),
+		useCookie: true,
+		validators: []func(c *releaseTest, tester *tester, t *testing.T){
+			releaseReleaseBodyValidator,
+		},
+	},
+	&releaseTest{
+		initializers: []func(tester *tester){
+			initDefaultReleases,
+		},
+		msg:       "Release not found",
+		method:    "GET",
+		namespace: "default",
+		endpoint: "/api/releases/airwatch/5?" + url.Values{
+			"namespace": []string{""},
+			"context":   []string{"context-test"},
+			"storage":   []string{"memory"},
+		}.Encode(),
+		body:      "",
+		expStatus: http.StatusNotFound,
+		expBody:   `{"code":602,"errors":["release not found"]}`,
+		useCookie: true,
+		validators: []func(c *releaseTest, tester *tester, t *testing.T){
+			releaseBasicBodyValidator,
+		},
+	},
+}
+
+func TestHandleGetRelease(t *testing.T) {
+	testReleaseRequests(t, getReleaseTests, true)
+}
+
+var listReleaseHistoryTests = []*releaseTest{
+	&releaseTest{
+		initializers: []func(tester *tester){
+			initHistoryReleases,
+		},
+		msg:       "List release history",
+		method:    "GET",
+		namespace: "default",
+		endpoint: "/api/releases/wordpress/history?" + url.Values{
+			"namespace": []string{""},
+			"context":   []string{"context-test"},
+			"storage":   []string{"memory"},
+		}.Encode(),
+		body:      "",
+		expStatus: http.StatusOK,
+		expBody:   releaseStubsToReleaseJSON(historyReleaseStubs),
+		useCookie: true,
+		validators: []func(c *releaseTest, tester *tester, t *testing.T){
+			releaseReleaseArrBodyValidator,
+		},
+	},
+	&releaseTest{
+		initializers: []func(tester *tester){
+			initDefaultReleases,
+		},
+		msg:       "Release not found",
+		method:    "GET",
+		namespace: "default",
+		endpoint: "/api/releases/asldfkja/history?" + url.Values{
+			"namespace": []string{""},
+			"context":   []string{"context-test"},
+			"storage":   []string{"memory"},
+		}.Encode(),
+		body:      "",
+		expStatus: http.StatusNotFound,
+		expBody:   `{"code":602,"errors":["release not found"]}`,
+		useCookie: true,
+		validators: []func(c *releaseTest, tester *tester, t *testing.T){
+			releaseBasicBodyValidator,
+		},
+	},
+}
+
+func TestHandleListReleaseHistory(t *testing.T) {
+	testReleaseRequests(t, listReleaseHistoryTests, true)
+}
+
+var upgradeReleaseTests = []*releaseTest{
+	&releaseTest{
+		initializers: []func(tester *tester){
+			initHistoryReleases,
+		},
+		msg:       "Upgrade relase",
+		method:    "POST",
+		namespace: "default",
+		endpoint:  "/api/releases/wordpress/upgrade",
+		body: `
+			{
+				"namespace": "default",
+				"context": "context-test",
+				"storage": "memory",
+				"values": "\nfoo: bar\n"
+			}
+		`,
+		expStatus: http.StatusOK,
+		expBody:   ``,
+		useCookie: true,
+		validators: []func(c *releaseTest, tester *tester, t *testing.T){
+			func(c *releaseTest, tester *tester, t *testing.T) {
+				req, err := http.NewRequest(
+					"GET",
+					"/api/releases/wordpress/3?"+url.Values{
+						"namespace": []string{"default"},
+						"context":   []string{"context-test"},
+						"storage":   []string{"memory"},
+					}.Encode(),
+					strings.NewReader(""),
+				)
+
+				req.AddCookie(tester.cookie)
+
+				if err != nil {
+					t.Fatal(err)
+				}
+
+				rr2 := httptest.NewRecorder()
+				tester.router.ServeHTTP(rr2, req)
+
+				gotBody := &release.Release{}
+				expBody := &release.Release{}
+
+				expBodyJSON := releaseStubToReleaseJSON(releaseStub{"wordpress", "default", 3, "1.0.2", release.StatusDeployed})
+
+				json.Unmarshal(rr2.Body.Bytes(), gotBody)
+				json.Unmarshal([]byte(expBodyJSON), expBody)
+
+				// just check name and version match, other items will be different
+				if gotBody.Name != expBody.Name {
+					t.Errorf("%s, validation wrong body: got %v want %v",
+						c.msg, gotBody.Name, expBody.Name)
+				}
+
+				if gotBody.Version != expBody.Version {
+					t.Errorf("%s, validation wrong body: got %v want %v",
+						c.msg, gotBody.Version, expBody.Version)
+				}
+
+				expConfig := map[string]interface{}{
+					"foo": "bar",
+				}
+
+				if !reflect.DeepEqual(gotBody.Config, expConfig) {
+					t.Errorf("%s, validation wrong config: got %v want %v",
+						c.msg, gotBody.Config, expConfig)
+				}
+			},
+		},
+	},
+}
+
+func TestUpgradeRelease(t *testing.T) {
+	testReleaseRequests(t, upgradeReleaseTests, true)
+}
+
+var rollbackReleaseTests = []*releaseTest{
+	&releaseTest{
+		initializers: []func(tester *tester){
+			initHistoryReleases,
+		},
+		msg:       "Rollback relase",
+		method:    "POST",
+		namespace: "default",
+		endpoint:  "/api/releases/wordpress/rollback",
+		body: `
+			{
+				"namespace": "default",
+				"context": "context-test",
+				"storage": "memory",
+				"revision": 1
+			}
+		`,
+		expStatus: http.StatusOK,
+		expBody:   ``,
+		useCookie: true,
+		validators: []func(c *releaseTest, tester *tester, t *testing.T){
+			func(c *releaseTest, tester *tester, t *testing.T) {
+				req, err := http.NewRequest(
+					"GET",
+					"/api/releases/wordpress/3?"+url.Values{
+						"namespace": []string{"default"},
+						"context":   []string{"context-test"},
+						"storage":   []string{"memory"},
+					}.Encode(),
+					strings.NewReader(""),
+				)
+
+				req.AddCookie(tester.cookie)
+
+				if err != nil {
+					t.Fatal(err)
+				}
+
+				rr2 := httptest.NewRecorder()
+				tester.router.ServeHTTP(rr2, req)
+
+				gotBody := &release.Release{}
+				expBody := &release.Release{}
+
+				expBodyJSON := releaseStubToReleaseJSON(releaseStub{"wordpress", "default", 3, "1.0.1", release.StatusDeployed})
+
+				fmt.Println(rr2.Body.String())
+
+				json.Unmarshal(rr2.Body.Bytes(), gotBody)
+				json.Unmarshal([]byte(expBodyJSON), expBody)
+
+				// just check name and version match, other items will be different
+				if gotBody.Name != expBody.Name {
+					t.Errorf("%s, validation wrong body: got %v want %v",
+						c.msg, gotBody.Name, expBody.Name)
+				}
+
+				if gotBody.Version != expBody.Version {
+					t.Errorf("%s, validation wrong body: got %v want %v",
+						c.msg, gotBody.Version, expBody.Version)
+				}
+			},
+		},
+	},
+}
+
+func TestRollbackRelease(t *testing.T) {
+	testReleaseRequests(t, rollbackReleaseTests, true)
+}
+
+// ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
+
+func initDefaultReleases(tester *tester) {
+	initUserDefault(tester)
+
+	agent := tester.app.TestAgents.HelmAgent
+
+	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 initHistoryReleases(tester *tester) {
+	initUserDefault(tester)
+
+	agent := tester.app.TestAgents.HelmAgent
+
+	makeReleases(agent, historyReleaseStubs)
+
+	// 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("")
+}
+
+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},
+}
+
+var historyReleaseStubs = []releaseStub{
+	releaseStub{"wordpress", "default", 1, "1.0.1", release.StatusSuperseded},
+	releaseStub{"wordpress", "default", 2, "1.0.2", release.StatusDeployed},
+}
+
+func releaseStubsToReleaseJSON(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 releaseStubToReleaseJSON(r releaseStub) string {
+	rel := releaseStubToRelease(r)
+
+	str, _ := json.Marshal(rel)
+
+	return string(str)
+}
+
+func releaseStubToRelease(r releaseStub) *release.Release {
+	return &release.Release{
+		Name:      r.name,
+		Namespace: r.namespace,
+		Version:   r.version,
+		Info: &release.Info{
+			Status: r.status,
+		},
+		Chart: &chart.Chart{
+			Metadata: &chart.Metadata{
+				Version: r.releaseVersion,
+				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 releaseBasicBodyValidator(c *releaseTest, tester *tester, t *testing.T) {
+	if body := tester.rr.Body.String(); strings.TrimSpace(body) != strings.TrimSpace(c.expBody) {
+		t.Errorf("%s, handler returned wrong body: got %v want %v",
+			c.msg, body, c.expBody)
+	}
+}
+
+func releaseReleaseBodyValidator(c *releaseTest, 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)
+	}
+}
+
+func releaseReleaseArrBodyValidator(c *releaseTest, 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)
+	}
+}

+ 6 - 4
server/router/router.go

@@ -27,10 +27,12 @@ func New(a *api.App, store sessions.Store, cookieName string) *chi.Mux {
 		r.Method("GET", "/auth/check", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleAuthCheck, l)))
 		r.Method("POST", "/logout", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleLogoutUser, l)))
 
-		// /api/charts routes
-		r.Method("GET", "/charts", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListCharts, l)))
-		r.Method("GET", "/charts/{name}/history", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListChartHistory, l)))
-		r.Method("GET", "/charts/{name}/{revision}", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleGetChart, l)))
+		// /api/releases routes
+		r.Method("GET", "/releases", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListReleases, l)))
+		r.Method("GET", "/releases/{name}/history", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListReleaseHistory, l)))
+		r.Method("POST", "/releases/{name}/upgrade", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleUpgradeRelease, l)))
+		r.Method("GET", "/releases/{name}/{revision}", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleGetRelease, l)))
+		r.Method("POST", "/releases/{name}/rollback", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleRollbackRelease, l)))
 
 		// /api/k8s routes
 		r.Method("GET", "/k8s/namespaces", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListNamespaces, l)))