Răsfoiți Sursa

gcp provisioning pre-provisioner blind integration

jusrhee 5 ani în urmă
părinte
comite
1c6f8dcb2f

+ 1 - 1
cli/cmd/registry.go

@@ -201,7 +201,7 @@ func listRepos(user *api.AuthCheckResponse, client *api.Client, args []string) e
 func listImages(user *api.AuthCheckResponse, client *api.Client, args []string) error {
 	pID := getProjectID()
 	rID := getRegistryID()
-	repoName := args[1]
+	repoName := args[0]
 
 	// get the list of namespaces
 	imgs, err := client.ListImages(

+ 102 - 0
dashboard/src/components/values-form/Base64InputRow.tsx

@@ -0,0 +1,102 @@
+import React, { ChangeEvent, Component } from 'react';
+import styled from 'styled-components';
+
+type PropsType = {
+  label?: string,
+  type: string,
+  value: string | number,
+  setValue: (x: string | number) => void,
+  unit?: string,
+  placeholder?: string,
+  width?: string,
+  disabled?: boolean,
+  isRequired?: boolean,
+};
+
+type StateType = {
+  readOnly: boolean
+};
+
+export default class InputRow extends Component<PropsType, StateType> {
+  state = {
+    readOnly: true
+  }
+
+  handleChange = (e: ChangeEvent<HTMLInputElement>) => {
+    this.props.setValue(e.target.value);
+  }
+  
+  render() {
+    let { label, value, type, unit, placeholder, width } = this.props;
+    if (type === 'b64') {
+      type = 'string-input';
+    } else if (type === 'b64-pass') {
+      type = 'password';
+    }
+    if (value === undefined) {
+        value = '';
+    }
+    value = value.toString();
+    value = atob(value);
+    return (
+      <StyledInputRow>
+        <Label>{label} <Required>{this.props.isRequired ? ' *' : null}</Required></Label>
+        <InputWrapper>
+          <Input
+            readOnly={this.state.readOnly} onFocus={() => this.setState({ readOnly: false })}
+            disabled={this.props.disabled}
+            placeholder={placeholder}
+            width={width}
+            type={type}
+            value={value}
+            onChange={this.handleChange}
+          />
+          {unit ? <Unit>{unit}</Unit> : null}
+        </InputWrapper>
+      </StyledInputRow>
+    );
+  }
+}
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+`;
+
+const Unit = styled.div`
+  margin-right: 8px;
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  margin-bottom: -1px;
+  align-items: center;
+`;
+
+const Input = styled.input`
+  outline: none;
+  border: none;
+  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;
+  margin-right: 8px;
+  height: 30px;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  font-family: 'Work Sans', sans-serif;
+`;
+
+const StyledInputRow = styled.div`
+  margin-bottom: 15px;
+  margin-top: 20px;
+`;

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

@@ -8,6 +8,7 @@ import api from '../../shared/api';
 
 import CheckboxRow from './CheckboxRow';
 import InputRow from './InputRow';
+import Base64InputRow from './Base64InputRow';
 import SelectRow from './SelectRow';
 import Helper from './Helper';
 import Heading from './Heading';
@@ -149,6 +150,40 @@ export default class ValuesForm extends Component<PropsType, StateType> {
             <VeleroForm
             />
           );
+        case 'base-64':
+          return (
+            <Base64InputRow
+              key={i}
+              isRequired={item.required}
+              type='b64'
+              value={this.getInputValue(item)}
+              setValue={(x: string) => {
+                if (item.settings && item.settings.unit && x !== '') {
+                  x = x + item.settings.unit;
+                }
+                this.props.setMetaState({ [key]: btoa(x) });
+              }}
+              label={item.label}
+              unit={item.settings ? item.settings.unit : null}
+            />
+          );
+        case 'base-64-password':
+          return (
+            <Base64InputRow
+              key={i}
+              isRequired={item.required}
+              type='b64-pass'
+              value={this.getInputValue(item)}
+              setValue={(x: string) => {
+                if (item.settings && item.settings.unit && x !== '') {
+                  x = x + item.settings.unit;
+                }
+                this.props.setMetaState({ [key]: btoa(x) });
+              }}
+              label={item.label}
+              unit={item.settings ? item.settings.unit : null}
+            />
+          );
         default:
       }
     });

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

@@ -468,7 +468,10 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         return
       }
       console.log(res.data)
-      this.setState({url: `http://${res.data?.status?.loadBalancer?.ingress[0]?.hostname}` })
+      
+      if (res.data?.status?.loadBalancer?.ingress) {
+        this.setState({url: `http://${res.data?.status?.loadBalancer?.ingress[0]?.hostname}` })
+      }
     })
   }
 

+ 3 - 3
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx

@@ -80,8 +80,8 @@ export default class ControllerTab extends Component<PropsType, StateType> {
   }
 
   getPodStatus = (status: any) => {
-    if (status?.phase == 'Pending' && status?.containerStatuses?.length > 0) {
-      return status?.containerStatuses[0].state.waiting.reason
+    if (status?.phase == 'Pending' && status?.containerStatuses) {
+      return status.containerStatuses[0].state.waiting.reason
       // return 'waiting'
     }
 
@@ -92,7 +92,7 @@ export default class ControllerTab extends Component<PropsType, StateType> {
     if (status?.phase == 'Running') {
       let collatedStatus = 'running';
 
-      status.containerStatuses.forEach((s: any) => {
+      status?.containerStatuses?.forEach((s: any) => {
         if (s.state?.waiting) {
           collatedStatus = 'waiting'
         } else if (s.state?.terminated) {

+ 115 - 12
dashboard/src/main/home/new-project/NewProject.tsx

@@ -26,6 +26,9 @@ type StateType = {
   awsRegion: string | null,
   awsAccessId: string | null,
   awsSecretKey: string | null,
+  gcpRegion: string | null,
+  gcpProjectId: string | null,
+  gcpKeyData: string | null,
   status: string | null,
 };
 
@@ -37,6 +40,9 @@ export default class NewProject extends Component<PropsType, StateType> {
     awsRegion: '' as string | null,
     awsAccessId: '' as string | null,
     awsSecretKey: '' as string | null,
+    gcpRegion: '' as string | null,
+    gcpProjectId: '' as string | null,
+    gcpKeyData: '' as string | null,
     status: null as string | null,
   }
 
@@ -52,11 +58,11 @@ export default class NewProject extends Component<PropsType, StateType> {
     this.setState({ selectedProvider: provider });
   }
 
-  renderTemplateList = () => {
+  renderProviderList = () => {
     return providers.map((provider: string, i: number) => {
       let providerInfo = integrationList[provider];
       return (
-        <Block 
+        <Block
           key={i} 
           onClick={() => this.handleSelectProvider(provider)}
         >
@@ -127,9 +133,41 @@ export default class NewProject extends Component<PropsType, StateType> {
           }}>
             <CloseButtonImg src={close} />
           </CloseButton>
-          <Flex>
-            GCP support is in closed beta. If you would like to run Porter in your own Google Cloud account, email <Highlight>contact@getporter.dev</Highlight>.
-          </Flex>
+          <DarkMatter />
+          <Heading>
+            GCP Credentials
+            <GuideButton href='https://docs.getporter.dev/docs/getting-started-with-porter-on-gcp' target='_blank'>
+              <i className="material-icons-outlined">help</i> 
+              Guide
+            </GuideButton>
+          </Heading>
+          <InputRow
+            type='text'
+            value={this.state.gcpRegion}
+            setValue={(x: string) => this.setState({ gcpRegion: x })}
+            label='📍 GCP Region'
+            placeholder='ex: us-central1-a'
+            width='100%'
+            isRequired={true}
+          />
+          <InputRow
+            type='text'
+            value={this.state.gcpProjectId}
+            setValue={(x: string) => this.setState({ gcpProjectId: x })}
+            label='🏷️ GCP Project ID'
+            placeholder='ex: pale-moon-24601'
+            width='100%'
+            isRequired={true}
+          />
+          <InputRow
+            type='password'
+            value={this.state.gcpKeyData}
+            setValue={(x: string) => this.setState({ gcpKeyData: x })}
+            label='🔒 GCP Key Data'
+            placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
+            width='100%'
+            isRequired={true}
+          />
         </FormSection>
       );
     } else if (this.state.selectedProvider === 'do') {
@@ -149,7 +187,7 @@ export default class NewProject extends Component<PropsType, StateType> {
 
     return (
       <BlockList>
-        {this.renderTemplateList()}
+        {this.renderProviderList()}
       </BlockList>
     );
   }
@@ -195,12 +233,23 @@ export default class NewProject extends Component<PropsType, StateType> {
   }
 
   validateForm = () => {
-    let { projectName, selectedProvider, awsAccessId, awsSecretKey, awsRegion } = this.state;
+    let { 
+      projectName,
+      selectedProvider, 
+      awsAccessId, 
+      awsSecretKey, 
+      awsRegion,
+      gcpRegion,
+      gcpKeyData,
+      gcpProjectId,
+    } = this.state;
     if (!this.isAlphanumeric(projectName) || projectName === '') {
       return false;
     } else if (selectedProvider === 'aws') {
       return awsAccessId !== '' && awsSecretKey !== '' && awsRegion !== '';
-    }  else if (selectedProvider === 'skipped') {
+    } else if (selectedProvider === 'gcp') {
+      return gcpRegion !== '' && gcpKeyData !== '' && gcpProjectId !== '';
+    } else if (selectedProvider === 'skipped') {
       return true;
     }
     return false;
@@ -265,13 +314,64 @@ export default class NewProject extends Component<PropsType, StateType> {
         }
 
         this.props.setCurrentView('provisioner', [
-          {infra_id: ecr?.data?.id, kind: ecr?.data?.kind},
-          {infra_id: eks?.data?.id, kind: eks?.data?.kind},
+          { infra_id: ecr?.data?.id, kind: ecr?.data?.kind },
+          { infra_id: eks?.data?.id, kind: eks?.data?.kind },
         ]);
       })
     })
   }
 
+  provisionGKE = (proj: ProjectType, id: number) => {
+    let clusterName = `${proj.name}-cluster`
+    console.log('provisioning gke...');
+    api.createGKE('<token>', {
+      gke_name: clusterName,
+      gcp_integration_id: id,
+    }, { project_id: proj.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else if (res?.data) {
+        
+        // TODO: set to provisioner
+        alert('success');
+      }
+    });
+  }
+
+  provisionGCR = (proj: ProjectType, id: number) => {
+    console.log('provisioning gcr...');
+    api.createGCR('<token>', {
+      gcp_integration_id: id,
+    }, { project_id: proj.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else if (res?.data) {
+        console.log('gcr provisioned with response: ', res.data);
+        this.provisionGKE(proj, id);
+      }
+    });
+  }
+
+  provisionGCP = (proj: ProjectType) => {
+    this.setState({ status: 'loading' });
+
+    let { gcpRegion, gcpKeyData, gcpProjectId } = this.state;
+    console.log('provisioning gcp...');
+    api.createGCPIntegration('<token>', {
+      gcp_region: gcpRegion,
+      gcp_key_data: gcpKeyData,
+      gcp_project_id: gcpProjectId,
+    }, { project_id: proj.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else if (res?.data) {
+        console.log('gcp provisioned with response: ', res.data);
+        let { id } = res.data;
+        this.provisionGCR(proj, id);
+      }
+    });
+  }
+
   createProject = () => {
     this.setState({ status: 'loading' });
     api.createProject('<token>', {
@@ -291,7 +391,9 @@ export default class NewProject extends Component<PropsType, StateType> {
               this.context.setCurrentProject(proj);
               
               if (this.state.selectedProvider === 'aws') {
-                this.provisionECR(proj, this.provisionEKS)
+                this.provisionECR(proj, this.provisionEKS);
+              } else if (this.state.selectedProvider === 'gcp') { 
+                this.provisionGCP(proj);
               } else {
                 this.props.setCurrentView('dashboard', null);
               }
@@ -406,8 +508,9 @@ export default class NewProject extends Component<PropsType, StateType> {
   }
   
   render() {
+    let { selectedProvider } = this.state;
     return (
-      <StyledNewProject height={this.state.selectedProvider === 'aws' ? '700px' : '600px'}>
+      <StyledNewProject height={selectedProvider === 'aws' || selectedProvider === 'gcp' ? '700px' : '600px'}>
         {this.renderHeaderSection()}
         {this.renderHostingSection()}
         {this.renderButton()}

+ 31 - 1
dashboard/src/shared/api.tsx

@@ -291,10 +291,40 @@ const deleteCluster = baseApi<{
   cluster_id: number,
 }>('DELETE', pathParams => {
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}`;
-})
+});
+
+const createGCPIntegration = baseApi<{
+  gcp_region: string,
+  gcp_key_data: string,
+  gcp_project_id: string,
+}, {
+  project_id: number,
+}>('POST', pathParams => {
+  return `/api/projects/${pathParams.project_id}/integrations/gcp`;
+});
+
+const createGCR = baseApi<{
+  gcp_integration_id: number,
+}, {
+  project_id: number,
+}>('POST', pathParams => {
+  return `/api/projects/${pathParams.project_id}/provision/gcr`;
+});
+
+const createGKE = baseApi<{
+  gcp_integration_id: number,
+  gke_name: string,
+}, {
+  project_id: number,
+}>('POST', pathParams => {
+  return `/api/projects/${pathParams.project_id}/provision/gke`;
+});
 
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
+  createGCR,
+  createGKE,
+  createGCPIntegration,
   deleteCluster,
   destroyCluster,
   getInfra,

+ 6 - 1
internal/kubernetes/provisioner/provisioner.go

@@ -69,8 +69,13 @@ func (conf *Conf) GetProvisionerJobTemplate() (*batchv1.Job, error) {
 	env = conf.attachDefaultEnv(env)
 
 	ttl := int32(3600)
+
 	backoffLimit := int32(5)
 
+	if operation == string(Apply) {
+		backoffLimit = int32(1)
+	}
+
 	labels := map[string]string{
 		"app": "provisioner",
 	}
@@ -103,7 +108,7 @@ func (conf *Conf) GetProvisionerJobTemplate() (*batchv1.Job, error) {
 					Labels: labels,
 				},
 				Spec: v1.PodSpec{
-					RestartPolicy: v1.RestartPolicyOnFailure,
+					RestartPolicy: v1.RestartPolicyNever,
 					Containers: []v1.Container{
 						{
 							Name:  "provisioner",

+ 4 - 19
internal/registry/registry.go

@@ -103,14 +103,8 @@ func (r *Registry) listGCRRepositories(
 		return nil, err
 	}
 
-	// get oauth2 access token
-	oauthTok, err := gcp.GetBearerToken(r.getTokenCache, r.setTokenCacheFunc(repo))
-
-	if err != nil {
-		return nil, err
-	}
-
-	// use JWT token to request catalog
+	// Just use service account key to authenticate, since scopes may not be in place
+	// for oauth. This also prevents us from making more requests.
 	client := &http.Client{}
 
 	req, err := http.NewRequest(
@@ -123,9 +117,7 @@ func (r *Registry) listGCRRepositories(
 		return nil, err
 	}
 
-	req.SetBasicAuth("oauth2accesstoken", oauthTok)
-
-	// req.Header.Add("Authorization", "Bearer "+jwtTok)
+	req.SetBasicAuth("_json_key", string(gcp.GCPKeyData))
 
 	resp, err := client.Do(req)
 
@@ -279,13 +271,6 @@ func (r *Registry) listGCRImages(repoName string, repo repository.Repository) ([
 		return nil, err
 	}
 
-	// get oauth2 access token
-	oauthTok, err := gcp.GetBearerToken(r.getTokenCache, r.setTokenCacheFunc(repo))
-
-	if err != nil {
-		return nil, err
-	}
-
 	// use JWT token to request catalog
 	client := &http.Client{}
 
@@ -299,7 +284,7 @@ func (r *Registry) listGCRImages(repoName string, repo repository.Repository) ([
 		return nil, err
 	}
 
-	req.SetBasicAuth("oauth2accesstoken", oauthTok)
+	req.SetBasicAuth("_json_key", string(gcp.GCPKeyData))
 
 	resp, err := client.Do(req)