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

Merge pull request #747 from porter-dev/0.4.0-create-namespace

Create/Delete Namespace
sunguroku 5 лет назад
Родитель
Сommit
b9d8304178

+ 51 - 1
dashboard/src/components/Selector.tsx

@@ -1,9 +1,12 @@
 import React, { Component } from "react";
 import styled from "styled-components";
+import { Context } from "shared/Context";
 
 type PropsType = {
   activeValue: string;
+  refreshOptions?: () => void;
   options: { value: string; label: string }[];
+  addButton?: boolean;
   setActiveValue: (x: string) => void;
   width: string;
   height?: string;
@@ -76,6 +79,21 @@ export default class Selector extends Component<PropsType, StateType> {
     }
   };
 
+  renderAddButton = () => {
+    if (this.props.addButton) {
+      return (
+        <NewOption
+          onClick={() => {
+            this.context.setCurrentModal("NamespaceModal");
+          }}
+        >
+          <Plus>+</Plus>
+          Add Namespace
+        </NewOption>
+      );
+    }
+  };
+
   renderDropdown = () => {
     if (this.state.expanded) {
       return (
@@ -91,6 +109,7 @@ export default class Selector extends Component<PropsType, StateType> {
         >
           {this.renderDropdownLabel()}
           {this.renderOptionList()}
+          {this.renderAddButton()}
         </Dropdown>
       );
     }
@@ -107,11 +126,15 @@ export default class Selector extends Component<PropsType, StateType> {
 
   render() {
     let { activeValue } = this.props;
+
     return (
       <StyledSelector width={this.props.width}>
         <MainSelector
           ref={this.parentRef}
-          onClick={() => this.setState({ expanded: !this.state.expanded })}
+          onClick={() => {
+            this.props.refreshOptions();
+            this.setState({ expanded: !this.state.expanded });
+          }}
           expanded={this.state.expanded}
           width={this.props.width}
           height={this.props.height}
@@ -127,6 +150,13 @@ export default class Selector extends Component<PropsType, StateType> {
   }
 }
 
+Selector.contextType = Context;
+
+const Plus = styled.div`
+  margin-right: 10px;
+  font-size: 15px;
+`;
+
 const TextWrap = styled.div`
   white-space: nowrap;
   overflow: hidden;
@@ -141,6 +171,26 @@ const DropdownLabel = styled.div`
   margin: 10px 13px;
 `;
 
+const NewOption = styled.div`
+  display: flex;
+  width: 100%;
+  border-top: 1px solid #00000000;
+  border-bottom: 1px solid #ffffff00;
+  height: 37px;
+  font-size: 13px;
+  align-items: center;
+  padding-left: 15px;
+  cursor: pointer;
+  padding-right: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
 const Option = styled.div`
   width: 100%;
   border-top: 1px solid #00000000;

+ 10 - 0
dashboard/src/main/home/Home.tsx

@@ -19,6 +19,7 @@ import IntegrationsInstructionsModal from "./modals/IntegrationsInstructionsModa
 import IntegrationsModal from "./modals/IntegrationsModal";
 import Modal from "./modals/Modal";
 import UpdateClusterModal from "./modals/UpdateClusterModal";
+import NamespaceModal from "./modals/NamespaceModal";
 import Navbar from "./navbar/Navbar";
 import NewProject from "./new-project/NewProject";
 import ProjectSettings from "./project-settings/ProjectSettings";
@@ -508,6 +509,15 @@ class Home extends Component<PropsType, StateType> {
             <IntegrationsInstructionsModal />
           </Modal>
         )}
+        {currentModal === "NamespaceModal" && (
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width="600px"
+            height="220px"
+          >
+            <NamespaceModal />
+          </Modal>
+        )}
 
         {this.renderSidebar()}
 

+ 2 - 0
dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx

@@ -686,6 +686,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
   };
 
   render() {
+    console.log("RENDERING");
     let { name, icon } = this.props.currentTemplate;
     let { currentTemplate } = this.props;
 
@@ -755,6 +756,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             setActiveValue={(namespace: string) =>
               this.setState({ selectedNamespace: namespace })
             }
+            addButton={true}
             options={this.state.namespaceOptions}
             width="250px"
             dropdownWidth="335px"

+ 4 - 0
dashboard/src/main/home/launch/launch-flow/SettingsPage.tsx

@@ -253,6 +253,10 @@ export default class SettingsPage extends Component<PropsType, StateType> {
             </NamespaceLabel>
             <Selector
               key={"namespace"}
+              refreshOptions={() => {
+                this.updateNamespaces(this.context.currentCluster.id);
+              }}
+              addButton={true}
               activeValue={selectedNamespace}
               setActiveValue={setSelectedNamespace}
               options={this.state.namespaceOptions}

+ 199 - 0
dashboard/src/main/home/modals/NamespaceModal.tsx

@@ -0,0 +1,199 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import close from "assets/close.png";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+import SaveButton from "components/SaveButton";
+import InputRow from "components/values-form/InputRow";
+
+type PropsType = {};
+
+type StateType = {
+  namespaceName: string;
+  status: string | null;
+};
+
+export default class NamespaceModal extends Component<PropsType, StateType> {
+  state = {
+    namespaceName: "",
+    status: null as string | null,
+  };
+
+  createNamespace = () => {
+    api
+      .createNamespace(
+        "<token>",
+        {
+          name: this.state.namespaceName,
+        },
+        {
+          id: this.context.currentProject.id,
+          cluster_id: this.context.currentCluster.id,
+        }
+      )
+      .then((res) => {
+        this.setState({ status: "successful" }, () => {
+          setTimeout(() => {
+            this.context.setCurrentModal(null, null);
+          }, 1000);
+        });
+      })
+      .catch((err) => {
+        this.setState({ status: "Could not create" });
+      });
+  };
+
+  render() {
+    return (
+      <StyledUpdateProjectModal>
+        <CloseButton
+          onClick={() => {
+            this.context.setCurrentModal(null, null);
+          }}
+        >
+          <CloseButtonImg src={close} />
+        </CloseButton>
+
+        <ModalTitle>Add Namespace</ModalTitle>
+        <Subtitle>Name</Subtitle>
+
+        <InputWrapper>
+          <DashboardIcon>
+            <i className="material-icons">space_dashboard</i>
+          </DashboardIcon>
+          <InputRow
+            type="string"
+            value={this.state.namespaceName}
+            setValue={(x: string) => this.setState({ namespaceName: x })}
+            placeholder="ex: porter-workers"
+            width="480px"
+          />
+        </InputWrapper>
+
+        {/* <Help
+          href="https://docs.getporter.dev/docs/deleting-dangling-resources"
+          target="_blank"
+        >
+          <i className="material-icons">help_outline</i> Help
+        </Help> */}
+
+        <SaveButton
+          text="Create Namespace"
+          color="#616FEEcc"
+          onClick={() => this.createNamespace()}
+          status={this.state.status}
+        />
+      </StyledUpdateProjectModal>
+    );
+  }
+}
+
+NamespaceModal.contextType = Context;
+
+const Help = styled.a`
+  position: absolute;
+  left: 31px;
+  bottom: 35px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff55;
+  font-size: 13px;
+  :hover {
+    color: #ffffff;
+  }
+
+  > i {
+    margin-right: 9px;
+    font-size: 16px;
+  }
+`;
+
+const DashboardIcon = styled.div`
+  width: 32px;
+  margin-top: 6px;
+  min-width: 25px;
+  height: 32px;
+  border-radius: 3px;
+  overflow: hidden;
+  position: relative;
+  margin-right: 15px;
+  font-weight: 400;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #676c7c;
+  border: 2px solid #8e94aa;
+  color: white;
+
+  > i {
+    font-size: 17px;
+  }
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Subtitle = styled.div`
+  margin-top: 23px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  margin-bottom: -10px;
+`;
+
+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 StyledUpdateProjectModal = styled.div`
+  width: 100%;
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  padding: 25px 30px;
+  overflow: hidden;
+  border-radius: 6px;
+  background: #202227;
+`;

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

@@ -798,6 +798,27 @@ const deleteConfigMap = baseApi<
   return `/api/projects/${pathParams.id}/k8s/configmap/delete`;
 });
 
+const createNamespace = baseApi<
+  {
+    name: string;
+  },
+  { id: number; cluster_id: number }
+>("POST", (pathParams) => {
+  let { id, cluster_id } = pathParams;
+  return `/api/projects/${id}/k8s/namespaces/create?cluster_id=${cluster_id}`;
+});
+
+const deleteNamespace = baseApi<
+  {
+    name: string;
+    cluster_id: number;
+  },
+  { id: number }
+>("DELETE", (pathParams) => {
+  let { id } = pathParams;
+  return `/api/projects/${id}/k8s/namespaces/delete`;
+});
+
 const stopJob = baseApi<
   {},
   { name: string; namespace: string; id: number; cluster_id: number }
@@ -820,6 +841,7 @@ export default {
   createGHAction,
   createGKE,
   createInvite,
+  createNamespace,
   createPasswordReset,
   createPasswordResetVerify,
   createPasswordResetFinalize,
@@ -829,6 +851,7 @@ export default {
   deleteConfigMap,
   deleteGitRepoIntegration,
   deleteInvite,
+  deleteNamespace,
   deletePod,
   deleteProject,
   deleteRegistryIntegration,

+ 1 - 0
go.sum

@@ -1686,6 +1686,7 @@ k8s.io/apimachinery v0.20.0 h1:jjzbTJRXk0unNS71L7h3lxGDH/2HPxMPaQY+MjECKL8=
 k8s.io/apimachinery v0.20.0/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU=
 k8s.io/apimachinery v0.20.4/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU=
 k8s.io/apimachinery v0.21.0/go.mod h1:jbreFvJo3ov9rj7eWT7+sYiRx+qZuCYXwWT1bcDswPY=
+k8s.io/apimachinery v0.21.1 h1:Q6XuHGlj2xc+hlMCvqyYfbv3H7SRGn2c8NycxJquDVs=
 k8s.io/apiserver v0.18.8/go.mod h1:12u5FuGql8Cc497ORNj79rhPdiXQC4bf53X/skR/1YM=
 k8s.io/apiserver v0.20.4/go.mod h1:Mc80thBKOyy7tbvFtB4kJv1kbdD0eIH8k8vianJcbFM=
 k8s.io/cli-runtime v0.18.8 h1:ycmbN3hs7CfkJIYxJAOB10iW7BVPmXGXkfEyiV9NJ+k=

+ 4 - 0
internal/forms/k8s.go

@@ -44,3 +44,7 @@ type ConfigMapForm struct {
 	EnvVariables       map[string]string `json:"variables"`
 	SecretEnvVariables map[string]string `json:"secret_variables"`
 }
+
+type NamespaceForm struct {
+	Name string `json:"name" form:"required"`
+}

+ 24 - 0
internal/kubernetes/agent.go

@@ -233,6 +233,30 @@ func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
 	)
 }
 
+// CreateNamespace creates a namespace with the given name.
+func (a *Agent) CreateNamespace(name string) (*v1.Namespace, error) {
+	namespace := v1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: name,
+		},
+	}
+
+	return a.Clientset.CoreV1().Namespaces().Create(
+		context.TODO(),
+		&namespace,
+		metav1.CreateOptions{},
+	)
+}
+
+// DeleteNamespace deletes the namespace given the name.
+func (a *Agent) DeleteNamespace(name string) error {
+	return a.Clientset.CoreV1().Namespaces().Delete(
+		context.TODO(),
+		name,
+		metav1.DeleteOptions{},
+	)
+}
+
 // ListJobsByLabel lists jobs in a namespace matching a label
 type Label struct {
 	Key string

+ 113 - 0
server/api/k8s_handler.go

@@ -75,6 +75,119 @@ func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// HandleCreateNamespace creates a new namespace given the name.
+func (app *App) HandleCreateNamespace(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	fmt.Println(vals)
+	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)
+	}
+
+	ns := &forms.NamespaceForm{}
+
+	if err := json.NewDecoder(r.Body).Decode(ns); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	namespace, err := agent.CreateNamespace(ns.Name)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if err := json.NewEncoder(w).Encode(namespace); err != nil {
+		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
+// HandleDeleteNamespace deletes a namespace given the name.
+func (app *App) HandleDeleteNamespace(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)
+	}
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	namespace := &forms.NamespaceForm{}
+
+	if err := json.NewDecoder(r.Body).Decode(namespace); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	err = agent.DeleteNamespace(namespace.Name)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}
+
 // HandleListPodEvents retrieves all events tied to a pod.
 func (app *App) HandleListPodEvents(w http.ResponseWriter, r *http.Request) {
 	vals, err := url.ParseQuery(r.URL.RawQuery)

+ 28 - 0
server/router/router.go

@@ -1071,6 +1071,34 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"POST",
+				"/projects/{project_id}/k8s/namespaces/create",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleCreateNamespace, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
+
+			r.Method(
+				"DELETE",
+				"/projects/{project_id}/k8s/namespaces/delete",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleDeleteNamespace, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
+
 			r.Method(
 				"GET",
 				"/projects/{project_id}/k8s/kubeconfig",