Jelajahi Sumber

envgroups integrated

jusrhee 5 tahun lalu
induk
melakukan
ba500c75fc

+ 4 - 3
dashboard/src/components/StatusIndicator.tsx

@@ -87,11 +87,11 @@ const Spinner = styled.img`
   width: 15px;
   height: 15px;
   margin-right: 15px;
-  margin-bottom: -1px;
+  margin-bottom: -3px;
 `;
 
 const StatusColor = styled.div`
-  margin-bottom: 1px;
+  margin-top: 1px;
   width: 8px;
   height: 8px;
   background: ${(props: { status: string }) =>
@@ -103,6 +103,7 @@ const StatusColor = styled.div`
       ? "#00d12a"
       : "#f5cb42"};
   border-radius: 20px;
+  margin-left: 3px;
   margin-right: 16px;
 `;
 
@@ -113,7 +114,7 @@ const Status = styled.div`
   flex-direction: row;
   text-transform: capitalize;
   align-items: center;
-  font-family: "Hind Siliguri", sans-serif;
+  font-family: "Work Sans", sans-serif;
   color: #aaaabb;
   animation: fadeIn 0.5s;
   margin-left: ${(props: { margin_left: string }) => props.margin_left};

+ 113 - 17
dashboard/src/components/values-form/KeyValueArray.tsx

@@ -1,5 +1,9 @@
 import React, { Component } from "react";
 import styled from "styled-components";
+import Modal from "../../main/home/modals/Modal";
+import LoadEnvGroupModal from "../../main/home/modals/LoadEnvGroupModal";
+
+import sliders from "assets/sliders.svg";
 
 type PropsType = {
   label?: string;
@@ -7,15 +11,19 @@ type PropsType = {
   setValues: (x: any) => void;
   width?: string;
   disabled?: boolean;
+  namespace?: string;
+  clusterId?: number;
 };
 
 type StateType = {
   values: any[];
+  showEnvModal: boolean;
 };
 
 export default class KeyValueArray extends Component<PropsType, StateType> {
   state = {
     values: [] as any[],
+    showEnvModal: false,
   };
 
   componentDidMount() {
@@ -34,6 +42,17 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
     return obj;
   };
 
+  objectToValues = (obj: any) => {
+    let values = [] as any[];
+    Object.keys(obj).forEach((key: string, i: number) => {
+      let entry = {} as any;
+      entry.key = key;
+      entry.value = obj[key];
+      values.push(entry);
+    });
+    return values;
+  }
+
   renderDeleteButton = (i: number) => {
     if (!this.props.disabled) {
       return (
@@ -93,28 +112,86 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
     );
   };
 
+  renderEnvModal = () => {
+    if (this.state.showEnvModal) {
+      return (
+        <Modal
+          onRequestClose={() => this.setState({ showEnvModal: false })}
+          width="665px"
+          height="332px"
+        >
+          <LoadEnvGroupModal 
+            namespace={this.props.namespace} 
+            clusterId={this.props.clusterId}
+            closeModal={() => this.setState({ showEnvModal: false })}
+            setValues={(values: any) => {
+              this.props.setValues(values);
+              this.setState({ values: this.objectToValues(values) });
+            }}
+          />
+        </Modal>
+      )
+    }
+  }
+
   render() {
     return (
-      <StyledInputArray>
-        <Label>{this.props.label}</Label>
-        {this.state.values.length === 0 ? <></> : this.renderInputList()}
-        {this.props.disabled ? (
-          <></>
-        ) : (
-          <AddRowButton
-            onClick={() => {
-              this.state.values.push({ key: "", value: "" });
-              this.setState({ values: this.state.values });
-            }}
-          >
-            <i className="material-icons">add</i> Add Row
-          </AddRowButton>
-        )}
-      </StyledInputArray>
+      <>
+        <StyledInputArray>
+          <Label>{this.props.label}</Label>
+          {this.state.values.length === 0 ? <></> : this.renderInputList()}
+          {this.props.disabled ? (
+            <></>
+          ) : (
+            <InputWrapper>
+              <AddRowButton
+                onClick={() => {
+                  this.state.values.push({ key: "", value: "" });
+                  this.setState({ values: this.state.values });
+                }}
+              >
+                <i className="material-icons">add</i> Add Row
+              </AddRowButton>
+              <Spacer />
+              {
+                this.props.namespace && (
+                  <LoadButton 
+                    onClick={() => this.setState({ showEnvModal: !this.state.showEnvModal })}
+                  >
+                    <img src={sliders} /> Load from Env Group
+                  </LoadButton>
+                )
+              }
+            </InputWrapper>
+          )}
+        </StyledInputArray>
+        {this.renderEnvModal()}
+      </>
     );
   }
 }
 
+const CloseOverlay = styled.div`
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100vw;
+  height: 100vh;
+  z-index: 999;
+  background: #202227;
+  animation: fadeIn 0.2s 0s;
+  opacity: 0;
+  animation-fill-mode: forwards;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
 const Spacer = styled.div`
   width: 10px;
   height: 20px;
@@ -123,7 +200,6 @@ const Spacer = styled.div`
 const AddRowButton = styled.div`
   display: flex;
   align-items: center;
-  margin-top: 5px;
   width: 270px;
   font-size: 13px;
   color: #aaaabb;
@@ -146,6 +222,25 @@ const AddRowButton = styled.div`
   }
 `;
 
+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: 8px;
+    margin-right: 12px;
+  }
+`;
+
 const DeleteButton = styled.div`
   width: 15px;
   height: 15px;
@@ -171,6 +266,7 @@ const DeleteButton = styled.div`
 const InputWrapper = styled.div`
   display: flex;
   align-items: center;
+  margin-top: 5px;
 `;
 
 const Input = styled.input`

+ 4 - 0
dashboard/src/components/values-form/ValuesForm.tsx

@@ -21,6 +21,8 @@ type PropsType = {
   setMetaState?: any;
   handleEnvChange?: (x: any) => void;
   disabled?: boolean;
+  namespace?: string;
+  clusterId?: number;
 };
 
 type StateType = any;
@@ -78,6 +80,8 @@ export default class ValuesForm extends Component<PropsType, StateType> {
           return (
             <KeyValueArray
               key={i}
+              namespace={this.props.namespace}
+              clusterId={this.props.clusterId}
               values={this.props.metaState[key]}
               setValues={(x: any) => {
                 this.props.setMetaState({ [key]: x });

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx

@@ -116,7 +116,6 @@ const InfoWrapper = styled.div`
 const LastDeployed = styled.div`
   font-size: 13px;
   margin-left: 10px;
-  margin-top: -1px;
   display: flex;
   align-items: center;
   color: #aaaabb66;

+ 63 - 8
dashboard/src/main/home/cluster-dashboard/env-groups/CreateEnvGroup.tsx

@@ -2,8 +2,11 @@ import React, { Component } from "react";
 import styled from "styled-components";
 
 import sliders from "assets/sliders.svg";
+import api from "shared/api";
 
 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 Selector from "components/Selector";
@@ -13,6 +16,7 @@ import { isAlphanumeric } from "shared/common";
 
 type PropsType = {
   goBack: () => void;
+  currentCluster: ClusterType;
 };
 
 type StateType = {
@@ -22,6 +26,7 @@ type StateType = {
   selectedNamespace: string;
   namespaceOptions: any[];
   envVariables: any;
+  submitStatus: string;
 };
 
 export default class CreateEnvGroup extends Component<PropsType, StateType> {
@@ -29,16 +34,65 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
     expand: false,
     update: [] as any[],
     envGroupName: "",
-    selectedNamespace: "",
+    selectedNamespace: "default",
     namespaceOptions: [] as any[],
     envVariables: {} as any,
+    submitStatus: "",
   };
 
+  componentDidMount() {
+    this.updateNamespaces();
+  }
+
   isDisabled = () => {
-    return !(!isAlphanumeric(this.state.envGroupName) &&
-    this.state.envGroupName !== "");
+    return !isAlphanumeric(this.state.envGroupName) ||
+    this.state.envGroupName === "";
+  }
+
+  onSubmit = () => {
+    this.setState({ submitStatus: "loading" });
+    api.createConfigMap("<token>", {
+      name: this.state.envGroupName,
+      namespace: this.state.selectedNamespace,
+      variables: this.state.envVariables,
+    }, { 
+      id: this.context.currentProject.id,
+      cluster_id: this.props.currentCluster.id
+    })
+      .then((res) => {
+        this.setState({ submitStatus: "successful" });
+        this.props.goBack();
+      })
+      .catch((err) => {
+        this.setState({ submitStatus: "Could not create" });
+      });
   }
 
+  updateNamespaces = () => {
+    let { currentProject } = this.context;
+    api
+      .getNamespaces(
+        "<token>",
+        {
+          cluster_id: this.props.currentCluster.id,
+        },
+        { id: currentProject.id }
+      )
+      .then((res) => {
+        if (res.data) {
+          let namespaceOptions = res.data.items.map(
+            (x: { metadata: { name: string } }) => {
+              return { label: x.metadata.name, value: x.metadata.name };
+            }
+          );
+          if (res.data.items.length > 0) {
+            this.setState({ namespaceOptions });
+          }
+        }
+      })
+      .catch(console.log);
+  };
+
   render() {
     return (
       <>
@@ -55,8 +109,8 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
           <DarkMatter antiHeight="-13px" />
           <Heading isAtTop={true}>Name</Heading>
           <Subtitle>
-            Randomly generated if left blank.
             <Warning
+              makeFlush={true}
               highlight={
                 !isAlphanumeric(this.state.envGroupName) &&
                 this.state.envGroupName !== ""
@@ -100,17 +154,18 @@ export default class CreateEnvGroup extends Component<PropsType, StateType> {
             Set environment variables for your secrets and environment-specific configuration.
           </Helper>
           <KeyValueArray
+            namespace={this.state.selectedNamespace}
             values={this.state.envVariables}
             setValues={(x: any) => this.setState({ envVariables: x })}
           />
           <SaveButton
             disabled={this.isDisabled()}
-            text="Deploy"
-            onClick={() => console.log("asdf")}
+            text="Create Env Group"
+            onClick={this.onSubmit}
             status={
               this.isDisabled()
                 ? "Missing required fields"
-                : "What is the status?"
+                : this.state.submitStatus
             }
             makeFlush={true}
           />
@@ -129,7 +184,7 @@ const Buffer = styled.div`
 `;
 
 const StyledCreateEnvGroup = styled.div`
-  padding-bottom: 50px;
+  padding-bottom: 70px;
   position: relative;
 `;
 

+ 8 - 4
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroup.tsx

@@ -35,6 +35,10 @@ export default class EnvGroup extends Component<PropsType, StateType> {
 
   render() {
     let { envGroup, setExpanded } = this.props;
+    let name = envGroup?.metadata?.name;
+    let timestamp = envGroup?.metadata?.creationTimestamp;
+    let namespace = envGroup?.metadata?.namespace;
+    let varCount = Object.values(envGroup?.data || {}).length;
 
     return (
       <StyledEnvGroup
@@ -47,23 +51,23 @@ export default class EnvGroup extends Component<PropsType, StateType> {
           <IconWrapper>
             <Icon src={key} />
           </IconWrapper>
-          {envGroup.name}
+          {name}
         </Title>
 
         <BottomWrapper>
           <InfoWrapper>
             <LastDeployed>
-              Last updated {this.readableDate(envGroup.last_updated)}
+              Last updated {this.readableDate(timestamp)}
             </LastDeployed>
           </InfoWrapper>
 
           <TagWrapper>
             Namespace
-            <NamespaceTag>{envGroup.namespace}</NamespaceTag>
+            <NamespaceTag>{namespace}</NamespaceTag>
           </TagWrapper>
         </BottomWrapper>
 
-        <Version>12 variables</Version>
+        <Version>{varCount} variables</Version>
       </StyledEnvGroup>
     );
   }

+ 5 - 2
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupDashboard.tsx

@@ -52,7 +52,10 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
   renderBody = () => {
     if (this.state.createEnvMode) {
       return (
-        <CreateEnvGroup goBack={() => this.setState({ createEnvMode: false })} />
+        <CreateEnvGroup 
+          goBack={() => this.setState({ createEnvMode: false })}
+          currentCluster={this.props.currentCluster}
+        />
       )
     } else {
       return (
@@ -90,7 +93,7 @@ class EnvGroupDashboard extends Component<PropsType, StateType> {
         <ExpandedEnvGroup
           namespace={this.state.namespace}
           currentCluster={this.props.currentCluster}
-          initialEnvGroup={this.state.expandedEnvGroup}
+          envGroup={this.state.expandedEnvGroup}
           closeExpanded={() => this.setState({ expandedEnvGroup: null })}
         />
       );

+ 19 - 4
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx

@@ -3,8 +3,7 @@ import styled from "styled-components";
 
 import { Context } from "shared/Context";
 import api from "shared/api";
-import { ChartType, StorageType, ClusterType } from "shared/types";
-import { PorterUrl } from "shared/routing";
+import { ClusterType } from "shared/types";
 
 import EnvGroup from "./EnvGroup";
 import Loading from "components/Loading";
@@ -30,16 +29,32 @@ const dummyEnvGroups = [
 
 export default class EnvGroupList extends Component<PropsType, StateType> {
   state = {
-    envGroups: dummyEnvGroups as any[],
+    envGroups: [] as any[],
     loading: false,
     error: false,
   };
 
   updateEnvGroups = () => {
-    // retrieve and set env groups
+    api.listConfigMaps("<token>", {
+      namespace: this.props.namespace,
+      cluster_id: this.props.currentCluster.id
+    }, { 
+      id: this.context.currentProject.id 
+    })
+      .then((res) => {
+        this.setState({ 
+          envGroups: res?.data?.items as any,
+          loading: false,
+        });
+        console.log(res.data.items);
+      })
+      .catch((err) => {
+        this.setState({ loading: false, error: true });
+      });
   };
 
   componentDidMount() {
+    this.setState({ loading: true });
     this.updateEnvGroups();
   }
 

+ 56 - 21
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -16,23 +16,21 @@ import TabRegion from "components/TabRegion";
 import KeyValueArray from "components/values-form/KeyValueArray";
 import Heading from "components/values-form/Heading";
 import Helper from "components/values-form/Helper";
-import SettingsSection from "../expanded-chart/SettingsSection";
 
 type PropsType = {
   namespace: string;
-  initialEnvGroup: any;
+  envGroup: any;
   currentCluster: ClusterType;
   closeExpanded: () => void;
 };
 
 type StateType = {
-  currentEnvGroup: any;
   loading: boolean;
   currentTab: string | null;
   showDeleteOverlay: boolean;
   deleting: boolean;
   saveValuesStatus: string | null;
-  values: any[];
+  values: any;
 };
 
 const tabOptions = [
@@ -42,25 +40,40 @@ const tabOptions = [
 
 export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
   state = {
-    currentEnvGroup: this.props.initialEnvGroup,
     loading: true,
     currentTab: "environment",
     showDeleteOverlay: false,
     deleting: false,
     saveValuesStatus: null as string | null,
-    values: [] as any[],
+    values: this.props.envGroup.data as any,
   };
 
-  getEnvGroupData = () => {
-    // Get complete envgroup data
-  };
-
-  handleSaveValues = (config?: any) => {
-    // Save env group values
+  handleUpdateValues = (config?: any) => {
+    let { envGroup } = this.props;
+    let name = envGroup.metadata.name;
+    let namespace = envGroup.metadata.namespace;
+
+    this.setState({ saveValuesStatus: "loading" });
+    api.updateConfigMap("<token>", {
+      name,
+      namespace,
+      variables: this.state.values,
+    }, { 
+      id: this.context.currentProject.id,
+      cluster_id: this.props.currentCluster.id
+    })
+      .then((res) => {
+        this.setState({ saveValuesStatus: "successful" });
+      })
+      .catch((err) => {
+        this.setState({ saveValuesStatus: "error" });
+      });
   };
 
   renderTabContents = () => {
     let currentTab = this.state.currentTab;
+    let { envGroup, namespace } = this.props;
+    let name = envGroup.metadata.name;
 
     switch (currentTab) {
       case "environment":
@@ -70,13 +83,14 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
               <Heading>Environment Variables</Heading>
               <Helper>Set environment variables for your secrets and environment-specific configuration.</Helper>
               <KeyValueArray
-                values={this.state.values}
+                namespace={namespace}
+                values={this.state.values || {}}
                 setValues={(x: any) => this.setState({ values: x })}
               />
             </InnerWrapper>
             <SaveButton
               text="Update"
-              onClick={() => this.handleSaveValues()}
+              onClick={() => this.handleUpdateValues()}
               status={this.state.saveValuesStatus}
               makeFlush={true}
             />
@@ -92,7 +106,7 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
                 color="#b91133"
                 onClick={() => this.setState({ showDeleteOverlay: true })}
               >
-                Delete {this.state.currentEnvGroup.name}
+                Delete {name}
               </Button>
             </InnerWrapper>
           </TabWrapper>
@@ -111,7 +125,25 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
   };
 
   handleDeleteEnvGroup = () => {
-    
+    let { envGroup } = this.props;
+    let name = envGroup.metadata.name;
+    let namespace = envGroup.metadata.namespace;
+
+    this.setState({ deleting: true });
+    api.deleteConfigMap("<token>", {
+      name,
+      namespace,
+      cluster_id: this.props.currentCluster.id
+    }, { id: this.context.currentProject.id })
+      .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);
+      });
   };
 
   renderDeleteOverlay = () => {
@@ -126,7 +158,10 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
 
   render() {
     let { closeExpanded } = this.props;
-    let { currentEnvGroup } = this.state;
+    let { envGroup } = this.props;
+    let name = envGroup.metadata.name;
+    let timestamp = envGroup.metadata.creationTimestamp;
+    let namespace = envGroup.metadata.namespace;
 
     return (
       <>
@@ -134,7 +169,7 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
         <StyledExpandedChart>
           <ConfirmOverlay
             show={this.state.showDeleteOverlay}
-            message={`Are you sure you want to delete ${currentEnvGroup.name}?`}
+            message={`Are you sure you want to delete ${name}?`}
             onYes={this.handleDeleteEnvGroup}
             onNo={() => this.setState({ showDeleteOverlay: false })}
           />
@@ -146,16 +181,16 @@ export default class ExpandedEnvGroup extends Component<PropsType, StateType> {
                 <IconWrapper>
                   <Icon src={key} />
                 </IconWrapper>
-                {currentEnvGroup.name}
+                {name}
               </Title>
               <InfoWrapper>
                 <LastDeployed>
-                  Last updated {this.readableDate(currentEnvGroup.last_updated)}
+                  Last updated {this.readableDate(timestamp)}
                 </LastDeployed>
               </InfoWrapper>
 
               <TagWrapper>
-                Namespace <NamespaceTag>{currentEnvGroup.namespace}</NamespaceTag>
+                Namespace <NamespaceTag>{namespace}</NamespaceTag>
               </TagWrapper>
             </TitleSection>
 

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

@@ -351,6 +351,9 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
                         metaState={metaState}
                         setMetaState={setMetaState}
                         sections={tab.sections}
+
+                        // For env group loader
+                        namespace={this.props.namespace}
                       />
                     );
                   }

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

@@ -40,6 +40,7 @@ type StateType = {
   saveValuesStatus: string | null;
   selectedNamespace: string;
   selectedCluster: string;
+  selectedClusterId: number;
   selectedImageUrl: string | null;
   sourceType: string;
   selectedTag: string | null;
@@ -70,6 +71,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
     clusterMap: {} as { [clusterId: string]: ClusterType },
     saveValuesStatus: "" as string | null,
     selectedCluster: this.context.currentCluster.name,
+    selectedClusterId: this.context.currentCluster.id,
     selectedNamespace: "default",
     selectedImageUrl: "" as string | null,
     sourceType: "",
@@ -412,6 +414,10 @@ class LaunchTemplate extends Component<PropsType, StateType> {
                   setMetaState={setMetaState}
                   key={tab.name}
                   sections={tab.sections}
+
+                  // For env group loader
+                  namespace={this.state.selectedNamespace}
+                  clusterId={this.state.selectedClusterId}
                 />
               );
             }
@@ -738,7 +744,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             setActiveValue={(cluster: string) => {
               this.context.setCurrentCluster(this.state.clusterMap[cluster]);
               this.updateNamespaces(this.state.clusterMap[cluster].id);
-              this.setState({ selectedCluster: cluster });
+              this.setState({ selectedCluster: cluster, selectedClusterId: this.state.clusterMap[cluster].id });
             }}
             options={this.state.clusterOptions}
             width="250px"

+ 217 - 0
dashboard/src/main/home/modals/LoadEnvGroupModal.tsx

@@ -0,0 +1,217 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import close from "assets/close.png";
+import sliders from "assets/sliders.svg";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+import Loading from "components/Loading";
+import SaveButton from "components/SaveButton";
+
+type PropsType = {
+  namespace: string;
+  clusterId: number;
+  closeModal: () => void;
+  setValues: (values: any) => void;
+};
+
+type StateType = {
+  envGroups: any[];
+  loading: boolean;
+  error: boolean;
+  selectedEnvGroup: any;
+  buttonStatus: string;
+};
+
+export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
+  state = {
+    envGroups: [] as any[],
+    loading: true,
+    error: false,
+    selectedEnvGroup: null as any,
+    buttonStatus: ""
+  };
+
+  onSubmit = () => {
+    this.props.setValues(this.state.selectedEnvGroup.data);
+    this.props.closeModal();
+  }
+
+  updateEnvGroups = () => {
+    api.listConfigMaps("<token>", {
+      namespace: this.props.namespace,
+      cluster_id: this.props.clusterId || this.context.currentCluster.id
+    }, { 
+      id: this.context.currentProject.id 
+    })
+      .then((res) => {
+        this.setState({ 
+          envGroups: res?.data?.items as any[],
+          loading: false,
+        });
+        console.log(res.data.items);
+      })
+      .catch((err) => {
+        this.setState({ loading: false, error: true });
+      });
+  };
+
+  componentDidMount() {
+    this.updateEnvGroups();
+  }
+
+  renderEnvGroupList = () => {
+    if (this.state.loading) {
+      return <LoadingWrapper><Loading /></LoadingWrapper>;
+    } else if (this.state.envGroups.length === 0) {
+      return (
+        <Placeholder>No environment groups found in this namespace ({this.props.namespace})</Placeholder>
+      );
+    } else {
+      return this.state.envGroups.map((envGroup: any, i: number) => {
+        return (
+          <EnvGroupRow
+            key={i}
+            isSelected={this.state.selectedEnvGroup === envGroup}
+            lastItem={i === this.state.envGroups.length - 1}
+            onClick={() => this.setState({ selectedEnvGroup: envGroup })}
+          >
+            <img src={sliders} />
+            {envGroup.metadata.name}
+          </EnvGroupRow>
+        );
+      });
+    }
+  }
+
+  render() {
+    return (
+      <StyledLoadEnvGroupModal>
+        <CloseButton onClick={this.props.closeModal}>
+          <CloseButtonImg src={close} />
+        </CloseButton>
+
+        <ModalTitle>Load from Environment Group</ModalTitle>
+        <Subtitle>Select an existing group of environment variables in this namespace ({this.props.namespace}).</Subtitle>
+
+        <EnvGroupList>
+          {this.renderEnvGroupList()}
+        </EnvGroupList>
+
+        <SaveButton
+          disabled={!this.state.selectedEnvGroup}
+          text="Load Selected Env Group"
+          status={!this.state.selectedEnvGroup ? "No env group selected" : ""}
+          onClick={this.onSubmit}
+        />
+      </StyledLoadEnvGroupModal>
+    );
+  }
+}
+
+LoadEnvGroupModal.contextType = Context;
+
+const Placeholder = styled.div`
+  width: 100%;
+  height: 150px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const LoadingWrapper = styled.div`
+  height: 150px;
+`;
+
+const EnvGroupRow = styled.div<{ lastItem?: boolean, isSelected: boolean }>`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  border-bottom: 1px solid
+    ${props => props.lastItem ? "#00000000" : "#606166"};
+  color: #ffffff;
+  user-select: none;
+  align-items: center;
+  padding: 10px 0px;
+  cursor: pointer;
+  background: ${props => props.isSelected ? "#ffffff11" : ""};
+  :hover {
+    background: #ffffff11;
+  }
+
+  > img,
+  i {
+    width: 16px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+    font-size: 20px;
+  }
+`;
+
+const EnvGroupList = styled.div`
+  margin-top: 20px;
+  width: 100%;
+  border-radius: 3px;
+  background: #ffffff11;
+  border: 1px solid #ffffff44;
+  max-height: 150px;
+  overflow-y: auto;
+`;
+
+const Subtitle = styled.div`
+  margin-top: 15px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+`;
+
+const ModalTitle = styled.div`
+  margin: 0px 0px 13px;
+  display: flex;
+  flex: 1;
+  font-family: "Assistant";
+  font-size: 18px;
+  color: #ffffff;
+  user-select: none;
+  font-weight: 700;
+  align-items: center;
+  position: relative;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const StyledLoadEnvGroupModal = styled.div`
+  width: 100%;
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  padding: 25px 30px;
+  overflow: hidden;
+  border-radius: 6px;
+  background: #202227;
+`;

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

@@ -710,6 +710,62 @@ const upgradeChartValues = baseApi<
   return `/api/projects/${id}/releases/${name}/upgrade?cluster_id=${cluster_id}`;
 });
 
+const listConfigMaps = baseApi<
+  {
+    namespace: string;
+    cluster_id: number;
+  },
+  { id: number }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.id}/k8s/configmap/list`;
+});
+
+const getConfigMap = baseApi<
+  {
+    name: string;
+    namespace: string;
+    cluster_id: number;
+  },
+  { id: number }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.id}/k8s/configmap`;
+});
+
+const createConfigMap = baseApi<
+  {
+    name: string;
+    namespace: string;
+    variables: Record<string, string>;
+  },
+  { id: number, cluster_id: number }
+>("POST", (pathParams) => {
+  let { id, cluster_id } = pathParams;
+  return `/api/projects/${id}/k8s/configmap/create?cluster_id=${cluster_id}`;
+});
+
+const updateConfigMap = baseApi<
+  {
+    name: string;
+    namespace: string;
+    variables: Record<string, string>;
+  },
+  { id: number, cluster_id: number }
+>("POST", (pathParams) => {
+  let { id, cluster_id } = pathParams;
+  return `/api/projects/${id}/k8s/configmap/update?cluster_id=${cluster_id}`;
+});
+
+const deleteConfigMap = baseApi<
+  {
+    name: string,
+    namespace: string,
+    cluster_id: number;
+  },
+  { id: number }
+>("DELETE", (pathParams) => {
+  return `/api/projects/${pathParams.id}/k8s/configmap/delete`;
+});
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -728,7 +784,9 @@ export default {
   createPasswordResetVerify,
   createPasswordResetFinalize,
   createProject,
+  createConfigMap,
   deleteCluster,
+  deleteConfigMap,
   deleteGitRepoIntegration,
   deleteInvite,
   deletePod,
@@ -747,6 +805,7 @@ export default {
   getChartControllers,
   getClusterIntegrations,
   getClusters,
+  getConfigMap,
   getGitRepoList,
   getGitRepos,
   getImageRepos,
@@ -776,6 +835,7 @@ export default {
   getApplicationTemplates,
   getUser,
   linkGithubProject,
+  listConfigMaps,
   logInUser,
   logOutUser,
   provisionECR,
@@ -784,5 +844,6 @@ export default {
   rollbackChart,
   uninstallTemplate,
   updateUser,
+  updateConfigMap,
   upgradeChartValues,
 };

+ 3 - 2
go.sum

@@ -926,14 +926,14 @@ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb
 github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
+github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3 h1:ZuhckGJ10ulaKkdvJtiAqsLTiPrLaXSdnVgXJKJkTxE=
+github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3/go.mod h1:9/Rh6yILuLysoQnZ2oNooD2g7aBnvM7r/fNVxRNWfBc=
 github.com/sendgrid/rest v1.0.2 h1:xdfALkR1m9eqf41/zEnUmV0fw4b31ZzGZ4Dj5f2/w04=
 github.com/sendgrid/rest v2.6.3+incompatible h1:h/uruXAzKxVyDDIQX/MkQI73p/gsdpEnb5q2wxSvTsA=
 github.com/sendgrid/rest v2.6.3+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE=
 github.com/sendgrid/sendgrid-go v1.2.0 h1:2K3teZdhaPe12ftFyFL4AWDH4QmNPc+sCi6mWFx5+oo=
 github.com/sendgrid/sendgrid-go v3.8.0+incompatible h1:7yoUFMwT+jDI2ArBpC6zvtuQj1RUyYfCDl7zZea3XV4=
 github.com/sendgrid/sendgrid-go v3.8.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
-github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3 h1:ZuhckGJ10ulaKkdvJtiAqsLTiPrLaXSdnVgXJKJkTxE=
-github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3/go.mod h1:9/Rh6yILuLysoQnZ2oNooD2g7aBnvM7r/fNVxRNWfBc=
 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
 github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc h1:jUIKcSPO9MoMJBbEoyE/RJoE8vz7Mb8AjvifMMwSyvY=
@@ -1494,6 +1494,7 @@ k8s.io/apiextensions-apiserver v0.18.8/go.mod h1:7f4ySEkkvifIr4+BRrRWriKKIJjPyg9
 k8s.io/apimachinery v0.16.8/go.mod h1:Xk2vD2TRRpuWYLQNM6lT9R7DSFZUYG03SarNkbGrnKE=
 k8s.io/apimachinery v0.18.8 h1:jimPrycCqgx2QPearX3to1JePz7wSbVLq+7PdBTTwQ0=
 k8s.io/apimachinery v0.18.8/go.mod h1:6sQd+iHEqmOtALqOFjSWp2KZ9F0wlU/nWm0ZgsYWMig=
+k8s.io/apimachinery v0.21.0 h1:3Fx+41if+IRavNcKOz09FwEXDBG6ORh6iMsTSelhkMA=
 k8s.io/apiserver v0.18.8/go.mod h1:12u5FuGql8Cc497ORNj79rhPdiXQC4bf53X/skR/1YM=
 k8s.io/cli-runtime v0.18.8 h1:ycmbN3hs7CfkJIYxJAOB10iW7BVPmXGXkfEyiV9NJ+k=
 k8s.io/cli-runtime v0.18.8/go.mod h1:7EzWiDbS9PFd0hamHHVoCY4GrokSTPSL32MA4rzIu0M=

+ 6 - 0
internal/forms/k8s.go

@@ -37,3 +37,9 @@ func (kf *K8sForm) PopulateK8sOptionsFromQueryParams(
 
 	return nil
 }
+
+type ConfigMapForm struct {
+	Name string `json:"name" form:"required"`
+	Namespace string `json:"namespace" form:"required"`
+	EnvVariables map[string]string `json:"variables"`
+}

+ 56 - 0
internal/kubernetes/agent.go

@@ -58,6 +58,62 @@ type ListOptions struct {
 	FieldSelector string
 }
 
+// CreateConfigMap creates the configmap given the key-value pairs and namespace
+func (a *Agent) CreateConfigMap(name string, namespace string, configMap map[string]string) (*v1.ConfigMap, error) {
+	return a.Clientset.CoreV1().ConfigMaps(namespace).Create(
+		context.TODO(),
+		&v1.ConfigMap{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: name,
+				Namespace: namespace,
+			},
+			Data: configMap,
+		},
+		metav1.CreateOptions{},
+	)
+}
+
+// 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(
+		context.TODO(),
+		&v1.ConfigMap{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: name,
+				Namespace: namespace,
+			},
+			Data: configMap,
+		},
+		metav1.UpdateOptions{},
+	)
+}
+
+// DeleteConfigMap deletes the configmap given its name and namespace
+func (a *Agent) DeleteConfigMap(name string, namespace string) (error) {
+	return a.Clientset.CoreV1().ConfigMaps(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(
+		context.TODO(),
+		name,
+		metav1.GetOptions{},
+	)
+}
+
+// ListConfigMaps simply lists namespaces
+func (a *Agent) ListConfigMaps(namespace string) (*v1.ConfigMapList, error) {
+	return a.Clientset.CoreV1().ConfigMaps(namespace).List(
+		context.TODO(),
+		metav1.ListOptions{},
+	)
+}
+
 // ListNamespaces simply lists namespaces
 func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
 	return a.Clientset.CoreV1().Namespaces().List(

+ 259 - 0
server/api/k8s_handler.go

@@ -19,6 +19,7 @@ import (
 const (
 	ErrK8sDecode ErrorCode = iota + 600
 	ErrK8sValidate
+	ErrEnvDecode
 )
 
 var upgrader = websocket.Upgrader{
@@ -73,6 +74,264 @@ 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)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	configMap := &forms.ConfigMapForm{}
+
+	if err := json.NewDecoder(r.Body).Decode(configMap); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	_, err = agent.CreateConfigMap(configMap.Name, configMap.Namespace, configMap.EnvVariables)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(configMap); err != nil {
+		app.handleErrorFormDecoding(err, ErrEnvDecode, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+// 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
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	configMaps, err := agent.ListConfigMaps(vals["namespace"][0])
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(configMaps); err != nil {
+		app.handleErrorFormDecoding(err, ErrEnvDecode, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+// 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
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	configMap, err := agent.GetConfigMap(vals["name"][0], vals["namespace"][0])
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(configMap); err != nil {
+		app.handleErrorFormDecoding(err, ErrEnvDecode, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+// HandleDeleteConfigMap deletes the pod given the name and namespace.
+func (app *App) HandleDeleteConfigMap(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	err = agent.DeleteConfigMap(vals["name"][0], vals["namespace"][0])
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+// HandleUpdateConfigMap deletes the pod given the name and namespace.
+func (app *App) HandleUpdateConfigMap(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	configMap := &forms.ConfigMapForm{}
+
+	if err := json.NewDecoder(r.Body).Decode(configMap); err != nil {
+		app.handleErrorFormDecoding(err, ErrEnvDecode, w)
+		return
+	}
+
+	_, err = agent.UpdateConfigMap(configMap.Name, configMap.Namespace, configMap.EnvVariables)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(configMap); err != nil {
+		app.handleErrorFormDecoding(err, ErrEnvDecode, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
 // HandleGetPodLogs returns real-time logs of the pod via websockets
 // TODO: Refactor repeated calls.
 func (app *App) HandleGetPodLogs(w http.ResponseWriter, r *http.Request) {

+ 70 - 0
server/router/router.go

@@ -1224,6 +1224,76 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		r.Method(
+			"POST",
+			"/projects/{project_id}/k8s/configmap/create",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleCreateConfigMap, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"DELETE",
+			"/projects/{project_id}/k8s/configmap/delete",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleDeleteConfigMap, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/k8s/configmap",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleGetConfigMap, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/k8s/configmap/list",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleListConfigMaps, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"POST",
+			"/projects/{project_id}/k8s/configmap/update",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleUpdateConfigMap, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		// /api/projects/{project_id}/subdomain routes
 		r.Method(
 			"POST",