Explorar o código

0.2.0-form-refactor

jusrhee %!s(int64=5) %!d(string=hai) anos
pai
achega
8301776c02
Modificáronse 29 ficheiros con 1300 adicións e 355 borrados
  1. 0 1
      cli/cmd/github/release.go
  2. 11 1
      dashboard/src/components/TabSelector.tsx
  3. 1 1
      dashboard/src/components/YamlEditor.tsx
  4. 5 3
      dashboard/src/components/repo-selector/ContentsList.tsx
  5. 1 0
      dashboard/src/components/values-form/CheckboxRow.tsx
  6. 61 5
      dashboard/src/components/values-form/FormDebugger.tsx
  7. 238 131
      dashboard/src/components/values-form/FormWrapper.tsx
  8. 84 46
      dashboard/src/components/values-form/KeyValueArray.tsx
  9. 50 37
      dashboard/src/components/values-form/UploadArea.tsx
  10. 23 23
      dashboard/src/components/values-form/ValuesForm.tsx
  11. 42 5
      dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx
  12. 418 0
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx
  13. 83 9
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  14. 10 7
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  15. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  16. 3 4
      dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/GraphDisplay.tsx
  17. 26 19
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  18. 13 15
      dashboard/src/main/home/integrations/create-integration/GCRForm.tsx
  19. 1 2
      dashboard/src/main/home/integrations/create-integration/GKEForm.tsx
  20. 6 1
      dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx
  21. 15 12
      dashboard/src/main/home/modals/EnvEditorModal.tsx
  22. 1 1
      dashboard/src/main/home/provisioner/GCPFormSection.tsx
  23. 9 4
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  24. 8 4
      dashboard/src/shared/ace-porter-theme.js
  25. 2 0
      dashboard/src/shared/api.tsx
  26. 5 4
      internal/forms/k8s.go
  27. 99 10
      internal/kubernetes/agent.go
  28. 67 4
      server/api/k8s_handler.go
  29. 17 5
      server/api/release_handler.go

+ 0 - 1
cli/cmd/github/release.go

@@ -156,7 +156,6 @@ func (z *ZIPReleaseGetter) getDownloadRegexp() (*regexp.Regexp, error) {
 // // it, and adds the binary to the porter directory
 // func DownloadLatestServerRelease(porterDir string) error {
 // 	releaseURL, staticReleaseURL, err := getLatestReleaseDownloadURL()
-// 	fmt.Println(releaseURL)
 
 // 	if err != nil {
 // 		return err

+ 11 - 1
dashboard/src/components/TabSelector.tsx

@@ -44,6 +44,7 @@ export default class TabSelector extends Component<PropsType, StateType> {
     return (
       <StyledTabSelector>
         <TabWrapper>
+          <Line />
           {this.renderTabList()}
           <Tab lastItem={true} highlight={null}>
             {this.props.noBuffer ? null : <Buffer />}
@@ -55,6 +56,16 @@ export default class TabSelector extends Component<PropsType, StateType> {
   }
 }
 
+const Line = styled.div`
+  height: 1px;
+  position: absolute;
+  top: 29px;
+  z-index: 0;
+  left: 0;
+  background: #aaaabb55;
+  width: 100%;
+`;
+
 const Buffer = styled.div`
   width: 138px;
   height: 10px;
@@ -98,7 +109,6 @@ const StyledTabSelector = styled.div`
   display: flex;
   width: calc(100% - 2px);
   align-items: center;
-  border-bottom: 1px solid #aaaabb55;
   padding-bottom: 1px;
   margin-left: 1px;
   position: relative;

+ 1 - 1
dashboard/src/components/YamlEditor.tsx

@@ -2,7 +2,7 @@ import React, { Component } from "react";
 import styled from "styled-components";
 import AceEditor from "react-ace";
 
-import "shared/ace-porter-theme"
+import "shared/ace-porter-theme";
 import "ace-builds/src-noconflict/mode-yaml";
 
 type PropsType = {

+ 5 - 3
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -213,9 +213,11 @@ export default class ContentsList extends Component<PropsType, StateType> {
   };
 
   renderOverlay = () => {
-    console.log(this.props.procfilePath)
+    console.log(this.props.procfilePath);
     if (this.props.procfilePath) {
-      let processes = this.state.processes ? Object.keys(this.state.processes) : [];
+      let processes = this.state.processes
+        ? Object.keys(this.state.processes)
+        : [];
       return (
         <Overlay>
           <BgOverlay
@@ -294,7 +296,7 @@ export default class ContentsList extends Component<PropsType, StateType> {
                 this.state.processes &&
                 Object.keys(this.state.processes).length > 0
               ) {
-                console.log('setting procfile')
+                console.log("setting procfile");
                 this.props.setProcfilePath("./Procfile");
               }
             }}

+ 1 - 0
dashboard/src/components/values-form/CheckboxRow.tsx

@@ -35,6 +35,7 @@ const CheckboxWrapper = styled.div`
   display: flex;
   align-items: center;
   cursor: pointer;
+  font-size: 13px;
   :hover {
     > div {
       background: #ffffff22;

+ 61 - 5
dashboard/src/components/values-form/FormDebugger.tsx

@@ -1,6 +1,8 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 import AceEditor from "react-ace";
+import FormWrapper from "components/values-form/FormWrapper";
+import CheckboxRow from "components/values-form/CheckboxRow";
 import yaml from "js-yaml";
 
 import "shared/ace-porter-theme"
@@ -15,20 +17,38 @@ type PropsType = {
 
 type StateType = {
   rawYaml: string;
+  showBonusTabs: boolean;
+  showStateDebugger: boolean;
 };
 
+const tabOptions = [
+  { value: "a", label: "Bonus Tab A" },
+  { value: "b", label: "Bonus Tab B" },
+];
+
 export default class FormDebugger extends Component<PropsType, StateType> {
   state = {
     rawYaml: "",
+    showBonusTabs: false,
+    showStateDebugger: true,
   }
 
-  renderForm = () => {
-    let formData = yaml.load(this.state.rawYaml);
-    return <h1>silver lining</h1>
+  renderTabContents = (currentTab: string) => {
+    return (
+      <TabWrapper>
+        {this.state.rawYaml.toString().slice(0, 300) || "No raw YAML inputted."}
+      </TabWrapper>
+    )
   }
 
   aceEditorRef = React.createRef<AceEditor>();
   render() {
+    let formData = {};
+    try {
+      formData = yaml.load(this.state.rawYaml);
+    } catch (err: any) {
+      console.log("YAML parsing error.")
+    }
     return (
       <StyledFormDebugger>
         <Button onClick={this.props.goBack}>
@@ -52,7 +72,8 @@ export default class FormDebugger extends Component<PropsType, StateType> {
             style={{ 
               borderRadius: "5px", 
               border: "1px solid #ffffff22",
-              marginTop: "27px"
+              marginTop: "27px",
+              marginBottom: "27px"
             }}
             showPrintMargin={false}
             showGutter={true}
@@ -60,13 +81,48 @@ export default class FormDebugger extends Component<PropsType, StateType> {
           />
         </EditorWrapper>
 
+        <CheckboxRow
+          label="Show form state debugger"
+          checked={this.state.showStateDebugger}
+          toggle={() => this.setState({ showStateDebugger: !this.state.showStateDebugger })}
+        />
+        <CheckboxRow
+          label="Include non-form dummy tabs"
+          checked={this.state.showBonusTabs}
+          toggle={() => this.setState({ showBonusTabs: !this.state.showBonusTabs })}
+        />
+
         <Heading>🎨 Rendered Form</Heading>
-        {this.renderForm()}
+        <Br />
+        <FormWrapper
+          showStateDebugger={this.state.showStateDebugger}
+          formData={formData}
+          tabOptions={this.state.showBonusTabs ? tabOptions : []}
+          renderTabContents={this.state.showBonusTabs ? this.renderTabContents : null}
+        />
       </StyledFormDebugger>
     );
   }
 }
 
+const Br = styled.div`
+  width: 100%;
+  height: 12px;
+`;
+
+const TabWrapper = styled.div`
+  background: #ffffff11;
+  height: 200px;
+  width: 100%;
+  border-radius: 5px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  overflow: auto;
+  padding: 50px;
+`;
+
 const EditorWrapper = styled.div`
   .ace_editor,
   .ace_editor * {

+ 238 - 131
dashboard/src/components/values-form/FormWrapper.tsx

@@ -1,173 +1,280 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 
-import { Section, FormElement } from "../../shared/types";
-import { Context } from "../../shared/Context";
+import { Section, FormElement } from "shared/types";
+import { Context } from "shared/Context";
+import TabRegion from "components/TabRegion";
+import ValuesForm from "components/values-form/ValuesForm";
 
 import SaveButton from "../SaveButton";
 
 type PropsType = {
-  formTabs: any;
-  onSubmit: (formValues: any) => void;
-  disabled?: boolean;
+  showStateDebugger?: boolean;
+  formData: any;
+  onSubmit?: (formValues: any) => void;
   saveValuesStatus?: string | null;
   isInModal?: boolean;
-  currentTab?: string; // For resetting state when flipping b/w tabs in ExpandedChart
-  renderSaveButton?: boolean;
-  overrideValues?: any;
+  renderTabContents?: (currentTab: string) => any;
+  tabOptions: any[];
+  // overrideValues?: any;
 };
 
-type StateType = any;
-
-const providerMap: any = {
-  gke: "gcp",
-  eks: "aws",
-  doks: "do",
+type StateType = {
+  currentTab: string;
+  tabOptions: { value: string, label: string }[];
+  metaState: any;
 };
 
-// Manages the consolidated state of all form tabs ("metastate")
-export default class ValuesWrapper extends Component<PropsType, StateType> {
-  // No need to render, so OK to set as class variable outside of state
-  requiredFields: string[] = [];
+export default class FormWrapper extends Component<PropsType, StateType> {
+  state = {
+    currentTab: "",
+    tabOptions: null as { value: string, label: string }[],
+    metaState: {},
+  }
 
-  updateFormState() {
+  updateTabs = () => {
+    let tabOptions = [] as { value: string, label: string }[];
+    let tabs = this.props.formData?.tabs;
+    let requiredFields = [] as string[];
     let metaState: any = {};
-    this.props.formTabs.forEach((tab: any, i: number) => {
-      // TODO: reconcile tab.name and tab.value
-      if (tab.name || (tab.value && tab.value.includes("@"))) {
-        tab.sections.forEach((section: Section, i: number) => {
-          section.contents.forEach((item: FormElement, i: number) => {
-            // If no name is assigned use values.yaml variable as identifier
-            let key = item.name || item.variable;
-
-            let def =
-              item.settings && item.settings.unit
-                ? `${item.settings.default}${item.settings.unit}`
-                : item.settings.default;
-            def = (item.value && item.value[0]) || def;
-
-            if (item.type === "checkbox") {
-              def = item.value[0];
-            }
-
-            // Handle add to list of required fields
-            if (item.required) {
-              key && this.requiredFields.push(key);
-            }
-
-            switch (item.type) {
-              case "checkbox":
-                metaState[key] = def ? def : false;
-                break;
-              case "string-input":
-                metaState[key] = def ? def : "";
-                break;
-              case "string-input-password":
-                metaState[key] = def ? def : item.settings.default;
-              case "array-input":
-                metaState[key] = def ? def : [];
-                break;
-              case "env-key-value-array":
-                metaState[key] = def ? def : {};
-                break;
-              case "key-value-array":
-                metaState[key] = def ? def : {};
-                break;
-              case "number-input":
-                metaState[key] = def.toString() ? def : "";
-                break;
-              case "select":
-                metaState[key] = def ? def : item.settings.options[0].value;
-                break;
-              case "provider-select":
-                def = providerMap[this.context.currentCluster.service];
-                metaState[key] = def ? def : "aws";
-                break;
-              case "base-64":
-                metaState[key] = def ? def : "";
-              case "base-64-password":
-                metaState[key] = def ? def : "";
-              default:
-            }
+    if (tabs) {
+      tabs.forEach((tab: any, i: number) => {
+        if (tab.name && tab.label) {
+
+          // If a tab is valid, first extract state
+          tab.sections.forEach((section: Section, i: number) => {
+            section.contents.forEach((item: FormElement, i: number) => {
+
+              // If no name is assigned use values.yaml variable as identifier
+              let key = item.name || item.variable;
+
+              let def =
+                item.settings && item.settings.unit
+                  ? `${item.settings.default}${item.settings.unit}`
+                  : item.settings?.default;
+              def = (item.value && item.value[0]) || def;
+
+              if (item.type === "checkbox") {
+                def = item.value && item.value[0];
+              }
+
+              // Handle add to list of required fields
+              if (item.required && key) {
+                requiredFields.push(key);
+              }
+
+              let value: any = def;
+              switch (item.type) {
+                case "checkbox":
+                  value = def || false;
+                  break;
+                case "string-input":
+                  value = def || "";
+                  break;
+                case "string-input-password":
+                  value = def || item.settings.default;
+                case "array-input":
+                  value = def || [];
+                  break;
+                case "env-key-value-array":
+                  value = def || {};
+                  break;
+                case "key-value-array":
+                  value = def || {};
+                  break;
+                case "number-input":
+                  value = def.toString() ? def : "";
+                  break;
+                case "select":
+                  value = def || item.settings.options[0].value;
+                  break;
+                case "provider-select":
+                  let providerMap: any = {
+                    gke: "gcp",
+                    eks: "aws",
+                    doks: "do",
+                  };
+                  def = providerMap[this.context.currentCluster.service];
+                  value = def || "aws";
+                  break;
+                case "base-64":
+                  value = def || "";
+                case "base-64-password":
+                  value = def || "";
+                default:
+              }
+              if (value !== null && value !== undefined) {
+                metaState[key] = { value };
+              }
+            });
           });
-        });
-      }
-    });
-    this.setState(metaState);
+          tabOptions.push({ value: tab.name, label: tab.label });
+        }
+      });
+    }
+    if (this.props.tabOptions.length > 0) {
+      tabOptions = tabOptions.concat(this.props.tabOptions);
+    }
+    if (tabOptions.length > 0) {
+      this.setState({ 
+        tabOptions: tabOptions,
+        currentTab: tabOptions[0].value,
+        metaState,
+      });
+    }
   }
 
-  // Initialize corresponding state fields for form blocks
   componentDidMount() {
-    this.updateFormState();
+    this.updateTabs();
   }
 
-  componentDidUpdate(prevProps: PropsType) {
+  componentDidUpdate(prevProps: any) {
     if (
-      this.props.formTabs !== prevProps.formTabs ||
-      this.props.currentTab !== prevProps.currentTab
+      prevProps.tabOptions !== this.props.tabOptions || 
+      prevProps.formData !== this.props.formData
     ) {
-      this.updateFormState();
-    }
-    if (this.props.overrideValues !== prevProps.overrideValues) {
-      this.setState({ ...this.props.overrideValues });
+      this.updateTabs();
     }
   }
 
-  // Checks if all required fields are set
-  isDisabled = (): boolean => {
-    let valueIndicators: any[] = [];
-    this.requiredFields.forEach((field: string, i: number) => {
-      valueIndicators.push(this.state[field] && true);
-    });
-    return valueIndicators.includes(false) || valueIndicators.includes("");
-  };
-
-  renderButton = () => {
-    if (this.props.renderSaveButton) {
-      let { formTabs, currentTab } = this.props;
-      let tab = formTabs.find(
-        (t: any) => t.name === currentTab || t.value === currentTab
-      );
-      if (tab && tab.context && tab.context.type === "helm/values") {
+  isDisabled = () => {
+    return false;
+  }
+
+  renderTabContents = () => {
+    let tabs = this.props.formData?.tabs;
+    if (tabs) {
+      let matchedTab = null as any;
+      tabs.forEach((tab: any, i: number) => {
+        if (tab.name === this.state.currentTab) {
+          matchedTab = tab;
+        }
+      });
+      if (matchedTab) {
         return (
-          <SaveButton
-            disabled={this.isDisabled() || this.props.disabled}
-            text="Deploy"
-            onClick={() => this.props.onSubmit(this.state)}
-            status={
-              this.isDisabled()
-                ? "Missing required fields"
-                : this.props.saveValuesStatus
-            }
-            makeFlush={true}
+          <ValuesForm
+            metaState={this.state.metaState}
+            setMetaState={(key: string, value: any) => {
+              let metaState: any = this.state.metaState;
+              metaState[key] = { value };
+              this.setState({ metaState });
+            }}
+            sections={matchedTab.sections}
           />
         );
       }
     }
-  };
 
-  render() {
-    let renderFunc: any = this.props.children;
-    if (this.props.isInModal) {
+    // If no form tabs match, check against external tabs
+    if (this.props.renderTabContents) {
+      return this.props.renderTabContents(this.state.currentTab);
+    } else {
+      return <h1>nothin</h1>
+    }
+  }
+
+  renderStateDebugger = () => {
+    if (this.props.showStateDebugger) {
       return (
-        <StyledValuesWrapper>
-          {renderFunc(this.state, (x: any) => this.setState(x))}
-          {this.renderButton()}
-        </StyledValuesWrapper>
-      );
+        <>
+          <StateDisplay>
+            <Header>FormWrapper State</Header>
+            <ScrollWrapper>
+              {JSON.stringify(this.state.metaState, undefined, 2)}
+            </ScrollWrapper>
+          </StateDisplay>
+        </>
+      )
     }
+  }
+
+  renderContents = () => {
     return (
-      <PaddedWrapper>
-        <StyledValuesWrapper>
-          {renderFunc(this.state, (x: any) => this.setState(x))}
-          {this.renderButton()}
-        </StyledValuesWrapper>
-      </PaddedWrapper>
+      <>
+        <TabWrapper>
+          <TabRegion
+            options={this.state.tabOptions}
+            currentTab={this.state.currentTab}
+            setCurrentTab={(x: string) => this.setState({ currentTab: x })}
+          >
+            {this.renderTabContents()}
+          </TabRegion>
+        </TabWrapper>
+        <SaveButton
+          disabled={this.isDisabled()}
+          text="Deploy"
+          onClick={() => this.props.onSubmit(this.state)}
+          status={
+            this.isDisabled()
+              ? "Missing required fields"
+              : this.props.saveValuesStatus
+          }
+          makeFlush={true}
+        />
+        {this.renderStateDebugger()}
+      </>
+    );
+  }
+
+  render() {
+    return (
+      <>
+        { 
+          this.props.isInModal ? (
+            <StyledValuesWrapper>
+              {this.renderContents()}
+            </StyledValuesWrapper>
+          ) : (
+            <PaddedWrapper>
+              <StyledValuesWrapper>
+                {this.renderContents()}
+              </StyledValuesWrapper>
+            </PaddedWrapper>
+          )
+        }
+      </>
     );
   }
 }
 
-ValuesWrapper.contextType = Context;
+FormWrapper.contextType = Context;
+
+const TabWrapper = styled.div`
+  min-height: 100px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const ScrollWrapper = styled.div`
+  padding: 20px;
+  overflow-y: auto;
+  max-height: 400px;
+  padding-top: 15px;
+`;
+
+const Header = styled.div`
+  width: 100%;
+  height: 40px;
+  color: #ffffff;
+  font-weight: 500;
+  padding-left: 17px;
+  background: #00000022;
+  display: flex;
+  align-items: center;
+`;
+
+const StateDisplay = styled.pre`
+  width: 100%;
+  font-size: 13px;
+  display:
+  overflow: hidden;
+  border-radius: 5px;
+  position: relative;
+  line-height: 1.5em;
+  color: #aaaabb;
+  background: #ffffff11;
+`;
 
 const StyledValuesWrapper = styled.div`
   width: 100%;

+ 84 - 46
dashboard/src/components/values-form/KeyValueArray.tsx

@@ -18,6 +18,7 @@ type PropsType = {
   clusterId?: number;
   envLoader?: boolean;
   fileUpload?: boolean;
+  secretOption?: boolean;
 };
 
 type StateType = {
@@ -80,10 +81,28 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
     }
   };
 
+  renderHiddenOption = (hidden: boolean, i: number) => {
+    if (this.props.secretOption && hidden) {
+      return (
+        <HideButton>
+          <i className="material-icons">lock</i>
+        </HideButton>
+      );
+    }
+  };
+
   renderInputList = () => {
     return (
       <>
         {this.state.values.map((entry: any, i: number) => {
+          // Preprocess non-string env values set via raw Helm values
+          let { value } = entry;
+          if (typeof value === "object") {
+            value = JSON.stringify(value);
+          } else if (typeof value === "number" || typeof value === "boolean") {
+            value = value.toString();
+          }
+
           return (
             <InputWrapper key={i}>
               <Input
@@ -97,13 +116,14 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
                   let obj = this.valuesToObject();
                   this.props.setValues(obj);
                 }}
-                disabled={this.props.disabled}
+                disabled={this.props.disabled || value.includes("PORTERSECRET")}
+                spellCheck={false}
               />
               <Spacer />
               <Input
                 placeholder="ex: value"
                 width="270px"
-                value={entry.value}
+                value={value}
                 onChange={(e: any) => {
                   this.state.values[i].value = e.target.value;
                   this.setState({ values: this.state.values });
@@ -111,9 +131,12 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
                   let obj = this.valuesToObject();
                   this.props.setValues(obj);
                 }}
-                disabled={this.props.disabled}
+                disabled={this.props.disabled || value.includes("PORTERSECRET")}
+                type={value.includes("PORTERSECRET") ? "password" : "text"}
+                spellCheck={false}
               />
               {this.renderDeleteButton(i)}
+              {this.renderHiddenOption(value.includes("PORTERSECRET"), i)}
             </InputWrapper>
           );
         })}
@@ -160,59 +183,64 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
     }
   };
 
-    // Parses src into an Object
+  // Parses src into an Object
   parseEnv = (src: any, options: any) => {
-    const debug = Boolean(options && options.debug)
-    const obj = {} as Record<string, string>
-    const NEWLINE = '\n'
-    const RE_INI_KEY_VAL = /^\s*([\w.-]+)\s*=\s*(.*)?\s*$/
-    const RE_NEWLINES = /\\n/g
-    const NEWLINES_MATCH = /\n|\r|\r\n/
+    const debug = Boolean(options && options.debug);
+    const obj = {} as Record<string, string>;
+    const NEWLINE = "\n";
+    const RE_INI_KEY_VAL = /^\s*([\w.-]+)\s*=\s*(.*)?\s*$/;
+    const RE_NEWLINES = /\\n/g;
+    const NEWLINES_MATCH = /\n|\r|\r\n/;
 
     // convert Buffers before splitting into lines and processing
-    src.toString().split(NEWLINES_MATCH).forEach(function (line: any, idx: any) {
-      // matching "KEY' and 'VAL' in 'KEY=VAL'
-      const keyValueArr = line.match(RE_INI_KEY_VAL)
-      // matched?
-      if (keyValueArr != null) {
-        const key = keyValueArr[1]
-        // default undefined or missing values to empty string
-        let val = (keyValueArr[2] || '')
-        const end = val.length - 1
-        const isDoubleQuoted = val[0] === '"' && val[end] === '"'
-        const isSingleQuoted = val[0] === "'" && val[end] === "'"
-
-        // if single or double quoted, remove quotes
-        if (isSingleQuoted || isDoubleQuoted) {
-          val = val.substring(1, end)
-
-          // if double quoted, expand newlines
-          if (isDoubleQuoted) {
-            val = val.replace(RE_NEWLINES, NEWLINE)
+    src
+      .toString()
+      .split(NEWLINES_MATCH)
+      .forEach(function (line: any, idx: any) {
+        // matching "KEY' and 'VAL' in 'KEY=VAL'
+        const keyValueArr = line.match(RE_INI_KEY_VAL);
+        // matched?
+        if (keyValueArr != null) {
+          const key = keyValueArr[1];
+          // default undefined or missing values to empty string
+          let val = keyValueArr[2] || "";
+          const end = val.length - 1;
+          const isDoubleQuoted = val[0] === '"' && val[end] === '"';
+          const isSingleQuoted = val[0] === "'" && val[end] === "'";
+
+          // if single or double quoted, remove quotes
+          if (isSingleQuoted || isDoubleQuoted) {
+            val = val.substring(1, end);
+
+            // if double quoted, expand newlines
+            if (isDoubleQuoted) {
+              val = val.replace(RE_NEWLINES, NEWLINE);
+            }
+          } else {
+            // remove surrounding whitespace
+            val = val.trim();
           }
-        } else {
-          // remove surrounding whitespace
-          val = val.trim()
-        }
 
-        obj[key] = val
-      } else if (debug) {
-        console.log(`did not match key and value when parsing line ${idx + 1}: ${line}`)
-      }
-    })
+          obj[key] = val;
+        } else if (debug) {
+          console.log(
+            `did not match key and value when parsing line ${idx + 1}: ${line}`
+          );
+        }
+      });
 
-    return obj
-  }
+    return obj;
+  };
 
   readFile = (env: string) => {
-    let envObj = this.parseEnv(env, null)
+    let envObj = this.parseEnv(env, null);
     let push = true;
 
     for (let key in envObj) {
       for (var i = 0; i < this.state.values.length; i++) {
-        let existingKey = this.state.values[i]["key"]
+        let existingKey = this.state.values[i]["key"];
         if (key === existingKey) {
-          this.state.values[i]["value"] = envObj[key]
+          this.state.values[i]["value"] = envObj[key];
           push = false;
         }
       }
@@ -220,14 +248,13 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
       if (push) {
         this.state.values.push({ key, value: envObj[key] });
       }
-
     }
 
     this.setState({ values: this.state.values }, () => {
       let obj = this.valuesToObject();
       this.props.setValues(obj);
     });
-  }
+  };
 
   render() {
     return (
@@ -259,7 +286,7 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
               )}
               {this.props.fileUpload && (
                 <UploadButton
-                  onClick={()=>{
+                  onClick={() => {
                     this.setState({ showEditorModal: true });
                   }}
                 >
@@ -367,6 +394,17 @@ const DeleteButton = styled.div`
   }
 `;
 
+const HideButton = styled(DeleteButton)`
+  margin-top: -5px;
+  > i {
+    font-size: 19px;
+    cursor: default;
+    :hover {
+      color: #ffffff44;
+    }
+  }
+`;
+
 const InputWrapper = styled.div`
   display: flex;
   align-items: center;

+ 50 - 37
dashboard/src/components/values-form/UploadArea.tsx

@@ -12,60 +12,74 @@ type PropsType = {
 };
 
 type StateType = {
-    fileName: string;
+  fileName: string;
 };
 
 export default class UploadArea extends Component<PropsType, StateType> {
-    state = {
-        fileName: null as string,
-    }
-    handleChange = (e: any) => {
+  state = {
+    fileName: null as string,
+  };
+  handleChange = (e: any) => {
     this.props.setValue(e.target.value);
   };
 
   readFile = (file: any) => {
-    const reader = new FileReader()
+    const reader = new FileReader();
     reader.onload = async (e) => {
-      let text = (e.target.result)
+      let text = e.target.result;
       this.props.setValue(text);
-    }
-    reader.readAsText(file, 'UTF-8')
+    };
+    reader.readAsText(file, "UTF-8");
     this.setState({ fileName: file.name });
-  }
+  };
 
   render() {
     let { label, placeholder } = this.props;
-    console.log(this.state.fileName)
+    console.log(this.state.fileName);
     if (this.state.fileName) {
-        placeholder = `Uploaded ${this.state.fileName}`
+      placeholder = `Uploaded ${this.state.fileName}`;
     }
 
     return (
       <StyledUploadArea>
         <Label>
-            {label}
-            <Required>{this.props.isRequired ? " *" : null}</Required>
-        </Label> 
-        <DNDArea 
-        onDragOver={(e: any) => {e.preventDefault()}}
-        onDragEnter={(e: any) => {e.preventDefault()}}
-        onDragLeave={(e: any) => {e.preventDefault()}}
-        onDrop={(e: any) => {
+          {label}
+          <Required>{this.props.isRequired ? " *" : null}</Required>
+        </Label>
+        <DNDArea
+          onDragOver={(e: any) => {
+            e.preventDefault();
+          }}
+          onDragEnter={(e: any) => {
+            e.preventDefault();
+          }}
+          onDragLeave={(e: any) => {
+            e.preventDefault();
+          }}
+          onDrop={(e: any) => {
             e.preventDefault();
             const files = e.dataTransfer.files;
-            this.readFile(files[0])
-        }}
-        onClick={() => {
+            this.readFile(files[0]);
+          }}
+          onClick={() => {
             document.getElementById("file").click();
-        }}>
-        <input id='file' hidden type="file" accept=".json" onChange={(event) => {
-            event.preventDefault();
-            this.readFile(event.target.files[0]);
-            event.currentTarget.value = null
-        }}/>
-        <Message>
-            <img src={upload} style={{ marginRight: "6px", height: "16px"}} /> {placeholder}
-        </Message>
+          }}
+        >
+          <input
+            id="file"
+            hidden
+            type="file"
+            accept=".json"
+            onChange={(event) => {
+              event.preventDefault();
+              this.readFile(event.target.files[0]);
+              event.currentTarget.value = null;
+            }}
+          />
+          <Message>
+            <img src={upload} style={{ marginRight: "6px", height: "16px" }} />{" "}
+            {placeholder}
+          </Message>
         </DNDArea>
       </StyledUploadArea>
     );
@@ -73,10 +87,10 @@ export default class UploadArea extends Component<PropsType, StateType> {
 }
 
 const Message = styled.div`
-    display: flex;
-    align-items: center;
-    vertical-align: middle;
-`
+  display: flex;
+  align-items: center;
+  vertical-align: middle;
+`;
 
 const Required = styled.div`
   margin-left: 8px;
@@ -112,7 +126,6 @@ const Label = styled.div`
   font-family: "Work Sans", sans-serif;
 `;
 
-
 const StyledUploadArea = styled.div`
   margin-top: 20px;
 `;

+ 23 - 23
dashboard/src/components/values-form/ValuesForm.tsx

@@ -18,7 +18,7 @@ import KeyValueArray from "./KeyValueArray";
 type PropsType = {
   sections?: Section[];
   metaState?: any;
-  setMetaState?: any;
+  setMetaState?: (key: string, value: any) => void;
   handleEnvChange?: (x: any) => void;
   disabled?: boolean;
   namespace?: string;
@@ -32,7 +32,7 @@ type StateType = any;
 export default class ValuesForm extends Component<PropsType, StateType> {
   getInputValue = (item: FormElement) => {
     let key = item.name || item.variable;
-    let value = this.props.metaState[key];
+    let value = this.props.metaState[key]?.value;
 
     if (item.settings && item.settings.unit && value && value.includes) {
       value = value.split(item.settings.unit)[0];
@@ -72,9 +72,9 @@ export default class ValuesForm extends Component<PropsType, StateType> {
           return (
             <CheckboxRow
               key={i}
-              checked={this.props.metaState[key]}
+              checked={this.props.metaState[key]?.value}
               toggle={() =>
-                this.props.setMetaState({ [key]: !this.props.metaState[key] })
+                this.props.setMetaState(key, !this.props.metaState[key]?.value)
               }
               label={item.label}
             />
@@ -86,20 +86,21 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               envLoader={true}
               namespace={this.props.namespace}
               clusterId={this.props.clusterId}
-              values={this.props.metaState[key]}
+              values={this.props.metaState[key]?.value}
               setValues={(x: any) => {
-                this.props.setMetaState({ [key]: x });
+                this.props.setMetaState(key, x);
 
                 // Need to pull env vars out of form.yaml for createGHA build env vars
                 if (
                   this.props.handleEnvChange &&
                   key === "container.env.normal"
                 ) {
-                  this.props.handleEnvChange(x);
+                  // this.props.handleEnvChange(x);
                 }
               }}
               label={item.label}
               disabled={this.props.disabled}
+              secretOption={true}
             />
           );
         case "key-value-array":
@@ -108,16 +109,16 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               key={i}
               namespace={this.props.namespace}
               clusterId={this.props.clusterId}
-              values={this.props.metaState[key]}
+              values={this.props.metaState[key]?.value}
               setValues={(x: any) => {
-                this.props.setMetaState({ [key]: x });
+                this.props.setMetaState(key, x);
 
                 // Need to pull env vars out of form.yaml for createGHA build env vars
                 if (
                   this.props.handleEnvChange &&
                   key === "container.env.normal"
                 ) {
-                  this.props.handleEnvChange(x);
+                  //this.props.handleEnvChange(x);
                 }
               }}
               label={item.label}
@@ -128,9 +129,9 @@ export default class ValuesForm extends Component<PropsType, StateType> {
           return (
             <InputArray
               key={i}
-              values={this.props.metaState[key]}
+              values={this.props.metaState[key]?.value}
               setValues={(x: string[]) => {
-                this.props.setMetaState({ [key]: x });
+                this.props.setMetaState(key, x);
               }}
               label={item.label}
               disabled={this.props.disabled}
@@ -147,7 +148,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
                 if (item.settings && item.settings.unit && x !== "") {
                   x = x + item.settings.unit;
                 }
-                this.props.setMetaState({ [key]: x });
+                this.props.setMetaState(key, x);
               }}
               label={item.label}
               unit={item.settings ? item.settings.unit : null}
@@ -165,7 +166,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
                 if (item.settings && item.settings.unit && x !== "") {
                   x = x + item.settings.unit;
                 }
-                this.props.setMetaState({ [key]: x });
+                this.props.setMetaState(key, x);
               }}
               label={item.label}
               unit={item.settings ? item.settings.unit : null}
@@ -191,7 +192,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
                   val = val + item.settings.unit;
                 }
 
-                this.props.setMetaState({ [key]: val });
+                this.props.setMetaState(key, val);
               }}
               label={item.label}
               unit={item.settings ? item.settings.unit : null}
@@ -202,8 +203,8 @@ export default class ValuesForm extends Component<PropsType, StateType> {
           return (
             <SelectRow
               key={i}
-              value={this.props.metaState[key]}
-              setActiveValue={(val) => this.props.setMetaState({ [key]: val })}
+              value={this.props.metaState[key]?.value}
+              setActiveValue={(val) => this.props.setMetaState(key, val)}
               options={item.settings.options}
               dropdownLabel=""
               label={item.label}
@@ -213,8 +214,8 @@ export default class ValuesForm extends Component<PropsType, StateType> {
           return (
             <SelectRow
               key={i}
-              value={this.props.metaState[key]}
-              setActiveValue={(val) => this.props.setMetaState({ [key]: val })}
+              value={this.props.metaState[key]?.value}
+              setActiveValue={(val) => this.props.setMetaState(key, val)}
               options={[
                 { value: "aws", label: "Amazon Web Services (AWS)" },
                 { value: "gcp", label: "Google Cloud Platform (GCP)" },
@@ -237,7 +238,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
                 if (item.settings && item.settings.unit && x !== "") {
                   x = x + item.settings.unit;
                 }
-                this.props.setMetaState({ [key]: btoa(x) });
+                this.props.setMetaState(key, btoa(x));
               }}
               label={item.label}
               unit={item.settings ? item.settings.unit : null}
@@ -255,7 +256,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
                 if (item.settings && item.settings.unit && x !== "") {
                   x = x + item.settings.unit;
                 }
-                this.props.setMetaState({ [key]: btoa(x) });
+                this.props.setMetaState(key, btoa(x));
               }}
               label={item.label}
               unit={item.settings ? item.settings.unit : null}
@@ -269,11 +270,10 @@ export default class ValuesForm extends Component<PropsType, StateType> {
 
   renderFormContents = () => {
     if (this.props.metaState) {
-      console.log(this.props.metaState)
       return this.props.sections.map((section: Section, i: number) => {
         // Hide collapsible section if deciding field is false
         if (section.show_if) {
-          if (this.props.metaState[section.show_if] === false) {
+          if (this.props.metaState[section.show_if]?.value === false) {
             return null;
           }
         }

+ 42 - 5
dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx

@@ -8,7 +8,7 @@ import { Context } from "shared/Context";
 import { ClusterType } from "shared/types";
 
 import InputRow from "components/values-form/InputRow";
-import KeyValueArray from "components/values-form/KeyValueArray";
+import EnvGroupArray, { KeyValueType } from "./EnvGroupArray";
 import Selector from "components/Selector";
 import Helper from "components/values-form/Helper";
 import SaveButton from "components/SaveButton";
@@ -25,7 +25,7 @@ type StateType = {
   envGroupName: string;
   selectedNamespace: string;
   namespaceOptions: any[];
-  envVariables: any;
+  envVariables: KeyValueType[];
   submitStatus: string;
 };
 
@@ -36,7 +36,7 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
     envGroupName: "",
     selectedNamespace: "default",
     namespaceOptions: [] as any[],
-    envVariables: {} as any,
+    envVariables: [] as KeyValueType[],
     submitStatus: "",
   };
 
@@ -52,13 +52,49 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
 
   onSubmit = () => {
     this.setState({ submitStatus: "loading" });
+
+    let apiEnvVariables: Record<string, string> = {};
+    let secretEnvVariables: Record<string, string> = {};
+
+    let envVariables = this.state.envVariables;
+
+    envVariables
+      .filter((envVar: KeyValueType, index: number, self: KeyValueType[]) => {
+        // remove any collisions that are marked as deleted and are duplicates
+        let numCollisions = self.reduce((n, _envVar: KeyValueType) => {
+          return n + (_envVar.key === envVar.key ? 1 : 0);
+        }, 0);
+
+        if (numCollisions == 1) {
+          return true;
+        } else {
+          return (
+            index ===
+            self.findIndex(
+              (_envVar: KeyValueType) =>
+                _envVar.key === envVar.key && !_envVar.deleted
+            )
+          );
+        }
+      })
+      .forEach((envVar: KeyValueType) => {
+        if (!envVar.deleted) {
+          if (envVar.hidden) {
+            secretEnvVariables[envVar.key] = envVar.value;
+          } else {
+            apiEnvVariables[envVar.key] = envVar.value;
+          }
+        }
+      });
+
     api
       .createConfigMap(
         "<token>",
         {
           name: this.state.envGroupName,
           namespace: this.state.selectedNamespace,
-          variables: this.state.envVariables,
+          variables: apiEnvVariables,
+          secret_variables: secretEnvVariables,
         },
         {
           id: this.context.currentProject.id,
@@ -159,11 +195,12 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
             Set environment variables for your secrets and environment-specific
             configuration.
           </Helper>
-          <KeyValueArray
+          <EnvGroupArray
             namespace={this.state.selectedNamespace}
             values={this.state.envVariables}
             setValues={(x: any) => this.setState({ envVariables: x })}
             fileUpload={true}
+            secretOption={true}
           />
           <SaveButton
             disabled={this.isDisabled()}

+ 418 - 0
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupArray.tsx

@@ -0,0 +1,418 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import Modal from "main/home/modals/Modal";
+import EnvEditorModal from "main/home/modals/EnvEditorModal";
+
+import sliders from "assets/sliders.svg";
+import upload from "assets/upload.svg";
+import { keysIn } from "lodash";
+
+export type KeyValueType = {
+  key: string;
+  value: string;
+  hidden: boolean;
+  locked: boolean;
+  deleted: boolean;
+};
+
+type PropsType = {
+  label?: string;
+  values: KeyValueType[];
+  setValues: (x: KeyValueType[]) => void;
+  width?: string;
+  disabled?: boolean;
+  namespace?: string;
+  clusterId?: number;
+  envLoader?: boolean;
+  fileUpload?: boolean;
+  secretOption?: boolean;
+};
+
+type StateType = {
+  showEnvModal: boolean;
+  showEditorModal: boolean;
+};
+
+export default class EnvGroupArray extends Component<PropsType, StateType> {
+  state = {
+    showEnvModal: false,
+    showEditorModal: false,
+  };
+
+  componentDidMount() {
+    if (!this.props.values) {
+      let _values = [] as KeyValueType[];
+      this.props.setValues(_values);
+    }
+  }
+
+  renderDeleteButton = (i: number) => {
+    if (!this.props.disabled) {
+      return (
+        <DeleteButton
+          onClick={() => {
+            let _values = this.props.values;
+            _values[i].deleted = true;
+            this.props.setValues(_values);
+          }}
+        >
+          <i className="material-icons">cancel</i>
+        </DeleteButton>
+      );
+    }
+  };
+
+  renderHiddenOption = (hidden: boolean, locked: boolean, i: number) => {
+    if (this.props.secretOption) {
+      let icon = <i className="material-icons">lock_open</i>;
+
+      if (hidden) {
+        icon = <i className="material-icons">lock</i>;
+      }
+
+      return (
+        <HideButton
+          onClick={() => {
+            if (!locked) {
+              let _values = this.props.values;
+              _values[i].hidden = !_values[i].hidden;
+              this.props.setValues(_values);
+            }
+          }}
+          disabled={locked}
+        >
+          {icon}
+        </HideButton>
+      );
+    }
+  };
+
+  renderInputList = () => {
+    return (
+      <>
+        {this.props.values.map((entry: KeyValueType, i: number) => {
+          if (!entry.deleted) {
+            return (
+              <InputWrapper key={i}>
+                <Input
+                  placeholder="ex: key"
+                  width="270px"
+                  value={entry.key}
+                  onChange={(e: any) => {
+                    let _values = this.props.values;
+                    _values[i].key = e.target.value;
+                    this.props.setValues(_values);
+                  }}
+                  disabled={this.props.disabled || entry.locked}
+                  spellCheck={false}
+                />
+                <Spacer />
+                <Input
+                  placeholder="ex: value"
+                  width="270px"
+                  value={entry.value}
+                  onChange={(e: any) => {
+                    let _values = this.props.values;
+                    _values[i].value = e.target.value;
+                    this.props.setValues(_values);
+                  }}
+                  disabled={this.props.disabled || entry.locked}
+                  type={entry.hidden ? "password" : "text"}
+                  spellCheck={false}
+                />
+                {this.renderHiddenOption(entry.hidden, entry.locked, i)}
+                {this.renderDeleteButton(i)}
+              </InputWrapper>
+            );
+          }
+        })}
+      </>
+    );
+  };
+
+  renderEditorModal = () => {
+    if (this.state.showEditorModal) {
+      return (
+        <Modal
+          onRequestClose={() => this.setState({ showEditorModal: false })}
+          width="60%"
+          height="80%"
+        >
+          <EnvEditorModal
+            closeModal={() => this.setState({ showEditorModal: false })}
+            setEnvVariables={(envFile: string) => this.readFile(envFile)}
+          />
+        </Modal>
+      );
+    }
+  };
+
+  // Parses src into an Object
+  parseEnv = (src: any, options: any) => {
+    const debug = Boolean(options && options.debug);
+    const obj = {} as Record<string, string>;
+    const NEWLINE = "\n";
+    const RE_INI_KEY_VAL = /^\s*([\w.-]+)\s*=\s*(.*)?\s*$/;
+    const RE_NEWLINES = /\\n/g;
+    const NEWLINES_MATCH = /\n|\r|\r\n/;
+
+    // convert Buffers before splitting into lines and processing
+    src
+      .toString()
+      .split(NEWLINES_MATCH)
+      .forEach(function (line: any, idx: any) {
+        // matching "KEY' and 'VAL' in 'KEY=VAL'
+        const keyValueArr = line.match(RE_INI_KEY_VAL);
+        // matched?
+        if (keyValueArr != null) {
+          const key = keyValueArr[1];
+          // default undefined or missing values to empty string
+          let val = keyValueArr[2] || "";
+          const end = val.length - 1;
+          const isDoubleQuoted = val[0] === '"' && val[end] === '"';
+          const isSingleQuoted = val[0] === "'" && val[end] === "'";
+
+          // if single or double quoted, remove quotes
+          if (isSingleQuoted || isDoubleQuoted) {
+            val = val.substring(1, end);
+
+            // if double quoted, expand newlines
+            if (isDoubleQuoted) {
+              val = val.replace(RE_NEWLINES, NEWLINE);
+            }
+          } else {
+            // remove surrounding whitespace
+            val = val.trim();
+          }
+
+          obj[key] = val;
+        } else if (debug) {
+          console.log(
+            `did not match key and value when parsing line ${idx + 1}: ${line}`
+          );
+        }
+      });
+
+    return obj;
+  };
+
+  readFile = (env: string) => {
+    let envObj = this.parseEnv(env, null);
+    let push = true;
+    let _values = this.props.values;
+
+    for (let key in envObj) {
+      for (var i = 0; i < this.props.values.length; i++) {
+        let existingKey = this.props.values[i]["key"];
+        if (key === existingKey) {
+          _values[i]["value"] = envObj[key];
+          push = false;
+        }
+      }
+
+      if (push) {
+        _values.push({
+          key,
+          value: envObj[key],
+          hidden: false,
+          locked: false,
+          deleted: false,
+        });
+      }
+    }
+
+    this.props.setValues(_values);
+  };
+
+  render() {
+    if (this.props.values) {
+      return (
+        <>
+          <StyledInputArray>
+            <Label>{this.props.label}</Label>
+            {this.props.values.length === 0 ? <></> : this.renderInputList()}
+            {this.props.disabled ? (
+              <></>
+            ) : (
+              <InputWrapper>
+                <AddRowButton
+                  onClick={() => {
+                    let _values = this.props.values;
+                    _values.push({
+                      key: "",
+                      value: "",
+                      hidden: false,
+                      locked: false,
+                      deleted: false,
+                    });
+                    this.props.setValues(_values);
+                  }}
+                >
+                  <i className="material-icons">add</i> Add Row
+                </AddRowButton>
+                <Spacer />
+                {this.props.namespace && this.props.envLoader && (
+                  <LoadButton
+                    onClick={() =>
+                      this.setState({ showEnvModal: !this.state.showEnvModal })
+                    }
+                  >
+                    <img src={sliders} /> Load from Env Group
+                  </LoadButton>
+                )}
+                {this.props.fileUpload && (
+                  <UploadButton
+                    onClick={() => {
+                      this.setState({ showEditorModal: true });
+                    }}
+                  >
+                    <img src={upload} /> Copy from File
+                  </UploadButton>
+                )}
+              </InputWrapper>
+            )}
+          </StyledInputArray>
+          {this.renderEditorModal()}
+        </>
+      );
+    }
+
+    return null;
+  }
+}
+
+const Spacer = styled.div`
+  width: 10px;
+  height: 20px;
+`;
+
+const AddRowButton = styled.div`
+  display: flex;
+  align-items: center;
+  width: 270px;
+  font-size: 13px;
+  color: #aaaabb;
+  height: 32px;
+  border-radius: 3px;
+  cursor: pointer;
+  background: #ffffff11;
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+`;
+
+const LoadButton = styled(AddRowButton)`
+  background: none;
+  border: 1px solid #ffffff55;
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  > img {
+    width: 14px;
+    margin-left: 10px;
+    margin-right: 12px;
+  }
+`;
+
+const UploadButton = styled(AddRowButton)`
+  background: none;
+  position: relative;
+  border: 1px solid #ffffff55;
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  > img {
+    width: 14px;
+    margin-left: 10px;
+    margin-right: 12px;
+  }
+`;
+
+const DeleteButton = styled.div`
+  width: 15px;
+  height: 15px;
+  display: flex;
+  align-items: center;
+  margin-left: 8px;
+  margin-top: -3px;
+  justify-content: center;
+
+  > i {
+    font-size: 17px;
+    color: #ffffff44;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    :hover {
+      color: #ffffff88;
+    }
+  }
+`;
+
+const HideButton = styled(DeleteButton)`
+  margin-top: -5px;
+  > i {
+    font-size: 19px;
+    cursor: ${(props: { disabled: boolean }) =>
+      props.disabled ? "default" : "pointer"};
+    :hover {
+      color: ${(props: { disabled: boolean }) =>
+        props.disabled ? "#ffffff44" : "#ffffff88"};
+    }
+  }
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 5px;
+`;
+
+const Input = styled.input`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { disabled?: boolean; width: string }) =>
+    props.width ? props.width : "270px"};
+  color: ${(props: { disabled?: boolean; width: string }) =>
+    props.disabled ? "#ffffff44" : "white"};
+  padding: 5px 10px;
+  height: 35px;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+`;
+
+const StyledInputArray = styled.div`
+  margin-bottom: 15px;
+  margin-top: 22px;
+`;

+ 83 - 9
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -12,7 +12,7 @@ import SaveButton from "components/SaveButton";
 import ConfirmOverlay from "components/ConfirmOverlay";
 import Loading from "components/Loading";
 import TabRegion from "components/TabRegion";
-import KeyValueArray from "components/values-form/KeyValueArray";
+import EnvGroupArray, { KeyValueType } from "./EnvGroupArray";
 import Heading from "components/values-form/Heading";
 import Helper from "components/values-form/Helper";
 
@@ -29,7 +29,7 @@ type StateType = {
   showDeleteOverlay: boolean;
   deleting: boolean;
   saveValuesStatus: string | null;
-  values: any;
+  envVariables: KeyValueType[];
 };
 
 const tabOptions = [
@@ -44,14 +44,88 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
     showDeleteOverlay: false,
     deleting: false,
     saveValuesStatus: null as string | null,
-    values: this.props.envGroup.data as any,
+    envVariables: [] as KeyValueType[],
   };
 
+  componentDidMount() {
+    // parse env group props into values type
+    let envVariables = [] as KeyValueType[];
+    let envGroupData = this.props.envGroup.data;
+
+    for (const key in envGroupData) {
+      envVariables.push({
+        key: key,
+        value: envGroupData[key],
+        hidden: envGroupData[key].includes("PORTERSECRET"),
+        locked: envGroupData[key].includes("PORTERSECRET"),
+        deleted: false,
+      });
+    }
+
+    this.setState({ envVariables });
+  }
+
   handleUpdateValues = () => {
     let { envGroup } = this.props;
     let name = envGroup.metadata.name;
     let namespace = envGroup.metadata.namespace;
 
+    let apiEnvVariables: Record<string, string> = {};
+    let secretEnvVariables: Record<string, string> = {};
+
+    let envVariables = this.state.envVariables;
+
+    envVariables
+      .filter((envVar: KeyValueType, index: number, self: KeyValueType[]) => {
+        // remove any collisions that are marked as deleted and are duplicates, unless they are
+        // all delete collisions
+        let numDeleteCollisions = self.reduce((n, _envVar: KeyValueType) => {
+          return n + (_envVar.key === envVar.key && envVar.deleted ? 1 : 0);
+        }, 0);
+
+        let numCollisions = self.reduce((n, _envVar: KeyValueType) => {
+          return n + (_envVar.key === envVar.key ? 1 : 0);
+        }, 0);
+
+        if (numCollisions == numDeleteCollisions) {
+          // if all collisions are delete collisions, just remove duplicates
+          return (
+            index ===
+            self.findIndex(
+              (_envVar: KeyValueType) => _envVar.key === envVar.key
+            )
+          );
+        } else if (numCollisions == 1) {
+          // if there's just one collision (self), keep the object
+          return true;
+        } else {
+          // if there are more collisions than delete collisions, remove all duplicates that
+          // are deletions
+          return (
+            index ===
+            self.findIndex(
+              (_envVar: KeyValueType) =>
+                _envVar.key === envVar.key && !_envVar.deleted
+            )
+          );
+        }
+      })
+      .forEach((envVar: KeyValueType) => {
+        if (envVar.hidden) {
+          if (envVar.deleted) {
+            secretEnvVariables[envVar.key] = null;
+          } else if (!envVar.value.includes("PORTERSECRET")) {
+            secretEnvVariables[envVar.key] = envVar.value;
+          }
+        } else {
+          if (envVar.deleted) {
+            apiEnvVariables[envVar.key] = null;
+          } else {
+            apiEnvVariables[envVar.key] = envVar.value;
+          }
+        }
+      });
+
     this.setState({ saveValuesStatus: "loading" });
     api
       .updateConfigMap(
@@ -59,7 +133,8 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
         {
           name,
           namespace,
-          variables: this.state.values,
+          variables: apiEnvVariables,
+          secret_variables: secretEnvVariables,
         },
         {
           id: this.context.currentProject.id,
@@ -89,11 +164,12 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
                 Set environment variables for your secrets and
                 environment-specific configuration.
               </Helper>
-              <KeyValueArray
+              <EnvGroupArray
                 namespace={namespace}
-                values={this.state.values || {}}
-                setValues={(x: any) => this.setState({ values: x }, () => {console.log(this.state.values)})}
+                values={this.state.envVariables}
+                setValues={(x: any) => this.setState({ envVariables: x })}
                 fileUpload={true}
+                secretOption={true}
               />
             </InnerWrapper>
             <SaveButton
@@ -154,11 +230,9 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
       .then((res) => {
         this.props.closeExpanded();
         this.setState({ deleting: false });
-        // console.log("CONFIGMAP", res);
       })
       .catch((err) => {
         this.setState({ deleting: false, showDeleteOverlay: false });
-        // console.log("CONFIGMAP", err);
       });
   };
 

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

@@ -98,7 +98,10 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         }
       )
       .then((res) => {
-        this.updateComponents({ currentChart: res.data, loading: false }, res.data);
+        this.updateComponents(
+          { currentChart: res.data, loading: false },
+          res.data
+        );
         // // if the current tab is manifests or chart overview, update components as well
         // if (this.state.currentTab == "graph" || this.state.currentTab == "list") {
         //   this.updateComponents({ currentChart: res.data, loading: false }, currentChart);
@@ -203,7 +206,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     this.setState({ websockets });
   };
 
-  updateComponents = (state : any, currentChart : ChartType) => {
+  updateComponents = (state: any, currentChart: ChartType) => {
     let { currentCluster, currentProject } = this.context;
 
     api
@@ -221,10 +224,10 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         }
       )
       .then((res) => {
-        let newState = state || {}
+        let newState = state || {};
 
-        newState.components = res.data.Objects
-        newState.podSelectors = res.data.PodSelectors
+        newState.components = res.data.Objects;
+        newState.podSelectors = res.data.PodSelectors;
 
         this.setState(newState);
         this.updateTabs();
@@ -255,7 +258,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
     this.setState({ saveValuesStatus: "loading" });
     this.refreshChart();
-    
+
     api
       .upgradeChartValues(
         "<token>",
@@ -282,7 +285,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         });
       })
       .catch((err) => {
-        console.log(err)
+        console.log(err);
         this.setState({ saveValuesStatus: "error" });
         window.analytics.track("Failed to Upgrade Chart", {
           chart: this.state.currentChart.name,

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -141,7 +141,7 @@ export default class SettingsSection extends Component<PropsType, StateType> {
     }
 
     if (true || this.state.webhookToken) {
-      let webhookText = `curl -X POST 'https://dashboard.getporter.dev/api/webhooks/deploy/${this.state.webhookToken}?commit=YOUR_COMMIT_HASH&repository=IMAGE_REPOSITORY_URL'`;
+      let webhookText = `curl -X POST 'https://dashboard.getporter.dev/api/webhooks/deploy/${this.state.webhookToken}?commit=YOUR_COMMIT_HASH'`;
       return (
         <>
           <Heading>Redeploy Webhook</Heading>

+ 3 - 4
dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/GraphDisplay.tsx

@@ -101,7 +101,7 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
     let graph = localStorage.getItem(
       `charts.${currentChart.name}-${currentChart.version}`
     );
-    
+
     let nodes = [] as NodeType[];
     let edges = [] as EdgeType[];
 
@@ -146,7 +146,7 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
 
   // Live update on rollback/upgrade
   componentDidUpdate(prevProps: PropsType) {
-    if (!(_.isEqual(prevProps.currentChart, this.props.currentChart))) {
+    if (!_.isEqual(prevProps.currentChart, this.props.currentChart)) {
       this.storeChartGraph(prevProps);
       this.getChartGraph();
     }
@@ -246,7 +246,6 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
   };
 
   storeChartGraph = (props?: PropsType) => {
-
     let useProps = props || this.props;
 
     let { currentChart } = useProps;
@@ -565,7 +564,7 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
           isOpen={node === this.state.openedNode}
           // Parameterized to allow setting to null
           setCurrentNode={(node: NodeType) => {
-            this.setState({ currentNode: node })
+            this.setState({ currentNode: node });
           }}
         />
       );

+ 26 - 19
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -1,7 +1,7 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 import { Context } from "shared/Context";
-import * as Anser from 'anser';
+import * as Anser from "anser";
 
 type PropsType = {
   selectedPod: any;
@@ -61,17 +61,21 @@ export default class Logs extends Component<PropsType, StateType> {
     }
 
     return this.state.logs.map((log, i) => {
-      return <Log key={i}>
-        {this.state.logs[i].map((ansi, j) => {
-          if (ansi.clearLine) {
-            return null
-          }
-
-          return <LogSpan key={i + "." + j} ansi={ansi}>
-            {ansi.content.replace(/ /g, '\u00a0')}
-          </LogSpan>
-        })}
-      </Log>;
+      return (
+        <Log key={i}>
+          {this.state.logs[i].map((ansi, j) => {
+            if (ansi.clearLine) {
+              return null;
+            }
+
+            return (
+              <LogSpan key={i + "." + j} ansi={ansi}>
+                {ansi.content.replace(/ /g, "\u00a0")}
+              </LogSpan>
+            );
+          })}
+        </Log>
+      );
     });
   };
 
@@ -87,10 +91,10 @@ export default class Logs extends Component<PropsType, StateType> {
     this.ws.onopen = () => {};
 
     this.ws.onmessage = (evt: MessageEvent) => {
-      let ansiLog = Anser.ansiToJson(evt.data)
+      let ansiLog = Anser.ansiToJson(evt.data);
 
-      let logs = this.state.logs
-      logs.push(ansiLog)
+      let logs = this.state.logs;
+      logs.push(ansiLog);
 
       this.setState({ logs: logs }, () => {
         if (this.state.scroll) {
@@ -262,7 +266,10 @@ const Log = styled.div`
 const LogSpan = styled.span`
   font-family: monospace, sans-serif;
   font-size: 12px;
-  font-weight: ${(props: { ansi: Anser.AnserJsonEntry }) => props.ansi?.decoration && props.ansi?.decoration == "bold" ? "700" : "400"};
-  color: ${(props: { ansi: Anser.AnserJsonEntry }) => props.ansi?.fg ? `rgb(${props.ansi?.fg})` : "white"};
-  background-color: ${(props: { ansi: Anser.AnserJsonEntry }) => props.ansi?.bg ? `rgb(${props.ansi?.bg})` : "transparent"};
-`
+  font-weight: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+    props.ansi?.decoration && props.ansi?.decoration == "bold" ? "700" : "400"};
+  color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+    props.ansi?.fg ? `rgb(${props.ansi?.fg})` : "white"};
+  background-color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+    props.ansi?.bg ? `rgb(${props.ansi?.bg})` : "transparent"};
+`;

+ 13 - 15
dashboard/src/main/home/integrations/create-integration/GCRForm.tsx

@@ -32,14 +32,8 @@ export default class GCRForm extends Component<PropsType, StateType> {
   };
 
   isDisabled = (): boolean => {
-    let {
-      serviceAccountKey,
-      credentialsName
-    } = this.state;
-    if (
-      serviceAccountKey === "" ||
-      credentialsName === ""
-    ) {
+    let { serviceAccountKey, credentialsName } = this.state;
+    if (serviceAccountKey === "" || credentialsName === "") {
       return true;
     }
     return false;
@@ -110,7 +104,11 @@ export default class GCRForm extends Component<PropsType, StateType> {
             height="100%"
             isRequired={true}
           />
-          <Helper>GCR URI, in the form <CodeBlock>[gcr_domain]/[gcp_project_id]</CodeBlock>. For example, <CodeBlock>gcr.io/skynet-dev-172969</CodeBlock>.</Helper>
+          <Helper>
+            GCR URI, in the form{" "}
+            <CodeBlock>[gcr_domain]/[gcp_project_id]</CodeBlock>. For example,{" "}
+            <CodeBlock>gcr.io/skynet-dev-172969</CodeBlock>.
+          </Helper>
           <InputRow
             type="text"
             value={this.state.url}
@@ -146,12 +144,12 @@ const StyledForm = styled.div`
 `;
 
 const CodeBlock = styled.span`
-  display: inline-block; 
+  display: inline-block;
   background-color: #1b1d26;
-  color: white; 
-  border-radius: 5px; 
+  color: white;
+  border-radius: 5px;
   font-family: monospace;
-  padding: 2px 3px; 
-  margin-top: -2px; 
+  padding: 2px 3px;
+  margin-top: -2px;
   user-select: text;
-`
+`;

+ 1 - 2
dashboard/src/main/home/integrations/create-integration/GKEForm.tsx

@@ -121,7 +121,7 @@ export default class GKEForm extends Component<PropsType, StateType> {
           onClick={this.isDisabled() ? null : this.handleSubmit}
         />
 
-              {/* <UploadButton
+        {/* <UploadButton
       onClick={()=>{
         // document.getElementById("file").click();
         this.setState({ showEditorModal: true });
@@ -133,7 +133,6 @@ export default class GKEForm extends Component<PropsType, StateType> {
         event.currentTarget.value = null
       }}/>}
     </UploadButton> */}
-      
       </StyledForm>
     );
   }

+ 6 - 1
dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx

@@ -249,7 +249,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
 
     // check if template is docker and create external domain if necessary
     if (this.props.currentTemplate.name == "web") {
-      if (!values?.ingress?.custom_domain) {
+      if (values?.ingress?.enabled && !values?.ingress?.custom_domain) {
         url = await new Promise((resolve, reject) => {
           api
             .createSubdomain(
@@ -438,6 +438,11 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             return;
           }
 
+          // handle when procfileProcess is already specified
+          if (this.state.procfileProcess) {
+            metaState["container.command"] = this.state.procfileProcess;
+          }
+
           return this.props.form?.tabs.map((tab: any, i: number) => {
             // If tab is current, render
             if (tab.name === this.state.currentTab) {

+ 15 - 12
dashboard/src/main/home/modals/EnvEditorModal.tsx

@@ -3,7 +3,7 @@ import styled from "styled-components";
 import close from "assets/close.png";
 import AceEditor from "react-ace";
 
-import "shared/ace-porter-theme"
+import "shared/ace-porter-theme";
 import "ace-builds/src-noconflict/mode-text";
 
 import { Context } from "shared/Context";
@@ -22,7 +22,7 @@ type StateType = {
 };
 
 export default class EnvEditorModal extends Component<PropsType, StateType> {
-    state = {
+  state = {
     error: false,
     buttonStatus: "",
     envFile: "",
@@ -31,13 +31,13 @@ export default class EnvEditorModal extends Component<PropsType, StateType> {
   aceEditorRef = React.createRef<AceEditor>();
 
   onSubmit = () => {
-    this.props.setEnvVariables(this.state.envFile)
+    this.props.setEnvVariables(this.state.envFile);
     this.props.closeModal();
   };
 
-  onChange = (e: string) => { 
-    this.setState({ envFile: e })
-  }
+  onChange = (e: string) => {
+    this.setState({ envFile: e });
+  };
 
   componentDidMount() {}
 
@@ -49,11 +49,14 @@ export default class EnvEditorModal extends Component<PropsType, StateType> {
         </CloseButton>
 
         <ModalTitle>Load from Environment Group</ModalTitle>
-        <Subtitle>
-          Copy paste your environment file in .env format:
-        </Subtitle>
-
-        <Editor onSubmit={(e: any) => {e.preventDefault()}} border={true}>
+        <Subtitle>Copy paste your environment file in .env format:</Subtitle>
+
+        <Editor
+          onSubmit={(e: any) => {
+            e.preventDefault();
+          }}
+          border={true}
+        >
           <AceEditor
             ref={this.aceEditorRef}
             mode="text"
@@ -163,4 +166,4 @@ const StyledLoadEnvGroupModal = styled.div`
   overflow: hidden;
   border-radius: 6px;
   background: #202227;
-`;
+`;

+ 1 - 1
dashboard/src/main/home/provisioner/GCPFormSection.tsx

@@ -319,7 +319,7 @@ class GCPFormSection extends Component<PropsType, StateType> {
   render() {
     let { setSelectedProvisioner } = this.props;
     let { gcpRegion, gcpProjectId, gcpKeyData, selectedInfras } = this.state;
-    console.log('gcpkeydata', gcpKeyData)
+    console.log("gcpkeydata", gcpKeyData);
     return (
       <StyledGCPFormSection>
         <FormSection>

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

@@ -59,18 +59,23 @@ class ClusterSection extends Component<PropsType, StateType> {
             let saved = JSON.parse(
               localStorage.getItem(currentProject.id + "-cluster")
             );
-            if (saved !== "null") {
-              setCurrentCluster(clusters[0]);
+            if (saved && saved !== "null") {
+              // Ensures currentCluster isn't prematurely set (causes issues downstream)
+              let loaded = false;
               for (let i = 0; i < clusters.length; i++) {
                 if (
                   clusters[i].id === saved.id &&
                   clusters[i].project_id === saved.project_id &&
                   clusters[i].name === saved.name
                 ) {
+                  loaded = true;
                   setCurrentCluster(clusters[i]);
                   break;
                 }
               }
+              if (!loaded) {
+                setCurrentCluster(clusters[0]);
+              }
             } else {
               setCurrentCluster(clusters[0]);
             }
@@ -173,10 +178,10 @@ class ClusterSection extends Component<PropsType, StateType> {
 
   render() {
     return (
-      <div>
+      <>
         {this.renderDrawer()}
         {this.renderContents()}
-      </div>
+      </>
     );
   }
 }

+ 8 - 4
dashboard/src/shared/ace-porter-theme.js

@@ -1,6 +1,9 @@
-import ace from 'brace';
+import ace from "brace";
 
-ace['define']('ace/theme/porter', ['require', 'exports', 'module', 'ace/lib/dom'], (acequire, exports) => {
+ace["define"](
+  "ace/theme/porter",
+  ["require", "exports", "module", "ace/lib/dom"],
+  (acequire, exports) => {
     exports.isDark = true;
     exports.cssClass = "ace-porter";
     exports.cssText = `.ace-porter, div.ace_content, div.ace_line, div.ace_gutter-cell {\
@@ -105,7 +108,8 @@ ace['define']('ace/theme/porter', ['require', 'exports', 'module', 'ace/lib/dom'
     .ace-porter .ace_indent-guide {
     background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQImWNgYGBgYLBWV/8PAAK4AYnhiq+xAAAAAElFTkSuQmCC) right repeat-y;
     }`;
-    
+
     var dom = acequire("../lib/dom");
     dom.importCssString(exports.cssText, exports.cssClass);
-});
+  }
+);

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

@@ -753,6 +753,7 @@ const createConfigMap = baseApi<
     name: string;
     namespace: string;
     variables: Record<string, string>;
+    secret_variables?: Record<string, string>;
   },
   { id: number; cluster_id: number }
 >("POST", (pathParams) => {
@@ -765,6 +766,7 @@ const updateConfigMap = baseApi<
     name: string;
     namespace: string;
     variables: Record<string, string>;
+    secret_variables?: Record<string, string>;
   },
   { id: number; cluster_id: number }
 >("POST", (pathParams) => {

+ 5 - 4
internal/forms/k8s.go

@@ -39,7 +39,8 @@ func (kf *K8sForm) PopulateK8sOptionsFromQueryParams(
 }
 
 type ConfigMapForm struct {
-	Name string `json:"name" form:"required"`
-	Namespace string `json:"namespace" form:"required"`
-	EnvVariables map[string]string `json:"variables"`
-}
+	Name               string            `json:"name" form:"required"`
+	Namespace          string            `json:"namespace" form:"required"`
+	EnvVariables       map[string]string `json:"variables"`
+	SecretEnvVariables map[string]string `json:"secret_variables"`
+}

+ 99 - 10
internal/kubernetes/agent.go

@@ -4,6 +4,7 @@ import (
 	"bufio"
 	"bytes"
 	"context"
+	"encoding/json"
 	"fmt"
 	"io"
 	"strings"
@@ -33,6 +34,7 @@ import (
 	v1beta1 "k8s.io/api/extensions/v1beta1"
 	"k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
 	"k8s.io/client-go/informers"
 	"k8s.io/client-go/kubernetes"
@@ -64,7 +66,7 @@ func (a *Agent) CreateConfigMap(name string, namespace string, configMap map[str
 		context.TODO(),
 		&v1.ConfigMap{
 			ObjectMeta: metav1.ObjectMeta{
-				Name: name,
+				Name:      name,
 				Namespace: namespace,
 				Labels: map[string]string{
 					"porter": "true",
@@ -76,26 +78,104 @@ func (a *Agent) CreateConfigMap(name string, namespace string, configMap map[str
 	)
 }
 
-// UpdateConfigMap updates the configmap given its name and namespace
-func (a *Agent) UpdateConfigMap(name string, namespace string, configMap map[string]string) (*v1.ConfigMap, error) {
-	return a.Clientset.CoreV1().ConfigMaps(namespace).Update(
+// CreateLinkedSecret creates a secret given the key-value pairs and namespace. Values are
+// base64 encoded
+func (a *Agent) CreateLinkedSecret(name, namespace, cmName string, data map[string][]byte) (*v1.Secret, error) {
+	return a.Clientset.CoreV1().Secrets(namespace).Create(
 		context.TODO(),
-		&v1.ConfigMap{
+		&v1.Secret{
 			ObjectMeta: metav1.ObjectMeta{
-				Name: name,
+				Name:      name,
 				Namespace: namespace,
 				Labels: map[string]string{
-					"porter": "true",
+					"porter":    "true",
+					"configmap": cmName,
 				},
 			},
-			Data: configMap,
+			Data: data,
 		},
-		metav1.UpdateOptions{},
+		metav1.CreateOptions{},
 	)
 }
 
+type mergeConfigMapData struct {
+	Data map[string]*string `json:"data"`
+}
+
+// UpdateConfigMap updates the configmap given its name and namespace
+func (a *Agent) UpdateConfigMap(name string, namespace string, configMap map[string]string) error {
+	cmData := make(map[string]*string)
+
+	for key, val := range configMap {
+		valCopy := val
+		cmData[key] = &valCopy
+
+		if len(val) == 0 {
+			cmData[key] = nil
+		}
+	}
+
+	mergeCM := &mergeConfigMapData{
+		Data: cmData,
+	}
+
+	patchBytes, err := json.Marshal(mergeCM)
+
+	if err != nil {
+		return err
+	}
+
+	_, err = a.Clientset.CoreV1().ConfigMaps(namespace).Patch(
+		context.Background(),
+		name,
+		types.MergePatchType,
+		patchBytes,
+		metav1.PatchOptions{},
+	)
+
+	return err
+}
+
+type mergeLinkedSecretData struct {
+	Data map[string]*[]byte `json:"data"`
+}
+
+// UpdateLinkedSecret updates the secret given its name and namespace
+func (a *Agent) UpdateLinkedSecret(name, namespace, cmName string, data map[string][]byte) error {
+	secretData := make(map[string]*[]byte)
+
+	for key, val := range data {
+		valCopy := val
+		secretData[key] = &valCopy
+
+		if len(val) == 0 {
+			secretData[key] = nil
+		}
+	}
+
+	mergeSecret := &mergeLinkedSecretData{
+		Data: secretData,
+	}
+
+	patchBytes, err := json.Marshal(mergeSecret)
+
+	if err != nil {
+		return err
+	}
+
+	_, err = a.Clientset.CoreV1().Secrets(namespace).Patch(
+		context.TODO(),
+		name,
+		types.MergePatchType,
+		patchBytes,
+		metav1.PatchOptions{},
+	)
+
+	return err
+}
+
 // DeleteConfigMap deletes the configmap given its name and namespace
-func (a *Agent) DeleteConfigMap(name string, namespace string) (error) {
+func (a *Agent) DeleteConfigMap(name string, namespace string) error {
 	return a.Clientset.CoreV1().ConfigMaps(namespace).Delete(
 		context.TODO(),
 		name,
@@ -103,6 +183,15 @@ func (a *Agent) DeleteConfigMap(name string, namespace string) (error) {
 	)
 }
 
+// DeleteLinkedSecret deletes the secret given its name and namespace
+func (a *Agent) DeleteLinkedSecret(name, namespace string) error {
+	return a.Clientset.CoreV1().Secrets(namespace).Delete(
+		context.TODO(),
+		name,
+		metav1.DeleteOptions{},
+	)
+}
+
 // GetConfigMap retrieves the configmap given its name and namespace
 func (a *Agent) GetConfigMap(name string, namespace string) (*v1.ConfigMap, error) {
 	return a.Clientset.CoreV1().ConfigMaps(namespace).Get(

+ 67 - 4
server/api/k8s_handler.go

@@ -77,7 +77,6 @@ func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
 
 // HandleCreateConfigMap deletes the pod given the name and namespace.
 func (app *App) HandleCreateConfigMap(w http.ResponseWriter, r *http.Request) {
-	fmt.Println("CREATING CONFGIMAP")
 	vals, err := url.ParseQuery(r.URL.RawQuery)
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
@@ -116,6 +115,32 @@ func (app *App) HandleCreateConfigMap(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	secretData := make(map[string][]byte)
+
+	for key, rawValue := range configMap.SecretEnvVariables {
+		// encodedValue := base64.StdEncoding.EncodeToString([]byte(rawValue))
+
+		// if err != nil {
+		// 	app.handleErrorInternal(err, w)
+		// 	return
+		// }
+
+		secretData[key] = []byte(rawValue)
+	}
+
+	// create secret first
+	_, err = agent.CreateLinkedSecret(configMap.Name, configMap.Namespace, configMap.Name, secretData)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	// add all secret env variables to configmap with value PORTERSECRET_${configmap_name}
+	for key, _ := range configMap.SecretEnvVariables {
+		configMap.EnvVariables[key] = fmt.Sprintf("PORTERSECRET_%s", configMap.Name)
+	}
+
 	_, err = agent.CreateConfigMap(configMap.Name, configMap.Namespace, configMap.EnvVariables)
 
 	if err != nil {
@@ -135,7 +160,7 @@ func (app *App) HandleCreateConfigMap(w http.ResponseWriter, r *http.Request) {
 // HandleListConfigMaps lists all configmaps in a namespace.
 func (app *App) HandleListConfigMaps(w http.ResponseWriter, r *http.Request) {
 	vals, err := url.ParseQuery(r.URL.RawQuery)
-	
+
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
 		return
@@ -185,7 +210,7 @@ func (app *App) HandleListConfigMaps(w http.ResponseWriter, r *http.Request) {
 // HandleGetConfigMap retreives the configmap given the name and namespace.
 func (app *App) HandleGetConfigMap(w http.ResponseWriter, r *http.Request) {
 	vals, err := url.ParseQuery(r.URL.RawQuery)
-	
+
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
 		return
@@ -265,6 +290,13 @@ func (app *App) HandleDeleteConfigMap(w http.ResponseWriter, r *http.Request) {
 		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
 	}
 
+	err = agent.DeleteLinkedSecret(vals["name"][0], vals["namespace"][0])
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
 	err = agent.DeleteConfigMap(vals["name"][0], vals["namespace"][0])
 
 	if err != nil {
@@ -317,7 +349,38 @@ func (app *App) HandleUpdateConfigMap(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	_, err = agent.UpdateConfigMap(configMap.Name, configMap.Namespace, configMap.EnvVariables)
+	secretData := make(map[string][]byte)
+
+	for key, rawValue := range configMap.SecretEnvVariables {
+		// encodedValue, err := base64.StdEncoding.DecodeString(rawValue)
+
+		// if err != nil {
+		// 	app.handleErrorInternal(err, w)
+		// 	return
+		// }
+
+		secretData[key] = []byte(rawValue)
+	}
+
+	// create secret first
+	err = agent.UpdateLinkedSecret(configMap.Name, configMap.Namespace, configMap.Name, secretData)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	// add all secret env variables to configmap with value PORTERSECRET_${configmap_name}
+	for key, val := range configMap.SecretEnvVariables {
+		// if val is empty and key does not exist in configmap already, set to empty
+		if _, found := configMap.EnvVariables[key]; val == "" && !found {
+			configMap.EnvVariables[key] = ""
+		} else if val != "" {
+			configMap.EnvVariables[key] = fmt.Sprintf("PORTERSECRET_%s", configMap.Name)
+		}
+	}
+
+	err = agent.UpdateConfigMap(configMap.Name, configMap.Namespace, configMap.EnvVariables)
 
 	if err != nil {
 		app.handleErrorInternal(err, w)

+ 17 - 5
server/api/release_handler.go

@@ -789,9 +789,6 @@ func (app *App) HandleReleaseDeployWebhook(w http.ResponseWriter, r *http.Reques
 
 	vals, err := url.ParseQuery(r.URL.RawQuery)
 
-	commit := vals["commit"][0]
-	repository := vals["repository"][0]
-
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
 		return
@@ -823,11 +820,21 @@ func (app *App) HandleReleaseDeployWebhook(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
+	rel, err := agent.GetRelease(form.Name, 0)
+
+	// repository is set to current repository by default
+	commit := vals["commit"][0]
+	repository := rel.Config["image"].(map[string]interface{})["repository"]
+
+	gitAction := release.GitActionConfig
+
+	if gitAction.ID != 0 && repository == "porterdev/hello-porter" {
+		repository = gitAction.ImageRepoURI
+	}
+
 	image := map[string]interface{}{}
 	image["repository"] = repository
 	image["tag"] = commit
-
-	rel, err := agent.GetRelease(form.Name, 0)
 	rel.Config["image"] = image
 
 	if rel.Config["auto_deploy"] == false {
@@ -1040,6 +1047,7 @@ func (app *App) getAgentFromQueryParams(
 		err := f(vals, app.Repo.Cluster)
 
 		if err != nil {
+			app.handleErrorInternal(err, w)
 			return nil, err
 		}
 	}
@@ -1071,6 +1079,10 @@ func (app *App) getAgentFromReleaseForm(
 		agent, err = helm.GetAgentOutOfClusterConfig(form.Form, app.Logger)
 	}
 
+	if err != nil {
+		app.handleErrorInternal(err, w)
+	}
+
 	return agent, err
 }