Procházet zdrojové kódy

Merge pull request #57 from porter-dev/frontend-integration

Frontend integration
abelanger5 před 5 roky
rodič
revize
adcc821c43

+ 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",
@@ -972,6 +977,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",
@@ -2490,6 +2503,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",
@@ -4128,6 +4146,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",
@@ -6188,6 +6215,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",

+ 2 - 0
dashboard/package.json

@@ -3,10 +3,12 @@
   "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",

+ 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;
+`;

+ 15 - 7
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,8 +53,8 @@ class YamlEditor extends Component<PropsType, StateType> {
             onChange={this.props.onChange}
             name='codeEditor'
             editorProps={{ $blockScrolling: true }}
+            height={this.props.height}
             width='100%'
-            height='295px'
             style={{ borderRadius: '5px' }}
           />
         </Editor>
@@ -61,15 +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;
+  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;
+  }
 `;

+ 49 - 7
dashboard/src/main/home/dashboard/expanded-chart/ExpandedChart.tsx

@@ -5,7 +5,9 @@ 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';
 
 type PropsType = {
   currentChart: ChartType,
@@ -14,12 +16,19 @@ type PropsType = {
 };
 
 type StateType = {
-  showRevisions: boolean
+  showRevisions: boolean,
+  currentTab: string
 };
 
+const tabOptions = [
+  { label: 'Chart Overview', value: 'overview' },
+  { label: 'Values Editor', value: 'values' }
+]
+
 export default class ExpandedChart extends Component<PropsType, StateType> {
   state = {
-    showRevisions: false
+    showRevisions: false,
+    currentTab: 'overview'
   }
 
   renderIcon = () => {
@@ -39,6 +48,25 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     return `${time} on ${date}`;
   }
 
+  renderTabContents = () => {
+    let { currentChart, refreshChart } = this.props;
+
+    if (this.state.currentTab === 'overview') {
+      return (
+        <Wrapper>
+          <Placeholder>(Under construction)</Placeholder>
+        </Wrapper>
+      );
+    }
+
+    return (
+      <ValuesYaml
+        currentChart={currentChart}
+        refreshChart={refreshChart}
+      />
+    );
+  }
+
   render() {
     let { currentChart, setCurrentChart, refreshChart } = this.props;
     let chart = currentChart;
@@ -82,9 +110,14 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
           refreshChart={refreshChart}
         />
 
-        <ChartSection>
-          <Placeholder>(Under construction)</Placeholder>
-        </ChartSection>
+        <TabSelector
+          options={tabOptions}
+          setCurrentTab={(value: string) => this.setState({ currentTab: value })}
+          tabWidth='120px'
+        />
+        <ContentSection>
+          {this.renderTabContents()}
+        </ContentSection>
       </StyledExpandedChart>
     );
   }
@@ -92,22 +125,31 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
 ExpandedChart.contextType = Context;
 
+const Wrapper = styled.div`
+  width: 100%;
+  height: 100%;
+  background: #ffffff11;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
 const Placeholder = styled.div`
   color: #ffffff66;
   padding-bottom: 30px;
 `;
 
-const ChartSection = styled.div`
+const ContentSection = styled.div`
   display: flex;
   margin-top: 20px;
   border-radius: 5px;
   flex: 1;
   width: 100%;
-  background: #ffffff11;
   display: flex;
   justify-content: center;
   align-items: center;
   font-size: 13px;
+  overflow-y: auto;
 `;
 
 const StatusColor = styled.div`

+ 12 - 4
dashboard/src/main/home/dashboard/expanded-chart/RevisionSection.tsx

@@ -47,6 +47,13 @@ export default class RevisionSection extends Component<PropsType, StateType> {
     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();
@@ -61,10 +68,10 @@ export default class RevisionSection extends Component<PropsType, StateType> {
     api.rollbackChart('<token>', {
       namespace: this.props.chart.namespace,
       context: this.context.currentCluster,
-      storage: 'secret'
-    }, {
-      name: this.props.chart.name,
+      storage: 'secret',
       revision: revisionNumber
+    }, {
+      name: this.props.chart.name
     }, (err: any, res: any) => {
       if (err) {
         console.log(err)
@@ -301,6 +308,7 @@ const RevisionsTable = styled.table`
   margin-top: 5px;
   padding-left: 32px;
   padding-bottom: 20px;
+  min-width: 500px;
 `;
 
 const Revision = styled.div`
@@ -339,7 +347,7 @@ const StyledRevisionSection = styled.div`
   width: 100%;
   max-height: ${(props: { showRevisions: boolean }) => props.showRevisions ? '255px' : '40px'};
   background: #ffffff11;
-  margin-top: 25px;
+  margin: 25px 0px;
   overflow: hidden;
   border-radius: 5px;
   animation: ${(props: { showRevisions: boolean }) => props.showRevisions ? 'expandRevisions 0.3s' : ''};

+ 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 } 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 } = this.context;
+    this.setState({ saveValuesStatus: 'loading' });
+
+    api.upgradeChartValues('<token>', {
+      namespace: this.props.currentChart.namespace,
+      context: currentCluster,
+      storage: 'secret',
+      values: this.state.values
+    }, { name: this.props.currentChart.name }, (err: any, res: any) => {
+      if (err) {
+        console.log(err)
+        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%;
+`;

+ 15 - 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,6 +160,8 @@ 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
@@ -204,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>
     );
@@ -319,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';

+ 18 - 7
dashboard/src/shared/api.tsx

@@ -48,14 +48,14 @@ const getCharts = baseApi<{
   skip: number,
   byDate: boolean,
   statusFilter: string[]
-}>('GET', '/api/charts');
+}>('GET', '/api/releases');
 
 const getChart = baseApi<{
   namespace: string,
   context: string,
   storage: string
 }, { name: string, revision: number }>('GET', pathParams => {
-  return `/api/charts/${pathParams.name}/${pathParams.revision}`;
+  return `/api/releases/${pathParams.name}/${pathParams.revision}`;
 });
 
 const getNamespaces = baseApi<{
@@ -67,15 +67,25 @@ const getRevisions = baseApi<{
   context: string,
   storage: string
 }, { name: string }>('GET', pathParams => {
-  return `/api/charts/${pathParams.name}/history`;
+  return `/api/releases/${pathParams.name}/history`;
 });
 
 const rollbackChart = baseApi<{
   namespace: string,
   context: string,
-  storage: string
-}, { name: string, revision: number }>('POST', pathParams => {
-  return `/api/charts/rollback/${pathParams.name}/${pathParams.revision}`;
+  storage: string,
+  revision: number
+}, { name: string }>('POST', pathParams => {
+  return `/api/releases/${pathParams.name}/rollback`;
+});
+
+const upgradeChartValues = baseApi<{
+  namespace: string,
+  context: string,
+  storage: string,
+  values: string
+}, { name: string }>('POST', pathParams => {
+  return `/api/releases/${pathParams.name}/upgrade`;
 });
 
 // Bundle export to allow default api import (api.<method> is more readable)
@@ -91,5 +101,6 @@ export default {
   getChart,
   getNamespaces,
   getRevisions,
-  rollbackChart
+  rollbackChart,
+  upgradeChartValues
 }

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

@@ -25,6 +25,7 @@ export interface ChartType {
       apiVersion: string
     },
   },
+  config: string,
   version: number,
   namespace: string
 }

+ 25 - 24
docs/API.md

@@ -13,12 +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)
-  - [`POST /api/charts/rollback/{name}/{revision}`](#post-apichartsrollbacknamerevision)
-  - [`POST /api/charts/{name}/upgrade`](#post-apichartsnameupgrade)
+- [`/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)
 
@@ -419,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
 
@@ -446,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{
@@ -485,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
@@ -501,9 +501,9 @@ User object with only the id field. Other fields are empty - with values in para
 
 **Errors:** TBD
 
-#### `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:** 
 
@@ -527,7 +527,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{
@@ -566,7 +566,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
@@ -582,9 +582,9 @@ User object with only the id field. Other fields are empty - with values in para
 
 **Errors:** TBD
 
-#### `GET /api/charts/{name}/{revision}`
+#### `GET /api/releases/{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. 
+**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:** 
 
@@ -609,7 +609,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{
@@ -648,7 +648,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
@@ -664,14 +664,13 @@ Chart{
 
 **Errors:** TBD
 
-#### `POST /api/charts/rollback/{name}/{revision}`
+#### `POST /api/releases/{name}/rollback`
 
 **Description:** Rolls a release back to a specified revision. 
 
 **URL parameters:** 
 
 - `name` The name of the release.
-- `revision` The number of the release. 
 
 **Query parameters:** N/A
 
@@ -684,7 +683,9 @@ Chart{
   // The name of the context in the kubeconfig being used
   "context": String,
   // The Helm storage option to use
-  "storage": String("secret"|"configmap"|"memory")
+  "storage": String("secret"|"configmap"|"memory"),
+  // The revision number of the desired rollback target
+  "revision": Number
 }
 ```
 
@@ -729,9 +730,9 @@ Chart{
 
 **Errors:** TBD
 
-#### `POST /api/charts/{name}/upgrade`
+#### `POST /api/releases/{name}/upgrade`
 
-**Description:** Upgrades a chart with new `values.yaml`. 
+**Description:** Upgrades a release with new `values.yaml`. 
 
 **URL parameters:** 
 

+ 1 - 0
go.sum

@@ -1279,6 +1279,7 @@ k8s.io/component-base v0.18.8/go.mod h1:00frPRDas29rx58pPCxNkhUfPbwajlyyvu8ruNgS
 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=

+ 0 - 110
internal/forms/chart.go

@@ -1,110 +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"`
-}
-
-// RollbackChartForm represents the accepted values for getting a single Helm chart
-type RollbackChartForm struct {
-	*ChartForm
-	Name     string `json:"name" form:"required"`
-	Revision int    `json:"revision" form:"required"`
-}
-
-// UpgradeChartForm represents the accepted values for updating a Helm chart
-type UpgradeChartForm struct {
-	*ChartForm
-	Name   string `json:"name" form:"required"`
-	Values string `json:"values" 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"`
+}

+ 2 - 2
internal/helm/agent.go

@@ -47,8 +47,8 @@ func (a *Agent) GetReleaseHistory(
 	return cmd.Run(name)
 }
 
-// UpgradeChart upgrades a specific chart using a string of values.yaml
-func (a *Agent) UpgradeChart(
+// UpgradeRelease upgrades a specific release with new values.yaml
+func (a *Agent) UpgradeRelease(
 	name string,
 	values string,
 ) (*release.Release, error) {

+ 2 - 2
internal/helm/agent_test.go

@@ -283,7 +283,7 @@ var upgradeTests = []listReleaseTest{
 	},
 }
 
-func TestUpgradeChart(t *testing.T) {
+func TestUpgradeRelease(t *testing.T) {
 	for _, tc := range upgradeTests {
 		agent := newAgentFixture(t, tc.namespace)
 		makeReleases(t, agent, tc.releases)
@@ -292,7 +292,7 @@ func TestUpgradeChart(t *testing.T) {
 		// namespace, so we have to reset the namespace of the storage driver
 		agent.ActionConfig.Releases.Driver.(*driver.Memory).SetNamespace(tc.namespace)
 
-		agent.UpgradeChart("wordpress", "")
+		agent.UpgradeRelease("wordpress", "")
 
 		releases, err := agent.GetReleaseHistory("wordpress")
 

+ 0 - 307
server/api/chart_handler.go

@@ -1,307 +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
-	}
-}
-
-// HandleUpgradeChart upgrades a chart with new values.yaml
-func (app *App) HandleUpgradeChart(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.UpgradeChartForm{
-		ChartForm: &forms.ChartForm{
-			Form: &helm.Form{},
-		},
-		Name: name,
-	}
-
-	// decode from JSON to form value
-	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
-		app.handleErrorFormDecoding(err, ErrUserDecode, w)
-		return
-	}
-
-	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)
-	}
-
-	_, err = agent.UpgradeChart(form.Name, form.Values)
-
-	if err != nil {
-		app.handleErrorInternal(err, w)
-		return
-	}
-
-	w.WriteHeader(http.StatusOK)
-}
-
-// HandleRollbackChart rolls a release back to a specified revision
-func (app *App) HandleRollbackChart(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),
-	}
-
-	// decode from JSON to form value
-	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
-		app.handleErrorFormDecoding(err, ErrUserDecode, w)
-		return
-	}
-
-	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)
-	}
-
-	err = agent.RollbackRelease(form.Name, form.Revision)
-
-	if err != nil {
-		app.handleErrorInternal(err, w)
-		return
-	}
-
-	w.WriteHeader(http.StatusOK)
-}

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

+ 268 - 0
server/api/release_handler.go

@@ -0,0 +1,268 @@
+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
+)
+
+// 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.handleErrorRead(err, ErrReleaseReadData, 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.handleErrorFormValidation(err, ErrReleaseValidateFields, 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.handleErrorInternal(err, 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.handleErrorInternal(err, 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
+}

+ 74 - 73
server/api/chart_handler_test.go → server/api/release_handler_test.go

@@ -18,16 +18,16 @@ import (
 )
 
 type releaseStub struct {
-	name         string
-	namespace    string
-	version      int
-	chartVersion string
-	status       release.Status
+	name           string
+	namespace      string
+	version        int
+	releaseVersion string
+	status         release.Status
 }
 
 // ------------------------- TEST TYPES AND MAIN LOOP ------------------------- //
 
-type chartTest struct {
+type releaseTest struct {
 	initializers []func(tester *tester)
 	namespace    string
 	msg          string
@@ -37,10 +37,10 @@ type chartTest struct {
 	expStatus    int
 	expBody      string
 	useCookie    bool
-	validators   []func(c *chartTest, tester *tester, t *testing.T)
+	validators   []func(c *releaseTest, tester *tester, t *testing.T)
 }
 
-func testChartRequests(t *testing.T, tests []*chartTest, canQuery bool) {
+func testReleaseRequests(t *testing.T, tests []*releaseTest, canQuery bool) {
 	for _, c := range tests {
 		// create a new tester
 		tester := newTester(canQuery)
@@ -86,14 +86,14 @@ func testChartRequests(t *testing.T, tests []*chartTest, canQuery bool) {
 
 // ------------------------- TEST FIXTURES AND FUNCTIONS  ------------------------- //
 
-var listChartsTests = []*chartTest{
-	&chartTest{
+var listReleasesTests = []*releaseTest{
+	&releaseTest{
 		initializers: []func(tester *tester){
-			initDefaultCharts,
+			initDefaultReleases,
 		},
-		msg:    "List charts",
+		msg:    "List releases",
 		method: "GET",
-		endpoint: "/api/charts?" + url.Values{
+		endpoint: "/api/releases?" + url.Values{
 			"namespace":    []string{""},
 			"context":      []string{"context-test"},
 			"storage":      []string{"memory"},
@@ -104,20 +104,20 @@ var listChartsTests = []*chartTest{
 		}.Encode(),
 		body:      "",
 		expStatus: http.StatusOK,
-		expBody:   releaseStubsToChartJSON(sampleReleaseStubs),
+		expBody:   releaseStubsToReleaseJSON(sampleReleaseStubs),
 		useCookie: true,
-		validators: []func(c *chartTest, tester *tester, t *testing.T){
-			chartReleaseArrBodyValidator,
+		validators: []func(c *releaseTest, tester *tester, t *testing.T){
+			releaseReleaseArrBodyValidator,
 		},
 	},
-	&chartTest{
+	&releaseTest{
 		initializers: []func(tester *tester){
-			initDefaultCharts,
+			initDefaultReleases,
 		},
-		msg:       "List charts",
+		msg:       "List releases",
 		method:    "GET",
 		namespace: "default",
-		endpoint: "/api/charts?" + url.Values{
+		endpoint: "/api/releases?" + url.Values{
 			"namespace":    []string{"default"},
 			"context":      []string{"context-test"},
 			"storage":      []string{"memory"},
@@ -128,84 +128,84 @@ var listChartsTests = []*chartTest{
 		}.Encode(),
 		body:      "",
 		expStatus: http.StatusOK,
-		expBody: releaseStubsToChartJSON([]releaseStub{
+		expBody: releaseStubsToReleaseJSON([]releaseStub{
 			sampleReleaseStubs[0],
 			sampleReleaseStubs[2],
 		}),
 		useCookie: true,
-		validators: []func(c *chartTest, tester *tester, t *testing.T){
-			chartReleaseArrBodyValidator,
+		validators: []func(c *releaseTest, tester *tester, t *testing.T){
+			releaseReleaseArrBodyValidator,
 		},
 	},
 }
 
-func TestHandleListCharts(t *testing.T) {
-	testChartRequests(t, listChartsTests, true)
+func TestHandleListReleases(t *testing.T) {
+	testReleaseRequests(t, listReleasesTests, true)
 }
 
-var getChartTests = []*chartTest{
-	&chartTest{
+var getReleaseTests = []*releaseTest{
+	&releaseTest{
 		initializers: []func(tester *tester){
-			initDefaultCharts,
+			initDefaultReleases,
 		},
-		msg:       "Get charts",
+		msg:       "Get releases",
 		method:    "GET",
 		namespace: "default",
-		endpoint: "/api/charts/airwatch/1?" + url.Values{
+		endpoint: "/api/releases/airwatch/1?" + url.Values{
 			"namespace": []string{""},
 			"context":   []string{"context-test"},
 			"storage":   []string{"memory"},
 		}.Encode(),
 		body:      "",
 		expStatus: http.StatusOK,
-		expBody:   releaseStubToChartJSON(sampleReleaseStubs[0]),
+		expBody:   releaseStubToReleaseJSON(sampleReleaseStubs[0]),
 		useCookie: true,
-		validators: []func(c *chartTest, tester *tester, t *testing.T){
-			chartReleaseBodyValidator,
+		validators: []func(c *releaseTest, tester *tester, t *testing.T){
+			releaseReleaseBodyValidator,
 		},
 	},
 }
 
-func TestHandleGetChart(t *testing.T) {
-	testChartRequests(t, getChartTests, true)
+func TestHandleGetRelease(t *testing.T) {
+	testReleaseRequests(t, getReleaseTests, true)
 }
 
-var listChartHistoryTests = []*chartTest{
-	&chartTest{
+var listReleaseHistoryTests = []*releaseTest{
+	&releaseTest{
 		initializers: []func(tester *tester){
-			initHistoryCharts,
+			initHistoryReleases,
 		},
-		msg:       "List chart history",
+		msg:       "List release history",
 		method:    "GET",
 		namespace: "default",
-		endpoint: "/api/charts/wordpress/history?" + url.Values{
+		endpoint: "/api/releases/wordpress/history?" + url.Values{
 			"namespace": []string{""},
 			"context":   []string{"context-test"},
 			"storage":   []string{"memory"},
 		}.Encode(),
 		body:      "",
 		expStatus: http.StatusOK,
-		expBody:   releaseStubsToChartJSON(historyReleaseStubs),
+		expBody:   releaseStubsToReleaseJSON(historyReleaseStubs),
 		useCookie: true,
-		validators: []func(c *chartTest, tester *tester, t *testing.T){
-			chartReleaseArrBodyValidator,
+		validators: []func(c *releaseTest, tester *tester, t *testing.T){
+			releaseReleaseArrBodyValidator,
 		},
 	},
 }
 
-func TestHandleListChartHistory(t *testing.T) {
-	testChartRequests(t, listChartHistoryTests, true)
+func TestHandleListReleaseHistory(t *testing.T) {
+	testReleaseRequests(t, listReleaseHistoryTests, true)
 }
 
-var upgradeChartTests = []*chartTest{
-	&chartTest{
+var upgradeReleaseTests = []*releaseTest{
+	&releaseTest{
 		initializers: []func(tester *tester){
-			initHistoryCharts,
+			initHistoryReleases,
 		},
 		msg:       "Upgrade relase",
 		method:    "POST",
 		namespace: "default",
-		endpoint:  "/api/charts/wordpress/upgrade",
+		endpoint:  "/api/releases/wordpress/upgrade",
 		body: `
 			{
 				"namespace": "default",
@@ -217,11 +217,11 @@ var upgradeChartTests = []*chartTest{
 		expStatus: http.StatusOK,
 		expBody:   ``,
 		useCookie: true,
-		validators: []func(c *chartTest, tester *tester, t *testing.T){
-			func(c *chartTest, tester *tester, t *testing.T) {
+		validators: []func(c *releaseTest, tester *tester, t *testing.T){
+			func(c *releaseTest, tester *tester, t *testing.T) {
 				req, err := http.NewRequest(
 					"GET",
-					"/api/charts/wordpress/3?"+url.Values{
+					"/api/releases/wordpress/3?"+url.Values{
 						"namespace": []string{"default"},
 						"context":   []string{"context-test"},
 						"storage":   []string{"memory"},
@@ -241,7 +241,7 @@ var upgradeChartTests = []*chartTest{
 				gotBody := &release.Release{}
 				expBody := &release.Release{}
 
-				expBodyJSON := releaseStubToChartJSON(releaseStub{"wordpress", "default", 3, "1.0.2", release.StatusDeployed})
+				expBodyJSON := releaseStubToReleaseJSON(releaseStub{"wordpress", "default", 3, "1.0.2", release.StatusDeployed})
 
 				json.Unmarshal(rr2.Body.Bytes(), gotBody)
 				json.Unmarshal([]byte(expBodyJSON), expBody)
@@ -270,34 +270,35 @@ var upgradeChartTests = []*chartTest{
 	},
 }
 
-func TestUpgradeChart(t *testing.T) {
-	testChartRequests(t, upgradeChartTests, true)
+func TestUpgradeRelease(t *testing.T) {
+	testReleaseRequests(t, upgradeReleaseTests, true)
 }
 
-var rollbackChartTests = []*chartTest{
-	&chartTest{
+var rollbackReleaseTests = []*releaseTest{
+	&releaseTest{
 		initializers: []func(tester *tester){
-			initHistoryCharts,
+			initHistoryReleases,
 		},
 		msg:       "Rollback relase",
 		method:    "POST",
 		namespace: "default",
-		endpoint:  "/api/charts/rollback/wordpress/1",
+		endpoint:  "/api/releases/wordpress/rollback",
 		body: `
 			{
 				"namespace": "default",
 				"context": "context-test",
-				"storage": "memory"
+				"storage": "memory",
+				"revision": 1
 			}
 		`,
 		expStatus: http.StatusOK,
 		expBody:   ``,
 		useCookie: true,
-		validators: []func(c *chartTest, tester *tester, t *testing.T){
-			func(c *chartTest, tester *tester, t *testing.T) {
+		validators: []func(c *releaseTest, tester *tester, t *testing.T){
+			func(c *releaseTest, tester *tester, t *testing.T) {
 				req, err := http.NewRequest(
 					"GET",
-					"/api/charts/wordpress/3?"+url.Values{
+					"/api/releases/wordpress/3?"+url.Values{
 						"namespace": []string{"default"},
 						"context":   []string{"context-test"},
 						"storage":   []string{"memory"},
@@ -317,7 +318,7 @@ var rollbackChartTests = []*chartTest{
 				gotBody := &release.Release{}
 				expBody := &release.Release{}
 
-				expBodyJSON := releaseStubToChartJSON(releaseStub{"wordpress", "default", 3, "1.0.1", release.StatusDeployed})
+				expBodyJSON := releaseStubToReleaseJSON(releaseStub{"wordpress", "default", 3, "1.0.1", release.StatusDeployed})
 
 				fmt.Println(rr2.Body.String())
 
@@ -339,13 +340,13 @@ var rollbackChartTests = []*chartTest{
 	},
 }
 
-func TestRollbackChart(t *testing.T) {
-	testChartRequests(t, rollbackChartTests, true)
+func TestRollbackRelease(t *testing.T) {
+	testReleaseRequests(t, rollbackReleaseTests, true)
 }
 
 // ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
 
-func initDefaultCharts(tester *tester) {
+func initDefaultReleases(tester *tester) {
 	initUserDefault(tester)
 
 	agent := tester.app.TestAgents.HelmAgent
@@ -357,7 +358,7 @@ func initDefaultCharts(tester *tester) {
 	agent.ActionConfig.Releases.Driver.(*driver.Memory).SetNamespace("")
 }
 
-func initHistoryCharts(tester *tester) {
+func initHistoryReleases(tester *tester) {
 	initUserDefault(tester)
 
 	agent := tester.app.TestAgents.HelmAgent
@@ -380,7 +381,7 @@ var historyReleaseStubs = []releaseStub{
 	releaseStub{"wordpress", "default", 2, "1.0.2", release.StatusDeployed},
 }
 
-func releaseStubsToChartJSON(rels []releaseStub) string {
+func releaseStubsToReleaseJSON(rels []releaseStub) string {
 	releases := make([]*release.Release, 0)
 
 	for _, r := range rels {
@@ -394,7 +395,7 @@ func releaseStubsToChartJSON(rels []releaseStub) string {
 	return string(str)
 }
 
-func releaseStubToChartJSON(r releaseStub) string {
+func releaseStubToReleaseJSON(r releaseStub) string {
 	rel := releaseStubToRelease(r)
 
 	str, _ := json.Marshal(rel)
@@ -412,7 +413,7 @@ func releaseStubToRelease(r releaseStub) *release.Release {
 		},
 		Chart: &chart.Chart{
 			Metadata: &chart.Metadata{
-				Version: r.chartVersion,
+				Version: r.releaseVersion,
 				Icon:    "https://example.com/icon.png",
 			},
 		},
@@ -429,7 +430,7 @@ func makeReleases(agent *helm.Agent, rels []releaseStub) {
 	}
 }
 
-func chartReleaseBodyValidator(c *chartTest, tester *tester, t *testing.T) {
+func releaseReleaseBodyValidator(c *releaseTest, tester *tester, t *testing.T) {
 	gotBody := &release.Release{}
 	expBody := &release.Release{}
 
@@ -442,7 +443,7 @@ func chartReleaseBodyValidator(c *chartTest, tester *tester, t *testing.T) {
 	}
 }
 
-func chartReleaseArrBodyValidator(c *chartTest, tester *tester, t *testing.T) {
+func releaseReleaseArrBodyValidator(c *releaseTest, tester *tester, t *testing.T) {
 	gotBody := &[]release.Release{}
 	expBody := &[]release.Release{}
 

+ 6 - 6
server/router/router.go

@@ -27,12 +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("POST", "/charts/{name}/upgrade", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleUpgradeChart, l)))
-		r.Method("GET", "/charts/{name}/{revision}", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleGetChart, l)))
-		r.Method("POST", "/charts/rollback/{name}/{revision}", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleRollbackChart, 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)))