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

Merge pull request #165 from porter-dev/beta.3.integration-frontend

Beta.3.integration frontend
jusrhee 5 лет назад
Родитель
Сommit
ec881e5bb6
58 измененных файлов с 2751 добавлено и 823 удалено
  1. 19 3
      cmd/app/main.go
  2. 1 0
      cmd/migrate/main.go
  3. BIN
      dashboard/src/assets/aws-normal.png
  4. BIN
      dashboard/src/assets/aws-white.png
  5. BIN
      dashboard/src/assets/aws.png
  6. BIN
      dashboard/src/assets/do.png
  7. BIN
      dashboard/src/assets/gcp.png
  8. 5 0
      dashboard/src/components/SaveButton.tsx
  9. 1 3
      dashboard/src/components/Selector.tsx
  10. 3 1
      dashboard/src/components/values-form/Heading.tsx
  11. 9 20
      dashboard/src/components/values-form/InputRow.tsx
  12. 6 1
      dashboard/src/main/Main.tsx
  13. 59 39
      dashboard/src/main/home/Home.tsx
  14. 8 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  15. 2 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  16. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  17. 5 1
      dashboard/src/main/home/dashboard/Dashboard.tsx
  18. 0 202
      dashboard/src/main/home/modals/CreateProjectModal.tsx
  19. 22 5
      dashboard/src/main/home/modals/UpdateProjectModal.tsx
  20. 255 0
      dashboard/src/main/home/navbar/Feedback.tsx
  21. 214 0
      dashboard/src/main/home/navbar/Navbar.tsx
  22. 546 0
      dashboard/src/main/home/new-project/NewProject.tsx
  23. 164 0
      dashboard/src/main/home/new-project/Provisioner.tsx
  24. 15 44
      dashboard/src/main/home/sidebar/ProjectSection.tsx
  25. 3 0
      dashboard/src/main/home/sidebar/ProjectSectionContainer.tsx
  26. 24 32
      dashboard/src/main/home/sidebar/Sidebar.tsx
  27. 11 2
      dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx
  28. 16 0
      dashboard/src/shared/Context.tsx
  29. 16 0
      dashboard/src/shared/common.tsx
  30. 7 0
      docker-compose.dev.yaml
  31. 20 18
      go.mod
  32. 63 339
      go.sum
  33. 22 0
      internal/adapter/redis.go
  34. 1 0
      internal/config/config.go
  35. 7 0
      internal/config/redis.go
  36. 0 2
      internal/forms/helper_test.go
  37. 23 0
      internal/forms/infra.go
  38. 72 1
      internal/kubernetes/agent.go
  39. 30 0
      internal/kubernetes/provisioner/aws/aws.go
  40. 18 0
      internal/kubernetes/provisioner/aws/ecr/ecr.go
  41. 18 0
      internal/kubernetes/provisioner/aws/eks/eks.go
  42. 133 0
      internal/kubernetes/provisioner/global_stream.go
  43. 236 0
      internal/kubernetes/provisioner/provisioner.go
  44. 63 0
      internal/kubernetes/provisioner/resource_stream.go
  45. 91 0
      internal/models/infra.go
  46. 31 15
      internal/models/integrations/aws.go
  47. 3 0
      internal/models/project.go
  48. 0 4
      internal/repository/gorm/auth_test.go
  49. 40 18
      internal/repository/gorm/helpers_test.go
  50. 75 0
      internal/repository/gorm/infra.go
  51. 90 0
      internal/repository/gorm/infra_test.go
  52. 1 0
      internal/repository/gorm/repository.go
  53. 13 0
      internal/repository/infra.go
  54. 91 0
      internal/repository/memory/infra.go
  55. 1 0
      internal/repository/repository.go
  56. 17 70
      server/api/api.go
  57. 147 0
      server/api/provision_handler.go
  58. 33 0
      server/router/router.go

+ 19 - 3
cmd/app/main.go

@@ -15,6 +15,7 @@ import (
 	lr "github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/server/router"
 
+	prov "github.com/porter-dev/porter/internal/kubernetes/provisioner"
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 )
 
@@ -29,6 +30,14 @@ func main() {
 		return
 	}
 
+	redis, err := adapter.NewRedisClient(&appConf.Redis)
+	prov.InitGlobalStream(redis)
+
+	if err != nil {
+		logger.Fatal().Err(err).Msg("")
+		return
+	}
+
 	err = db.AutoMigrate(
 		&models.Project{},
 		&models.Role{},
@@ -40,6 +49,7 @@ func main() {
 		&models.Cluster{},
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
+		&models.AWSInfra{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
@@ -66,9 +76,10 @@ func main() {
 	repo := gorm.NewRepository(db, &key)
 
 	a, _ := api.New(&api.AppConfig{
-		Logger:     logger,
-		Repository: repo,
-		ServerConf: appConf.Server,
+		Logger:      logger,
+		Repository:  repo,
+		ServerConf:  appConf.Server,
+		RedisClient: redis,
 	})
 
 	appRouter := router.New(a)
@@ -85,7 +96,12 @@ func main() {
 		IdleTimeout:  appConf.Server.TimeoutIdle,
 	}
 
+	errorChan := make(chan error)
+
+	go prov.GlobalStreamListener(redis, repo.AWSInfra, errorChan)
+
 	if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
 		log.Fatal("Server startup failed", err)
 	}
+
 }

+ 1 - 0
cmd/migrate/main.go

@@ -36,6 +36,7 @@ func main() {
 		&models.Cluster{},
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
+		&models.AWSInfra{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

BIN
dashboard/src/assets/aws-normal.png


BIN
dashboard/src/assets/aws-white.png


BIN
dashboard/src/assets/aws.png


BIN
dashboard/src/assets/do.png


BIN
dashboard/src/assets/gcp.png


+ 5 - 0
dashboard/src/components/SaveButton.tsx

@@ -8,6 +8,7 @@ type PropsType = {
   disabled?: boolean,
   status?: string | null,
   color?: string,
+  helper?: string | null,
 
   // Makes flush with corner if not within a modal
   makeFlush?: boolean 
@@ -45,6 +46,10 @@ export default class SaveButton extends Component<PropsType, StateType> {
           </StatusWrapper>
         );
       }
+    } else if (this.props.helper) {
+      return (
+        <StatusWrapper successful={true}>{this.props.helper}</StatusWrapper>
+      );
     }
   }
 

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

@@ -45,9 +45,7 @@ export default class Selector extends Component<PropsType, StateType> {
   renderDropdownLabel = () => {
     if (this.props.dropdownLabel && this.props.dropdownLabel !== '') {
       return (
-        <DropdownLabel>
-          {this.props.dropdownLabel}
-        </DropdownLabel>
+        <DropdownLabel>{this.props.dropdownLabel}</DropdownLabel>
       );
     }
   }

+ 3 - 1
dashboard/src/components/values-form/Heading.tsx

@@ -1,7 +1,7 @@
 import React from 'react';  
 import styled from 'styled-components';
 
-export default function Heading(props: { children: string }) {
+export default function Heading(props: { children: any }) {
   return <StyledHeading>{props.children}</StyledHeading>;
 }
 
@@ -11,4 +11,6 @@ const StyledHeading = styled.div`
   font-size: 16px;
   margin-top: 30px;
   margin-bottom: 5px;
+  display: flex;
+  align-items: center;
 `;

+ 9 - 20
dashboard/src/components/values-form/InputRow.tsx

@@ -29,22 +29,12 @@ export default class InputRow extends Component<PropsType, StateType> {
       this.props.setValue(e.target.value);
     }
   }
-
-  renderRequiredWarning = () => {
-    if (this.props.isRequired && this.props.value === '') {
-      return (
-        <Warning>
-          <i className="material-icons">error_outline</i>
-        </Warning>
-      );
-    }
-  }
   
   render() {
     let { label, value, type, unit, placeholder, width } = this.props;
     return (
       <StyledInputRow>
-        <Label>{label} {this.props.isRequired ? ' *' : null}</Label>
+        <Label>{label} <Required>{this.props.isRequired ? ' *' : null}</Required></Label>
         <InputWrapper>
           <Input
             readOnly={this.state.readOnly} onFocus={() => this.setState({ readOnly: false })}
@@ -56,27 +46,24 @@ export default class InputRow extends Component<PropsType, StateType> {
             onChange={this.handleChange}
           />
           {unit ? <Unit>{unit}</Unit> : null}
-          {this.renderRequiredWarning()}
         </InputWrapper>
       </StyledInputRow>
     );
   }
 }
 
-const Unit = styled.div`
-  margin-right: 8px;
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
 `;
 
-const Warning = styled.div`
-  margin-bottom: -3px;
-  > i {
-    font-size: 18px;
-    color: #fcba03;
-  }
+const Unit = styled.div`
+  margin-right: 8px;
 `;
 
 const InputWrapper = styled.div`
   display: flex;
+  margin-bottom: -1px;
   align-items: center;
 `;
 
@@ -97,6 +84,8 @@ const Input = styled.input`
 const Label = styled.div`
   color: #ffffff;
   margin-bottom: 10px;
+  display: flex;
+  align-items: center;
   font-size: 13px;
   font-family: 'Work Sans', sans-serif;
 `;

+ 6 - 1
dashboard/src/main/Main.tsx

@@ -54,6 +54,11 @@ export default class Main extends Component<PropsType, StateType> {
     this.setState({ isLoggedIn: true, initialized: true });
   }
 
+  handleLogOut = () => {
+    this.context.clearContext();
+    this.setState({ isLoggedIn: false, initialized: true });
+  }
+
   renderMain = () => {
     if (this.state.loading) {
       return <Loading />
@@ -79,7 +84,7 @@ export default class Main extends Component<PropsType, StateType> {
 
         <Route path='/dashboard' render={() => {
           if (this.state.isLoggedIn && this.state.initialized) {
-            return <Home logOut={() => this.setState({ isLoggedIn: false, initialized: true })} />
+            return <Home logOut={this.handleLogOut} />
           } else {
             return <Redirect to='/' />
           }

+ 59 - 39
dashboard/src/main/home/Home.tsx

@@ -4,6 +4,7 @@ import ReactModal from 'react-modal';
 
 import { Context } from '../../shared/Context';
 import api from '../../shared/api';
+import { ProjectType } from '../../shared/types';
 
 import Sidebar from './sidebar/Sidebar';
 import Dashboard from './dashboard/Dashboard';
@@ -11,11 +12,13 @@ import ClusterDashboard from './cluster-dashboard/ClusterDashboard';
 import Loading from '../../components/Loading';
 import Templates from './templates/Templates';
 import Integrations from "./integrations/Integrations";
-import CreateProjectModal from './modals/CreateProjectModal';
 import UpdateProjectModal from './modals/UpdateProjectModal';
 import ClusterInstructionsModal from './modals/ClusterInstructionsModal';
 import IntegrationsModal from './modals/IntegrationsModal';
 import IntegrationsInstructionsModal from './modals/IntegrationsInstructionsModal';
+import NewProject from './new-project/NewProject';
+import Navbar from './navbar/Navbar';
+import Provisioner from './new-project/Provisioner';
 
 type PropsType = {
   logOut: () => void
@@ -27,15 +30,36 @@ type StateType = {
   currentView: string,
 
   // Track last project id for refreshing clusters on project change
-  prevProjectId: number | null
+  prevProjectId: number | null,
 };
 
 export default class Home extends Component<PropsType, StateType> {
   state = {
     forceSidebar: true,
     showWelcome: false,
-    currentView: 'cluster-dashboard',
-    prevProjectId: null as number | null
+    currentView: 'dashboard',
+    prevProjectId: null as number | null,
+  }
+
+  // Possibly consolidate into context (w/ ProjectSection + NewProject)
+  getProjects = () => {
+    let { user, currentProject, projects, setProjects } = this.context;
+    api.getProjects('<token>', {}, { id: user.userId }, (err: any, res: any) => {
+      if (err) {
+        console.log(err)
+      } else if (res.data) {
+        setProjects(res.data);
+        if (res.data.length > 0 && !currentProject) {
+          this.context.setCurrentProject(res.data[0]);
+        } else if (res.data.length === 0) {
+          this.setState({ currentView: 'new-project' });
+        }
+      }
+    });
+  }
+
+  componentDidMount() {
+    this.getProjects();
   }
 
   componentDidUpdate(prevProps: PropsType) {
@@ -91,11 +115,17 @@ export default class Home extends Component<PropsType, StateType> {
     } else if (currentView === 'dashboard') {
       return (
         <DashboardWrapper>
-          <Dashboard />
+          <Dashboard setCurrentView={(x: string) => this.setState({ currentView: x })} />
         </DashboardWrapper>
       );
     } else if (currentView === 'integrations') {
       return <Integrations />;
+    } else if (currentView === 'new-project') {
+      return (
+        <NewProject setCurrentView={(x: string) => this.setState({ currentView: x })} />
+      );
+    } else if (currentView === 'provisioner') {
+      return <Provisioner />
     }
 
     return (
@@ -105,18 +135,29 @@ export default class Home extends Component<PropsType, StateType> {
     );
   }
 
+  renderSidebar = () => {
+    if (this.context.projects.length > 0) {
+
+      // Force sidebar closed on first provision 
+      if (this.state.currentView === 'provisioner' && this.state.forceSidebar) {
+        this.setState({ forceSidebar: false });
+      }
+      
+      return (
+        <Sidebar
+          forceSidebar={this.state.forceSidebar}
+          setWelcome={(x: boolean) => this.setState({ showWelcome: x })}
+          setCurrentView={(x: string) => this.setState({ currentView: x })}
+          currentView={this.state.currentView}
+        />
+      );
+    }
+  }
+
   render() {
     let { currentModal, setCurrentModal, currentProject } = this.context;
     return (
       <StyledHome>
-        <ReactModal
-          isOpen={currentModal === 'CreateProjectModal'}
-          onRequestClose={() => currentProject ? setCurrentModal(null, null) : null }
-          style={ProjectModalStyles}
-          ariaHideApp={false}
-        >
-          <CreateProjectModal />
-        </ReactModal>
         <ReactModal
           isOpen={currentModal === 'ClusterInstructionsModal'}
           onRequestClose={() => setCurrentModal(null, null)}
@@ -150,15 +191,13 @@ export default class Home extends Component<PropsType, StateType> {
           <IntegrationsInstructionsModal />
         </ReactModal>
 
-        <Sidebar
-          logOut={this.props.logOut}
-          forceSidebar={this.state.forceSidebar}
-          setWelcome={(x: boolean) => this.setState({ showWelcome: x })}
-          setCurrentView={(x: string) => this.setState({ currentView: x })}
-          currentView={this.state.currentView}
-        />
+        {this.renderSidebar()}
 
         <ViewWrapper>
+          <Navbar 
+            logOut={this.props.logOut} 
+            currentView={this.state.currentView} // For form feedback
+          />
           {this.renderContents()}
         </ViewWrapper>
       </StyledHome>
@@ -168,25 +207,6 @@ export default class Home extends Component<PropsType, StateType> {
 
 Home.contextType = Context;
 
-const MediumModalStyles = {
-  overlay: {
-    backgroundColor: 'rgba(0,0,0,0.6)',
-    zIndex: 2,
-  },
-  content: {
-    borderRadius: '7px',
-    border: 0,
-    width: '760px',
-    maxWidth: '80vw',
-    margin: '0 auto',
-    height: '575px',
-    top: 'calc(50% - 289px)',
-    backgroundColor: '#202227',
-    animation: 'floatInModal 0.5s 0s',
-    overflow: 'visible',
-  },
-};
-
 const SmallModalStyles = {
   overlay: {
     backgroundColor: 'rgba(0,0,0,0.6)',

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

@@ -586,6 +586,14 @@ const CloseOverlay = styled.div`
   left: 0;
   width: 100%;
   height: 100%;
+  background: #202227;
+  animation: fadeIn 0.2s 0s;
+  opacity: 0;
+  animation-fill-mode: forwards;
+  @keyframes fadeIn {
+    from { opacity: 0 }
+    to { opacity: 1 }
+  }
 `;
 
 const HeaderWrapper = styled.div`

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

@@ -237,7 +237,7 @@ const Webhook = styled.div`
 `;
 
 const Highlight = styled.div`
-  color: #949eff;
+  color: #8590ff;
   text-decoration: underline;
   margin-left: 5px;
   cursor: pointer;
@@ -245,7 +245,7 @@ const Highlight = styled.div`
 `;
 
 const A = styled.a`
-  color: #949eff;
+  color: #8590ff;
   text-decoration: underline;
   margin-left: 5px;
   cursor: pointer;

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

@@ -43,7 +43,7 @@ export default class Logs extends Component<PropsType, StateType> {
     if (!selectedPod.metadata?.name) return
     let protocol = process.env.NODE_ENV == 'production' ? 'wss' : 'ws'
     let ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`)
-
+    // let ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/provisioning/ecr/abcdef/logs?cluster_id=${currentCluster.id}`)
     this.setState({ ws }, () => {
       if (!this.state.ws) return;
   

+ 5 - 1
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -6,6 +6,7 @@ import { Context } from '../../../shared/Context';
 import PipelinesSection from './PipelinesSection';
 
 type PropsType = {
+  setCurrentView: (x: string) => void,
 };
 
 type StateType = {
@@ -32,7 +33,10 @@ export default class Dashboard extends Component<PropsType, StateType> {
             <Title>{currentProject && currentProject.name}</Title>
             <i
               className="material-icons"
-              onClick={() => this.context.setCurrentModal('UpdateProjectModal', { currentProject: currentProject })}
+              onClick={() => this.context.setCurrentModal('UpdateProjectModal', { 
+                currentProject: currentProject,
+                setCurrentView: this.props.setCurrentView,
+              })}
             >
               more_vert
           </i>

+ 0 - 202
dashboard/src/main/home/modals/CreateProjectModal.tsx

@@ -1,202 +0,0 @@
-import React, { Component } from 'react';
-import styled from 'styled-components';
-import close from '../../../assets/close.png';
-import gradient from '../../../assets/gradient.jpg';
-
-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 = {
-  projectName: string,
-  status: string | null
-};
-
-export default class CreateProjectModal extends Component<PropsType, StateType> {
-  state = {
-    projectName: '',
-    status: null as string | null,
-  };
-  
-  componentDidMount() {
-
-  }
-
-  createProject = () => {
-    this.setState({ status: 'loading' });
-    api.createProject('<token>', {
-      name: this.state.projectName
-    }, {}, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-      } else {
-        this.context.currentModalData.updateProjects();
-        this.context.setCurrentModal(null, null);
-      }
-    });
-  }
-
-  renderCloseButton = () => {
-    if (this.context.currentModalData && !this.context.currentModalData.keepOpen) {
-      return (
-        <CloseButton onClick={() => {
-          this.context.setCurrentModal(null, null);
-        }}>
-          <CloseButtonImg src={close} />
-        </CloseButton>
-      );
-    }
-  }
-
-  isAlphanumeric = (x: string) => {
-    let re = /^[a-z0-9-]+$/;
-    if (x.length == 0 || x.search(re) === -1) {
-      return false;
-    }
-    return true;
-  }
-
-  render() {
-    return (
-      <StyledCreateProjectModal>
-        {this.renderCloseButton()}
-
-        <ModalTitle>New Project</ModalTitle>
-        <Subtitle>
-          Project name
-          <Warning highlight={!this.isAlphanumeric(this.state.projectName) && this.state.projectName !== ''}>
-            (lowercase letters, numbers, and "-" only)
-          </Warning>
-        </Subtitle>
-
-        <InputWrapper>
-          <ProjectIcon>
-            <ProjectImage src={gradient} />
-            <Letter>{this.state.projectName ? this.state.projectName[0].toUpperCase() : '-'}</Letter>
-          </ProjectIcon>
-          <InputRow
-            type='string'
-            value={this.state.projectName}
-            setValue={(x: string) => this.setState({ projectName: x })}
-            placeholder='ex: perspective-vortex'
-            width='470px'
-          />
-        </InputWrapper>
-
-        <SaveButton
-          text='Create'
-          disabled={!this.isAlphanumeric(this.state.projectName) || this.state.projectName === ''}
-          onClick={this.createProject}
-          status={this.state.status}
-        />
-      </StyledCreateProjectModal>
-      );
-  }
-}
-
-CreateProjectModal.contextType = Context;
-
-const Warning = styled.span`
-  color: ${(props: { highlight: boolean }) => props.highlight ? '#f5cb42' : ''};
-  margin-left: 5px;
-`;
-
-const Letter = styled.div`
-  height: 100%;
-  width: 100%;
-  position: absolute;
-  background: #00000028;
-  top: 0;
-  left: 0;
-  display: flex;
-  color: white;
-  align-items: center;
-  justify-content: center;
-`;
-
-const ProjectImage = styled.img`
-  width: 100%;
-  height: 100%;
-`;
-
-const ProjectIcon = styled.div`
-  width: 25px;
-  min-width: 25px;
-  height: 25px;
-  border-radius: 3px;
-  overflow: hidden;
-  position: relative;
-  margin-right: 10px;
-  font-weight: 400;
-  margin-top: 14px;
-`;
-
-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 StyledCreateProjectModal= styled.div`
-  width: 100%;
-  position: absolute;
-  left: 0;
-  top: 0;
-  height: 100%;
-  padding: 25px 32px;
-  overflow: hidden;
-  border-radius: 6px;
-  background: #202227;
-`;

+ 22 - 5
dashboard/src/main/home/modals/UpdateProjectModal.tsx

@@ -25,6 +25,24 @@ export default class UpdateProjectModal extends Component<PropsType, StateType>
     status: null as string | null,
     showDeleteOverlay: false,
   };
+
+  // Possibly consolidate into context (w/ ProjectSection + NewProject)
+  getProjects = () => {
+    let { user, currentProject, projects, setProjects } = this.context;
+    api.getProjects('<token>', {}, { id: user.userId }, (err: any, res: any) => {
+      if (err) {
+        console.log(err)
+      } else if (res.data) {
+        setProjects(res.data);
+        if (res.data.length > 0) {
+          this.context.setCurrentProject(res.data[0]);
+        } else {
+          this.context.currentModalData.setCurrentView('new-project');
+        }
+        this.context.setCurrentModal(null, null);
+      }
+    });
+  }
   
   // TODO: Handle update to unmounted component
   handleDelete = () => {
@@ -35,8 +53,7 @@ export default class UpdateProjectModal extends Component<PropsType, StateType>
         this.setState({ status: 'error' });
         // console.log(err)
       } else {
-        this.context.setCurrentModal(null, null);
-        this.context.setCurrentProject(null);
+        this.getProjects();
         this.setState({ status: 'successful', showDeleteOverlay: false });
       }
     });
@@ -44,7 +61,7 @@ export default class UpdateProjectModal extends Component<PropsType, StateType>
 
   render() {
     return (
-      <StyledCreateProjectModal>
+      <StyledUpdateProjectModal>
         <CloseButton onClick={() => {
           this.context.setCurrentModal(null, null);
         }}>
@@ -84,7 +101,7 @@ export default class UpdateProjectModal extends Component<PropsType, StateType>
           onYes={this.handleDelete}
           onNo={() => this.setState({ showDeleteOverlay: false })}
         />
-      </StyledCreateProjectModal>
+      </StyledUpdateProjectModal>
       );
   }
 }
@@ -174,7 +191,7 @@ const CloseButtonImg = styled.img`
   margin: 0 auto;
 `;
 
-const StyledCreateProjectModal= styled.div`
+const StyledUpdateProjectModal= styled.div`
   width: 100%;
   position: absolute;
   left: 0;

+ 255 - 0
dashboard/src/main/home/navbar/Feedback.tsx

@@ -0,0 +1,255 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import axios from 'axios';
+import { Context } from '../../../shared/Context';
+
+type PropsType = {
+  currentView: string,
+};
+
+type StateType = {
+  feedbackSent: boolean,
+  showFeedbackDropdown: boolean,
+  feedbackText: string,
+};
+
+export default class Feedback extends Component<PropsType, StateType> {
+  state = {
+    feedbackSent: false,
+    showFeedbackDropdown: false,
+    feedbackText: '',
+  }
+
+  renderReceipt = () => {
+    if (this.state.feedbackSent) {
+      return (
+        <DropdownAlt dropdownWidth='300px' dropdownMaxHeight='200px'>
+          <ConfirmationMessage>
+            <i className="material-icons-outlined">emoji_food_beverage</i>
+            Thanks for improving Porter.
+          </ConfirmationMessage>
+        </DropdownAlt>
+      );
+    }
+  }
+
+  handleSubmitFeedback = () => {
+    let { user } = this.context;
+    let msg = '👤 ' + user.email + ' 📍 ' + this.props.currentView + ': ' + this.state.feedbackText;
+    axios.post('http://35.190.59.124/feedback', {
+      key: 'uzNP7MVYqDC7hs9Q8YP7ehvsBO4yRO02ZGYQ5rKJ2YngEqgYVBITRsvDww8CfV3q',
+      cid: '794372152769642507',
+      message: msg,
+    }, {
+      headers: {
+        Authorization: `Bearer <>`
+      }
+    })
+    .then(res => {
+      console.log('feedback sent');
+    })
+    .catch(err => {
+      console.log(err);
+    });
+    this.setState({ feedbackSent: true, feedbackText: '' });
+  }
+
+  renderFeedbackDropdown = () => {
+    if (this.state.showFeedbackDropdown) {
+      let disabled = this.state.feedbackText === '';
+      return (
+        <>
+          <CloseOverlay onClick={() => this.setState({ showFeedbackDropdown: false, feedbackSent: false })} />
+          <Dropdown 
+            feedbackSent={this.state.feedbackSent} 
+            dropdownWidth='300px' 
+            dropdownMaxHeight='200px'
+          >
+            <FeedbackInput 
+              autoFocus={true}
+              value={this.state.feedbackText}
+              onChange={(e) => this.setState({ feedbackText: e.target.value })}
+              placeholder='Help us improve this page.' 
+            />
+            <SendButton 
+              disabled={disabled} 
+              onClick={() => !disabled && this.handleSubmitFeedback()}
+            >
+              <i className="material-icons">send</i> Send
+            </SendButton>
+          </Dropdown>
+          {this.renderReceipt()}
+        </>
+      );
+    }
+  }
+
+  render() {
+    return (
+      <FeedbackButton>
+        <Flex onClick={() => this.setState({ showFeedbackDropdown: !this.state.showFeedbackDropdown })}>
+          <i className="material-icons-outlined">
+            campaign
+          </i>
+          Feedback?
+        </Flex>
+        {this.renderFeedbackDropdown()}
+      </FeedbackButton>
+    );
+  }
+}
+
+Feedback.contextType = Context;
+
+const CloseOverlay = styled.div`
+  position: fixed;
+  width: 100vw;
+  height: 100vh;
+  z-index: 100;
+  top: 0;
+  left: 0;
+  cursor: default;
+`;
+
+const ConfirmationMessage = styled.div`
+  width: 100%;
+  height: 100px;
+  display: flex;
+  font-size: 13px;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff55;
+
+  > i {
+    display: flex;
+    font-size: 16px;
+    margin-right: 10px;
+    align-items: center;
+    justify-content: center;
+    color: #ffffff55;
+  }
+`;
+
+const SendButton = styled.div`
+  display: flex;
+  align-items: center;
+  height: 40px;
+  cursor: ${(props: { disabled: boolean }) => props.disabled ? 'not-allowed' : 'pointer'};
+  justify-content: center;
+  margin-top: -3px;
+  font-size: 13px;
+  font-weight: 500;
+  font-family: 'Work Sans', sans-serif;
+  :hover {
+    background: ${(props: { disabled: boolean }) => props.disabled ? '' : '#ffffff11'};
+  }
+
+  > i {
+    background: none;
+    border-radius: 3px;
+    display: flex;
+    font-size: 14px;
+    top: 11px;
+    margin-right: 10px;
+    padding: 1px;
+    align-items: center;
+    justify-content: center;
+    color: #ffffffaa;
+  }
+`;
+
+const FeedbackInput = styled.textarea`
+  resize: none;
+  width: 100%;
+  height: 80px;
+  outline: 0;
+  padding: 14px;
+  color: white;
+  border: 0;
+  font-size: 13px;
+  font-family: 'Work Sans', sans-serif;
+  background: #aaaabb11;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+`;
+
+const Dropdown = styled.div`
+  position: absolute;
+  right: 0;
+  top: calc(100% + 5px);
+  background: #26282f;
+  width: ${(props: { dropdownWidth: string, dropdownMaxHeight: string, feedbackSent?: boolean }) => props.dropdownWidth};
+  max-height: ${(props: { dropdownWidth: string, dropdownMaxHeight: string, feedbackSent?: boolean }) => props.dropdownMaxHeight ? props.dropdownMaxHeight : '300px'};
+  border-radius: 3px;
+  z-index: 999;
+  overflow-y: auto;
+  margin-bottom: 20px;
+  box-shadow: 0 8px 20px 0px #00000088;
+  animation: ${(props: { dropdownWidth: string, dropdownMaxHeight: string, feedbackSent?: boolean }) => props.feedbackSent ? 'flyOff 0.3s 0.05s' : ''};
+  animation-fill-mode: forwards;
+  @keyframes flyOff {
+    from {
+      opacity: 1; transform: translateX(0px);
+    }
+    to {
+      opacity: 0; transform: translateX(100px);
+    }
+  }
+`;
+
+const DropdownAlt = styled(Dropdown)`
+  animation: fadeIn 0.3s 0.5s;
+  opacity: 0;
+  animation-fill-mode: forwards;
+  @keyframes fadeIn {
+    from { opacity: 0 }
+    to { opacity: 1 }
+  }
+`;
+
+const NavButton = styled.a`
+  display: flex;
+  position: relative;
+  align-items: center;
+  justify-content: center;
+  margin-right: 15px;
+  :hover {
+    > i {
+      color: #ffffff;
+    }
+  }
+  
+  > i {
+    cursor: pointer;
+    color: ${(props: { selected?: boolean }) => props.selected ? '#ffffff' : '#ffffff88'};
+    font-size: 24px;
+  }
+`;
+
+const FeedbackButton = styled(NavButton)`
+  color: ${(props: { selected?: boolean }) => props.selected ? '#ffffff' : '#ffffff88'};
+  font-family: 'Work Sans', sans-serif;
+  font-size: 14px;
+  margin-right: 20px;
+  :hover {
+    color: #ffffff;
+    > div {
+      > i {
+        color: #ffffff;
+      }
+    }
+  }
+
+  > div {
+    > i {
+      color: ${(props: { selected?: boolean }) => props.selected ? '#ffffff' : '#ffffff88'};
+      font-size: 26px;
+      margin-right: 6px;
+    }
+  }
+`;

+ 214 - 0
dashboard/src/main/home/navbar/Navbar.tsx

@@ -0,0 +1,214 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import api from '../../../shared/api';
+import { Context } from '../../../shared/Context';
+
+import Feedback from './Feedback';
+
+type PropsType = {
+  logOut: () => void,
+  currentView: string,
+};
+
+type StateType = {
+  showDropdown: boolean,
+};
+
+export default class Navbar extends Component<PropsType, StateType> {
+  state = {
+    showDropdown: false,
+  }
+
+  handleLogout = (): void => {
+    let { logOut } = this.props;
+    let { setCurrentError } = this.context;
+
+    // Attempt user logout
+    api.logOutUser('<token>', {}, {}, (err: any, res: any) => {
+      err ? setCurrentError(err.response.data.errors[0]) : logOut();
+    }); 
+  }
+
+  renderSettingsDropdown = () => {
+    if (this.state.showDropdown) {
+      return (
+        <>
+          <CloseOverlay onClick={() => this.setState({ showDropdown: false })} />
+          <Dropdown dropdownWidth='250px' dropdownMaxHeight='200px'>
+            <DropdownLabel>{this.context.user && this.context.user.email}</DropdownLabel>
+            <LogOutButton onClick={this.handleLogout}>
+              <i className="material-icons">keyboard_return</i> Log Out
+            </LogOutButton>
+          </Dropdown>
+        </>
+      );
+    }
+  }
+
+  render() {
+    return (
+      <StyledNavbar>
+        <Feedback currentView={this.props.currentView} />
+        <NavButton selected={this.state.showDropdown}>
+          <i 
+            className="material-icons-outlined" 
+            onClick={() => this.setState({ showDropdown: !this.state.showDropdown })}
+          >
+            account_circle
+          </i>
+          {this.renderSettingsDropdown()}
+        </NavButton>
+      </StyledNavbar>
+    );
+  }
+}
+
+Navbar.contextType = Context;
+
+const CloseOverlay = styled.div`
+  position: fixed;
+  width: 100vw;
+  height: 100vh;
+  z-index: 100;
+  top: 0;
+  left: 0;
+  cursor: default;
+`;
+
+const LogOutButton = styled.button`
+  padding: 13px;
+  height: 40px;
+  font-size: 13px;
+  font-weight: 500;
+  font-family: 'Work Sans', sans-serif;
+  color: white;
+  width: 100%;
+  border: 0;
+  text-align: left;
+  background: none;
+  cursor: ${(props) => (!props.disabled ? 'pointer' : 'default')};
+  user-select: none;
+  :focus { outline: 0 }
+  :hover {
+    background: #ffffff11;
+  }
+  display: flex;
+  align-items: center;
+
+  > i {
+    background: none;
+    border-radius: 3px;
+    display: flex;
+    font-size: 13px;
+    top: 11px;
+    margin-right: 10px;
+    padding: 1px;
+    align-items: center;
+    justify-content: center;
+    color: #ffffffaa;
+    border: 1px solid #ffffffaa;
+  }
+`;
+
+const DropdownLabel = styled.div`
+  font-size: 13px;
+  height: 40px;
+  color: #ffffff44;
+  font-weight: 500;
+  display: flex;
+  align-items: center;
+  padding: 13px;
+  max-width: 180px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const Dropdown = styled.div`
+  position: absolute;
+  right: 0;
+  top: calc(100% + 5px);
+  background: #26282f;
+  width: ${(props: { dropdownWidth: string, dropdownMaxHeight: string, feedbackSent?: boolean }) => props.dropdownWidth};
+  max-height: ${(props: { dropdownWidth: string, dropdownMaxHeight: string, feedbackSent?: boolean }) => props.dropdownMaxHeight ? props.dropdownMaxHeight : '300px'};
+  border-radius: 3px;
+  z-index: 999;
+  overflow-y: auto;
+  margin-bottom: 20px;
+  box-shadow: 0 8px 20px 0px #00000088;
+  animation: ${(props: { dropdownWidth: string, dropdownMaxHeight: string, feedbackSent?: boolean }) => props.feedbackSent ? 'flyOff 0.3s 0.05s' : ''};
+  animation-fill-mode: forwards;
+  @keyframes flyOff {
+    from {
+      opacity: 1; transform: translateX(0px);
+    }
+    to {
+      opacity: 0; transform: translateX(100px);
+    }
+  }
+`;
+
+const DropdownAlt = styled(Dropdown)`
+  animation: fadeIn 0.3s 0.5s;
+  opacity: 0;
+  animation-fill-mode: forwards;
+  @keyframes fadeIn {
+    from { opacity: 0 }
+    to { opacity: 1 }
+  }
+`;
+
+const StyledNavbar = styled.div`
+  width: 100%;
+  height: 60px;
+  position: absolute;
+  top: 0;
+  left: 0;
+  display: flex;
+  align-items: center;
+  padding-right: 5px;
+  justify-content: flex-end;
+`;
+
+const NavButton = styled.a`
+  display: flex;
+  position: relative;
+  align-items: center;
+  justify-content: center;
+  margin-right: 15px;
+  :hover {
+    > i {
+      color: #ffffff;
+    }
+  }
+  
+  > i {
+    cursor: pointer;
+    color: ${(props: { selected?: boolean }) => props.selected ? '#ffffff' : '#ffffff88'};
+    font-size: 24px;
+  }
+`;
+
+const FeedbackButton = styled(NavButton)`
+  color: ${(props: { selected?: boolean }) => props.selected ? '#ffffff' : '#ffffff88'};
+  font-family: 'Work Sans', sans-serif;
+  font-size: 14px;
+  margin-right: 20px;
+  :hover {
+    color: #ffffff;
+    > div {
+      > i {
+        color: #ffffff;
+      }
+    }
+  }
+
+  > div {
+    > i {
+      color: ${(props: { selected?: boolean }) => props.selected ? '#ffffff' : '#ffffff88'};
+      font-size: 26px;
+      margin-right: 6px;
+    }
+  }
+`;

+ 546 - 0
dashboard/src/main/home/new-project/NewProject.tsx

@@ -0,0 +1,546 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import gradient from '../../../assets/gradient.jpg';
+import close from '../../../assets/close.png';
+
+import api from '../../../shared/api';
+import { Context } from '../../../shared/Context';
+import { integrationList } from '../../../shared/common';
+import { ProjectType } from '../../../shared/types';
+
+import InputRow from '../../../components/values-form/InputRow';
+import Helper from '../../../components/values-form/Helper';
+import Heading from '../../../components/values-form/Heading';
+import SaveButton from '../../../components/SaveButton';
+
+const providers = ['aws', 'gcp', 'do',];
+
+type PropsType = {
+  setCurrentView: (x: string) => void,
+};
+
+type StateType = {
+  projectName: string,
+  selectedProvider: string | null,
+  awsRegion: string | null,
+  awsAccessId: string | null,
+  awsSecretKey: string | null,
+  status: string | null,
+};
+
+export default class NewProject extends Component<PropsType, StateType> {
+  state = {
+    projectName: '',
+    selectedProvider: null as string | null,
+    awsRegion: '' as string | null,
+    awsAccessId: '' as string | null,
+    awsSecretKey: '' as string | null,
+    status: null as string | null,
+  }
+
+  isAlphanumeric = (x: string) => {
+    let re = /^[a-z0-9-]+$/;
+    if (x.length == 0 || x.search(re) === -1) {
+      return false;
+    }
+    return true;
+  }
+
+  renderTemplateList = () => {
+    return providers.map((provider: string, i: number) => {
+      let providerInfo = integrationList[provider];
+      return (
+        <Block 
+          key={i} 
+          onClick={() => this.setState({ selectedProvider: provider })}
+        >
+          <Icon src={providerInfo.icon} />
+          <BlockTitle>
+            {providerInfo.label}
+          </BlockTitle>
+          <BlockDescription>
+            Hosted in your own cloud.
+          </BlockDescription>
+        </Block>
+      )
+    });
+  }
+
+  renderProvisioners = () => {
+    if (this.state.selectedProvider === 'aws') {
+      return (
+        <FormSection>
+          <CloseButton onClick={() => {
+            this.setState({ selectedProvider: null });
+          }}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
+          <DarkMatter />
+          <Heading>
+            AWS Credentials
+            <GuideButton href='https://docs.getporter.dev/docs/getting-started-with-porter-on-aws' target='_blank'>
+              <i className="material-icons-outlined">help</i> 
+              Guide
+            </GuideButton>
+          </Heading>
+          <InputRow
+            type='text'
+            value={this.state.awsRegion}
+            setValue={(x: string) => this.setState({ awsRegion: x })}
+            label='📍 AWS Region'
+            placeholder='ex: mars-north-12'
+            width='100%'
+            isRequired={true}
+          />
+          <InputRow
+            type='text'
+            value={this.state.awsAccessId}
+            setValue={(x: string) => this.setState({ awsAccessId: x })}
+            label='👤 AWS Access ID'
+            placeholder='ex: AKIAIOSFODNN7EXAMPLE'
+            width='100%'
+            isRequired={true}
+          />
+          <InputRow
+            type='password'
+            value={this.state.awsSecretKey}
+            setValue={(x: string) => this.setState({ awsSecretKey: x })}
+            label='🔒 AWS Secret Key'
+            placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
+            width='100%'
+            isRequired={true}
+          />
+        </FormSection>
+      );
+    } else if (this.state.selectedProvider === 'gcp') {
+      return (
+        <FormSection>
+          <CloseButton onClick={() => {
+            this.setState({ selectedProvider: null });
+          }}>
+            <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>
+        </FormSection>
+      );
+    } else if (this.state.selectedProvider === 'do') {
+      return (
+        <FormSection>
+          <CloseButton onClick={() => {
+            this.setState({ selectedProvider: null });
+          }}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
+          <Flex>
+            DigitalOcean support is in closed beta. If you would like to run Porter in your own DO account, email <Highlight>contact@getporter.dev</Highlight>.
+          </Flex>
+        </FormSection>
+      );
+    }
+
+    return (
+      <BlockList>
+        {this.renderTemplateList()}
+      </BlockList>
+    );
+  }
+
+  renderHostingSection = () => {
+    if (this.state.selectedProvider === 'skipped') {
+      return (
+        <>
+          <Helper>Select your hosting backend:</Helper>
+          <Placeholder>
+            You can manually link to an existing cluster once this project has been created.
+          </Placeholder>
+          <Helper>
+            Don't have a Kubernetes cluster?
+            <Highlight onClick={() => this.setState({ selectedProvider: null })}>
+              Provision through Porter
+            </Highlight>
+          </Helper>
+        </>
+      )
+    }
+
+    return (
+      <>
+        <Helper>
+          Select your hosting backend: <Required>*</Required>
+        </Helper>
+        {this.renderProvisioners()}
+        <Helper>
+          Already have a Kubernetes cluster? 
+          <Highlight onClick={() => this.setState({ selectedProvider: 'skipped' })}>
+            Skip
+          </Highlight>
+        </Helper>
+      </>
+    )
+  }
+
+  validateForm = () => {
+    let { projectName, selectedProvider, awsAccessId, awsSecretKey, awsRegion } = this.state;
+    if (!this.isAlphanumeric(projectName) || projectName === '') {
+      return false;
+    } else if (selectedProvider === 'aws') {
+      return awsAccessId !== '' && awsSecretKey !== '' && awsRegion !== '';
+    }  else if (selectedProvider === 'skipped') {
+      return true;
+    }
+    return false;
+  }
+
+  createProject = () => {
+    this.setState({ status: 'loading' });
+    api.createProject('<token>', {
+      name: this.state.projectName
+    }, {}, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        let { user } = this.context;
+        api.getProjects('<token>', {}, { id: user.userId }, (err: any, res: any) => {
+          if (err) {
+            console.log(err)
+          } else if (res.data) {
+            this.context.setProjects(res.data);
+            if (res.data.length > 0) {
+              let proj = res.data.find((el: ProjectType) => el.name === this.state.projectName);
+              this.context.setCurrentProject(proj);
+
+              // Handle provisioning logic
+              if (this.state.selectedProvider === 'aws') {
+                this.props.setCurrentView('provisioner');
+              } else {
+                this.props.setCurrentView('dashboard');
+              }
+            } 
+          }
+        });
+      }
+    });
+  }
+  
+  render() {
+    return (
+      <StyledNewProject height={this.state.selectedProvider === 'aws' ? '700px' : '600px'}>
+        <TitleSection>
+          <Title>New Project</Title>
+        </TitleSection>
+
+        <Helper>
+          Project name
+          <Warning highlight={!this.isAlphanumeric(this.state.projectName) && this.state.projectName !== ''}>
+            (lowercase letters, numbers, and "-" only)
+          </Warning>
+          <Required>*</Required>
+        </Helper>
+        <InputWrapper>
+          <ProjectIcon>
+            <ProjectImage src={gradient} />
+            <Letter>{this.state.projectName ? this.state.projectName[0].toUpperCase() : '-'}</Letter>
+          </ProjectIcon>
+          <InputRow
+            type='string'
+            value={this.state.projectName}
+            setValue={(x: string) => this.setState({ projectName: x })}
+            placeholder='ex: perspective-vortex'
+            width='470px'
+          />
+        </InputWrapper>
+
+        {this.renderHostingSection()}
+
+        <SaveButton
+          text='Create Project'
+          disabled={!this.validateForm()}
+          onClick={this.createProject}
+          makeFlush={true}
+          helper='Note: Provisioning can take up to 15 minutes'
+          status={this.state.status}
+        />
+      </StyledNewProject>
+    );
+  }
+}
+
+NewProject.contextType = Context;
+
+const GuideButton = styled.a`
+  display: flex;
+  align-items: center;
+  margin-left: 20px;
+  color: #aaaabb;
+  font-size: 13px;
+  margin-bottom: -1px;
+  border: 1px solid #aaaabb;
+  padding: 5px 10px;
+  padding-left: 6px;
+  border-radius: 5px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+    color: #ffffff;
+    border: 1px solid #ffffff;
+
+    > i {
+      color: #ffffff;
+    }
+  }
+
+  > i {
+    color: #aaaabb;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const Flex = styled.div`
+  display: flex;
+  height: 170px;
+  width: 100%;
+  margin-top: -10px;
+  color: #ffffff;
+  align-items: center;
+  justify-content: center;
+`;
+
+const BlockOverlay = styled.div`
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  background: #00000055;
+  top: 0;
+  left: 0;
+`;
+
+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 DarkMatter = styled.div`
+  margin-top: -30px;
+`;
+
+const FormSection = styled.div`
+  background: #ffffff11;
+  margin-top: 25px;
+  margin-bottom: 27px;
+  background: #26282f;
+  border-radius: 5px;
+  min-height: 170px;
+  padding: 25px;
+  padding-bottom: 15px;
+  font-size: 13px;
+  animation: fadeIn 0.3s 0s;
+  position: relative;
+`;
+
+const Placeholder = styled.div`
+  background: #ffffff11;
+  margin-top: 25px;
+  margin-bottom: 27px;
+  background: #26282f;
+  border-radius: 5px;
+  height: 170px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  font-size: 13px;
+`;
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+`;
+
+const Highlight = styled.div`
+  margin-left: 5px;
+  color: #8590ff;
+  cursor: pointer;
+`;
+
+const Letter = styled.div`
+  height: 100%;
+  width: 100%;
+  position: absolute;
+  background: #00000028;
+  top: 0;
+  left: 0;
+  display: flex;
+  color: white;
+  align-items: center;
+  justify-content: center;
+  font-size: 24px;
+  font-weight: 500;
+  font-family: 'Work Sans', sans-serif;
+`;
+
+const ProjectImage = styled.img`
+  width: 100%;
+  height: 100%;
+`;
+
+const ProjectIcon = styled.div`
+  width: 45px;
+  min-width: 45px;
+  height: 45px;
+  border-radius: 5px;
+  overflow: hidden;
+  position: relative;
+  margin-right: 15px;
+  font-weight: 400;
+  margin-top: 17px;
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: -15px;
+`;
+
+const Warning = styled.span`
+  color: ${(props: { highlight: boolean }) => props.highlight ? '#f5cb42' : ''};
+  margin-left: 5px;
+`;
+
+const Icon = styled.img`
+  height: 42px;
+  margin-top: 30px;
+  margin-bottom: 15px;
+  filter: ${(props: { bw?: boolean }) => props.bw ? 'grayscale(1)' : ''};
+`;
+
+const BlockDescription = styled.div`
+  margin-bottom: 12px;
+  color: #ffffff66;
+  text-align: center;
+  font-weight: default;
+  font-size: 13px;
+  padding: 0px 25px;
+  height: 2.4em;
+  font-size: 12px;
+  display: -webkit-box;
+  overflow: hidden;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;  
+`;
+
+const BlockTitle = styled.div`
+  margin-bottom: 12px;
+  width: 80%;
+  text-align: center;
+  font-size: 14px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const Block = styled.div`
+  align-items: center;
+  user-select: none;
+  border-radius: 5px;
+  display: flex;
+  font-size: 13px;
+  overflow: hidden;
+  font-weight: 500;
+  padding: 3px 0px 5px;
+  flex-direction: column;
+  align-item: center;
+  justify-content: space-between;
+  height: 170px;
+  cursor: ${(props: { disabled?: boolean }) => props.disabled ? '' : 'pointer'};
+  color: #ffffff;
+  position: relative;
+  background: #26282f;
+  box-shadow: 0 3px 5px 0px #00000022;
+  :hover {
+    background: ${(props: { disabled?: boolean }) => props.disabled ? '' : '#ffffff11'};
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from { opacity: 0 }
+    to { opacity: 1 }
+  }
+`;
+
+const ShinyBlock = styled(Block)`
+  background: linear-gradient(36deg, rgba(240,106,40,0.9) 0%, rgba(229,83,229,0.9) 100%);
+  :hover {
+    background: linear-gradient(36deg, rgba(240,106,40,1) 0%, rgba(229,83,229,1) 100%);
+  }
+`;
+
+const BlockList = styled.div`
+  overflow: visible;
+  margin-top: 25px;
+  margin-bottom: 27px;
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+`;
+
+const Title = styled.div`
+  font-size: 24px;
+  font-weight: 600;
+  font-family: 'Work Sans', sans-serif;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const TitleSection = styled.div`
+  margin-bottom: 20px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+
+  > a {
+    > i {
+      display: flex;
+      align-items: center;
+      margin-bottom: -2px;
+      font-size: 18px;
+      margin-left: 18px;
+      color: #858FAAaa;
+      cursor: pointer;
+      :hover {
+        color: #aaaabb;
+      }
+    }
+  }
+`;
+
+const StyledNewProject = styled.div`
+  width: calc(90% - 150px);
+  min-width: 300px;
+  height: ${(props: { height: string }) => props.height};
+  position: relative;
+  padding-top: 50px;
+  margin-top: ${(props: { height: string }) => props.height === '600px' ? 'calc(50vh - 350px)' : 'calc(50vh - 400px)'};
+`;

+ 164 - 0
dashboard/src/main/home/new-project/Provisioner.tsx

@@ -0,0 +1,164 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import api from '../../../shared/api';
+import { Context } from '../../../shared/Context';
+import { integrationList } from '../../../shared/common';
+import loading from '../../../assets/loading.gif';
+
+import Helper from '../../../components/values-form/Helper';
+
+type PropsType = {
+};
+
+type StateType = {
+  logs: string[],
+};
+
+const loadMax = 40;
+
+export default class Provisioner extends Component<PropsType, StateType> {
+  state = {
+    logs: [] as string[],
+  }
+
+  componentDidMount() {
+    this.setState({ logs: ['test-1', 'test-2'] });
+  }
+
+  scrollRef = React.createRef<HTMLDivElement>();
+
+  renderLogs = () => {
+    return this.state.logs.map((log, i) => {
+      return <div key={i}>{log}</div>
+    });
+  }
+  
+  render() {
+    return (
+      <StyledProvisioner>
+        <TitleSection>
+          <Title><img src={loading} /> Setting Up Porter</Title>
+        </TitleSection>
+
+        <Helper>
+          Porter is currently being provisioned to your AWS account:
+        </Helper>
+
+        <LoadingBar>
+          <Loaded progress={((7 / loadMax) * 100).toString() + '%'} />
+        </LoadingBar>
+
+        <LogStream ref={this.scrollRef}>
+          <Wrapper>
+            {this.renderLogs()}
+          </Wrapper>
+        </LogStream>
+
+        <Helper>
+          (Provisioning usually takes around 15 minutes)
+        </Helper>
+      </StyledProvisioner>
+    );
+  }
+}
+
+Provisioner.contextType = Context;
+
+const Wrapper = styled.div`
+  width: 100%;
+  height: 100%;
+  overflow: auto;
+  padding: 20px 25px;
+`;
+
+const LogStream = styled.div`
+  height: 300px;
+  margin-top: 30px;
+  font-size: 13px;
+  border: 2px solid #ffffff55;
+  border-radius: 10px;
+  width: 100%;
+  background: #00000022;
+  user-select: text;
+`;
+
+const Message = styled.div`
+  display: flex;
+  height: 100%;
+  width: 100%;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  font-size: 13px;
+`;
+
+const Loaded = styled.div`
+  width: ${(props: { progress: string }) => props.progress};
+  height: 100%;
+  background: linear-gradient(to right, #4f8aff, #8e7dff, #4f8aff);
+  background-size: 400% 400%;
+
+  animation: linkLoad 2s infinite;
+
+  @keyframes linkLoad {
+    0%{background-position:91% 100%}
+    100%{background-position:10% 0%}
+  }
+`;
+
+const LoadingBar = styled.div`
+  width: 100%;
+  margin-top: 24px;
+  overflow: hidden;
+  height: 20px;
+  background: #ffffff11;
+  border-radius: 30px;
+`;
+
+const Title = styled.div`
+  font-size: 24px;
+  font-weight: 600;
+  font-family: 'Work Sans', sans-serif;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+
+  > img {
+    width: 20px;
+    margin-right: 10px;
+    margin-bottom: -2px;
+  }
+`;
+
+const TitleSection = styled.div`
+  margin-bottom: 20px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+
+  > a {
+    > i {
+      display: flex;
+      align-items: center;
+      margin-bottom: -2px;
+      font-size: 18px;
+      margin-left: 18px;
+      color: #858FAAaa;
+      cursor: pointer;
+      :hover {
+        color: #aaaabb;
+      }
+    }
+  }
+`;
+
+const StyledProvisioner = styled.div`
+  width: calc(90% - 150px);
+  min-width: 300px;
+  height: 600px;
+  position: relative;
+  padding-top: 50px;
+  margin-top: calc(50vh - 350px);
+`;

+ 15 - 44
dashboard/src/main/home/sidebar/ProjectSection.tsx

@@ -7,63 +7,30 @@ import { Context } from '../../../shared/Context';
 import { ProjectType } from '../../../shared/types';
 
 type PropsType = {
-  currentProject: ProjectType
+  currentProject: ProjectType,
+  setCurrentView: (x: string) => void,
+  projects: ProjectType[],
 };
 
 type StateType = {
-  projects: ProjectType[],
   expanded: boolean
 };
 
 export default class ProjectSection extends Component<PropsType, StateType> {
   state = {
-    projects: [] as ProjectType[],
     expanded: false,
   };
 
-  updateProjects = () => {
-    let { user } = this.context;
-    api.getProjects('<token>', {}, { id: user.userId }, (err: any, res: any) => {
-      if (err) {
-        console.log(err)
-      } else if (res.data) {
-        this.setState({ projects: res.data });
-        if (res.data.length > 0) {
-          this.context.setCurrentProject(res.data[0]);
-        } else {
-          this.context.setCurrentModal('CreateProjectModal', {
-            keepOpen: true,
-            updateProjects: this.updateProjects
-          });
-        }
-      }
-    });
-  }
-
-  componentDidMount() {
-    this.updateProjects();
-  }
-
-  componentDidUpdate(prevProps: PropsType) {
-    if (!this.props.currentProject && (this.props.currentProject !== prevProps.currentProject)) {
-      this.updateProjects();
-    }
-  }
-  
-  showProjectCreateModal = () => {
-    this.context.setCurrentModal('CreateProjectModal', {
-      keepOpen: false,
-      updateProjects: this.updateProjects
-    });
-  }
-
   renderOptionList = () => {
-    return this.state.projects.map((project: ProjectType, i: number) => {
+    return this.props.projects.map((project: ProjectType, i: number) => {
       return (
         <Option
           key={i}
           selected={project.name === this.props.currentProject.name}
-          onClick={() => this.context.setCurrentProject(project)}
+          onClick={() => {
+            this.context.setCurrentProject(project);
+            this.props.setCurrentView('dashboard');
+          }}
         >
           <ProjectIcon>
             <ProjectImage src={gradient} />
@@ -85,7 +52,7 @@ export default class ProjectSection extends Component<PropsType, StateType> {
             <Option
               selected={false}
               lastItem={true}
-              onClick={this.showProjectCreateModal}
+              onClick={() => this.props.setCurrentView('new-project')}
             >
               <ProjectIconAlt>+</ProjectIconAlt>
               <ProjectLabel>Add a project</ProjectLabel>
@@ -96,13 +63,17 @@ export default class ProjectSection extends Component<PropsType, StateType> {
     }
   }
 
+  handleExpand = () => {
+    this.setState({ expanded: !this.state.expanded });
+  }
+
   render() {
     let { currentProject } = this.props;
     if (currentProject) {
       return (
         <StyledProjectSection>
           <MainSelector
-            onClick={() => this.setState({ expanded: !this.state.expanded })}
+            onClick={this.handleExpand}
             expanded={this.state.expanded}
           >
             <ProjectIcon>
@@ -117,7 +88,7 @@ export default class ProjectSection extends Component<PropsType, StateType> {
       );
     }
     return (
-      <InitializeButton onClick={this.showProjectCreateModal}>
+      <InitializeButton onClick={() => this.props.setCurrentView('new-project')}>
         <Plus>+</Plus> Create a Project
       </InitializeButton>
     );

+ 3 - 0
dashboard/src/main/home/sidebar/ProjectSectionContainer.tsx

@@ -5,6 +5,7 @@ import { Context } from '../../../shared/Context';
 import ProjectSection from './ProjectSection';
 
 type PropsType = {
+  setCurrentView: (x: string) => void,
 };
 
 type StateType = {
@@ -19,6 +20,8 @@ export default class ProjectSectionContainer extends Component<PropsType, StateT
     return (
       <ProjectSection
         currentProject={this.context.currentProject}
+        projects={this.context.projects}
+        setCurrentView={this.props.setCurrentView}
       />
     );
   }

+ 24 - 32
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -1,19 +1,16 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
-import gradient from '../../../assets/gradient.jpg';
 import category from '../../../assets/category.svg';
-import pipelines from '../../../assets/pipelines.svg';
 import integrations from '../../../assets/integrations.svg';
 import filter from '../../../assets/filter.svg';
 
-import api from '../../../shared/api';
 import { Context } from '../../../shared/Context';
 
 import ClusterSection from './ClusterSection';
 import ProjectSectionContainer from './ProjectSectionContainer';
+import loading from '../../../assets/loading.gif';
 
 type PropsType = {
-  logOut: () => void,
   forceSidebar: boolean,
   setWelcome: (x: boolean) => void,
   setCurrentView: (x: string) => void,
@@ -92,22 +89,16 @@ export default class Sidebar extends Component<PropsType, StateType> {
     }
   };
 
-  handleLogout = (): void => {
-    let { logOut } = this.props;
-    let { setCurrentError } = this.context;
-
-    // Attempt user logout
-    api.logOutUser('<token>', {}, {}, (err: any, res: any) => {
-      // TODO: case and set logout error
-      
-      err ? setCurrentError(err.response.data.errors[0]) : logOut();
-    }); 
-  }
-
   renderProjectContents = () => {
-    if (this.context.currentProject) {
+    if (this.props.currentView === 'provisioner') {
       return (
-        <div>
+        <ProjectPlaceholder>
+          <img src={loading} /> Creating . . .
+        </ProjectPlaceholder>
+      )
+    } else if (this.context.currentProject) {
+      return (
+        <>
           <SidebarLabel>Home</SidebarLabel>
           <NavButton
             onClick={() => this.props.setCurrentView('dashboard')}
@@ -124,9 +115,9 @@ export default class Sidebar extends Component<PropsType, StateType> {
             Templates
           </NavButton>
           <NavButton
-            // onClick={() => this.props.setCurrentView('integrations')}
-            // selected={this.props.currentView === 'integrations'}
-            onClick={() => this.context.setCurrentModal('IntegrationsInstructionsModal', {})}
+            onClick={() => this.props.setCurrentView('integrations')}
+            selected={this.props.currentView === 'integrations'}
+            // onClick={() => this.context.setCurrentModal('IntegrationsInstructionsModal', {})}
           >
             <img src={integrations} />
             Integrations
@@ -142,7 +133,7 @@ export default class Sidebar extends Component<PropsType, StateType> {
             setCurrentView={this.props.setCurrentView}
             isSelected={this.props.currentView === 'cluster-dashboard'}
           />
-        </div>
+        </>
       );
     }
 
@@ -170,17 +161,13 @@ export default class Sidebar extends Component<PropsType, StateType> {
             <i className="material-icons">double_arrow</i>
           </CollapseButton>
 
-          <ProjectSectionContainer />
+          <ProjectSectionContainer 
+            setCurrentView={this.props.setCurrentView}
+          />
 
           <br />
 
           {this.renderProjectContents()}
-
-          <BottomSection>
-            <LogOutButton onClick={this.handleLogout}>
-            Log Out <i className="material-icons">keyboard_return</i>
-            </LogOutButton>
-          </BottomSection>
         </StyledSidebar>
       </div>
     );
@@ -196,11 +183,16 @@ const ProjectPlaceholder = styled.div`
   display: flex;
   align-items: center;
   justify-content: center;
-  height: calc(100% - 180px);
+  height: calc(100% - 100px);
   font-size: 13px;
   font-family: 'Work Sans', sans-serif;
-  color: #ffffff44;
-  margin-top: 20px;
+  color: #aaaabb;
+  padding-bottom: 80px;
+
+  > img {
+    width: 17px;
+    margin-right: 10px;
+  }
 `;
 
 const NavButton = styled.div`

+ 11 - 2
dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx

@@ -61,8 +61,17 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
       _.set(values, key, rawValues[key]);
     }
 
-    _.set(values, "image.repository", this.state.selectedImageUrl)
-    _.set(values, "image.tag", this.state.selectedTag)
+    let imageUrl = this.state.selectedImageUrl;
+    let tag = this.state.selectedTag;
+
+    if (this.state.selectedImageUrl.includes(':')) {
+      let splits = this.state.selectedImageUrl.split(':');
+      imageUrl = splits[0];
+      tag = splits[1];
+    }
+
+    _.set(values, "image.repository", imageUrl)
+    _.set(values, "image.tag", tag)
 
     api.deployTemplate('<token>', {
       templateName: this.props.currentTemplate.name,

+ 16 - 0
dashboard/src/shared/Context.tsx

@@ -43,6 +43,10 @@ class ContextProvider extends Component {
     setCurrentProject: (currentProject: ProjectType) => {
       this.setState({ currentProject });
     },
+    projects: [] as ProjectType[],
+    setProjects: (projects: ProjectType[]) => {
+      this.setState({ projects });
+    },
     user: null as any,
     setUser: (userId: number, email: string) => {
       this.setState({ user: {userId, email} });
@@ -50,6 +54,18 @@ class ContextProvider extends Component {
     devOpsMode: true,
     setDevOpsMode: (devOpsMode: boolean) => {
       this.setState({ devOpsMode });
+    },
+    clearContext: () => {
+      this.setState({
+        currentModal: null,
+        currentModalData: null,
+        currentError: null,
+        currentCluster: null,
+        currentProject: null,
+        projects: [],
+        user: null,
+        devOpsMode: true,
+      });
     }
   };
   

+ 16 - 0
dashboard/src/shared/common.tsx

@@ -1,3 +1,7 @@
+import aws from '../assets/aws.png';
+import digitalOcean from '../assets/do.png';
+import gcp from '../assets/gcp.png';
+
 export const integrationList: any = {
   'kubernetes': {
     icon: 'https://uxwing.com/wp-content/themes/uxwing/download/10-brands-and-social-media/kubernetes.png',
@@ -38,6 +42,18 @@ export const integrationList: any = {
     icon: 'https://avatars2.githubusercontent.com/u/52505464?s=400&u=da920f994c67665c7ad6c606a5286557d4f8555f&v=4',
     label: 'Elastic Container Registry (ECR)',
   },
+  'aws': {
+    icon: aws,
+    label: 'AWS',
+  },
+  'gcp': {
+    icon: gcp,
+    label: 'GCP',
+  },
+  'do': {
+    icon: digitalOcean,
+    label: 'DigitalOcean',
+  }
 };
 
 export const getIgnoreCase = (object: any, key: string) => {

+ 7 - 0
docker-compose.dev.yaml

@@ -33,6 +33,13 @@ services:
       - 5400:5432
     volumes:
       - database:/var/lib/postgresql/data
+  redis:
+    image: redis:latest
+    container_name: redis
+    ports:
+      - 6379:6379
+    volumes:
+      - database:/var/lib/postgresql/data
   chartmuseum:
     image: docker.io/bitnami/chartmuseum:0-debian-10
     container_name: chartmuseum

+ 20 - 18
go.mod

@@ -5,33 +5,31 @@ go 1.14
 require (
 	cloud.google.com/go v0.65.0
 	github.com/Azure/go-autorest/autorest v0.11.1 // indirect
+	github.com/Azure/go-autorest/autorest/adal v0.9.5 // indirect
 	github.com/DATA-DOG/go-sqlmock v1.5.0
 	github.com/Masterminds/semver v1.5.0 // indirect
 	github.com/aws/aws-sdk-go v1.31.6
-	github.com/containerd/containerd v1.4.1
-	github.com/cosmtrek/air v1.21.2 // indirect
-	github.com/creack/pty v1.1.11 // indirect
-	github.com/danieljoos/wincred v1.1.0 // indirect
+	github.com/containerd/containerd v1.4.1 // indirect
+	github.com/coreos/rkt v1.30.0
 	github.com/docker/docker v1.4.2-0.20200203170920-46ec8731fbce
-	github.com/docker/docker-credential-helpers v0.6.3
 	github.com/docker/go-connections v0.4.0
-	github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 // indirect
 	github.com/evanphx/json-patch v4.9.0+incompatible // indirect
 	github.com/fatih/color v1.9.0
 	github.com/go-chi/chi v4.1.2+incompatible
-	github.com/go-chi/cors v1.1.1
 	github.com/go-playground/locales v0.13.0
 	github.com/go-playground/universal-translator v0.17.0
 	github.com/go-playground/validator/v10 v10.3.0
+	github.com/go-redis/redis v6.15.9+incompatible
+	github.com/go-redis/redis/v7 v7.4.0
+	github.com/go-redis/redis/v8 v8.3.1
 	github.com/go-test/deep v1.0.7
-	github.com/google/go-cmp v0.5.1
-	github.com/google/go-containerregistry v0.1.4
 	github.com/google/go-github v17.0.0+incompatible
-	github.com/google/go-github/v32 v32.1.0
+	github.com/google/go-querystring v1.0.0 // indirect
+	github.com/googleapis/gnostic v0.2.2 // indirect
 	github.com/gorilla/securecookie v1.1.1
 	github.com/gorilla/sessions v1.2.1
 	github.com/gorilla/websocket v1.4.2
-	github.com/hashicorp/consul/api v1.3.0
+	github.com/hashicorp/golang-lru v0.5.3 // indirect
 	github.com/imdario/mergo v0.3.11 // indirect
 	github.com/itchyny/gojq v0.11.1
 	github.com/itchyny/timefmt-go v0.1.1 // indirect
@@ -39,40 +37,44 @@ require (
 	github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd
 	github.com/json-iterator/go v1.1.10 // indirect
 	github.com/kr/pretty v0.2.0 // indirect
+	github.com/kr/text v0.2.0 // indirect
 	github.com/kris-nova/logger v0.0.0-20181127235838-fd0d87064b06
 	github.com/kris-nova/lolgopher v0.0.0-20180921204813-313b3abb0d9b // indirect
-	github.com/mattn/go-colorable v0.1.7 // indirect
+	github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
+	github.com/onsi/ginkgo v1.14.2 // indirect
+	github.com/opentracing/opentracing-go v1.2.0 // indirect
 	github.com/pelletier/go-toml v1.8.1 // indirect
 	github.com/pkg/errors v0.9.1
 	github.com/rs/zerolog v1.20.0
-	github.com/sirupsen/logrus v1.7.0
+	github.com/sirupsen/logrus v1.7.0 // indirect
 	github.com/spf13/cobra v1.0.0
 	github.com/spf13/viper v1.4.0
 	github.com/stretchr/testify v1.6.1
+	go.opentelemetry.io/otel v0.13.0 // indirect
 	golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0
+	golang.org/x/exp v0.0.0-20200908183739-ae8ad444f925 // indirect
 	golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43
-	golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6
+	golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6 // indirect
 	golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect
 	google.golang.org/api v0.30.0
 	google.golang.org/genproto v0.0.0-20201014134559-03b6142f0dc9
 	google.golang.org/grpc v1.33.0 // indirect
-	gopkg.in/go-playground/validator.v9 v9.31.0
+	gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
 	gopkg.in/yaml.v2 v2.3.0
 	gorm.io/driver/postgres v1.0.2
 	gorm.io/driver/sqlite v1.1.3
 	gorm.io/gorm v1.20.2
-	helm.sh/helm v2.16.12+incompatible
+	gotest.tools/v3 v3.0.3 // indirect
 	helm.sh/helm/v3 v3.3.4
 	k8s.io/api v0.18.8
 	k8s.io/apimachinery v0.18.8
 	k8s.io/cli-runtime v0.18.8
 	k8s.io/client-go v0.18.8
-	k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac // indirect
 	k8s.io/helm v2.16.12+incompatible
 	k8s.io/klog/v2 v2.2.0 // indirect
 	k8s.io/utils v0.0.0-20200912215256-4140de9c8800 // indirect
+	rsc.io/letsencrypt v0.0.3 // indirect
 	sigs.k8s.io/aws-iam-authenticator v0.5.2
-	sigs.k8s.io/structured-merge-diff/v4 v4.0.1 // indirect
 	sigs.k8s.io/yaml v1.2.0
 )
 

Разница между файлами не показана из-за своего большого размера
+ 63 - 339
go.sum


+ 22 - 0
internal/adapter/redis.go

@@ -0,0 +1,22 @@
+package adapter
+
+import (
+	"context"
+	"fmt"
+
+	redis "github.com/go-redis/redis/v8"
+	"github.com/porter-dev/porter/internal/config"
+)
+
+// NewRedisClient returns a new redis client instance
+func NewRedisClient(conf *config.RedisConf) (*redis.Client, error) {
+	client := redis.NewClient(&redis.Options{
+		Addr: fmt.Sprintf("%s:%s", conf.Host, conf.Port),
+		// Username: conf.Username,
+		// Password: conf.Password,
+		// DB:       conf.DB,
+	})
+
+	_, err := client.Ping(context.Background()).Result()
+	return client, err
+}

+ 1 - 0
internal/config/config.go

@@ -13,6 +13,7 @@ type Conf struct {
 	Server ServerConf
 	Db     DBConf
 	K8s    K8sConf
+	Redis  RedisConf
 }
 
 // ServerConf is the server configuration

+ 7 - 0
internal/config/redis.go

@@ -0,0 +1,7 @@
+package config
+
+// RedisConf is the redis config required for the provisioner container
+type RedisConf struct {
+	Host string `env:"REDIS_HOST,default=redis"`
+	Port string `env:"REDIS_PORT,default=6379"`
+}

+ 0 - 2
internal/forms/helper_test.go

@@ -259,8 +259,6 @@ func initAWSIntegration(tester *tester, t *testing.T) {
 	aws := &ints.AWSIntegration{
 		ProjectID:          tester.initProjects[0].ID,
 		UserID:             tester.initUsers[0].ID,
-		AWSEntityID:        "entity",
-		AWSCallerID:        "caller",
 		AWSClusterID:       []byte("example-cluster-0"),
 		AWSAccessKeyID:     []byte("accesskey"),
 		AWSSecretAccessKey: []byte("secret"),

+ 23 - 0
internal/forms/infra.go

@@ -0,0 +1,23 @@
+package forms
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// CreateECRInfra represents the accepted values for creating an
+// ECR infra via the provisioning container
+type CreateECRInfra struct {
+	ECRName          string `json:"ecr_name" form:"required"`
+	ProjectID        uint   `json:"project_id" form:"required"`
+	AWSIntegrationID uint   `json:"aws_integration_id" form:"required"`
+}
+
+// ToAWSInfra converts the form to a gorm aws infra model
+func (ce *CreateECRInfra) ToAWSInfra() (*models.AWSInfra, error) {
+	return &models.AWSInfra{
+		Kind:             models.AWSInfraECR,
+		ProjectID:        ce.ProjectID,
+		Status:           models.StatusCreating,
+		AWSIntegrationID: ce.AWSIntegrationID,
+	}, nil
+}

+ 72 - 1
internal/kubernetes/agent.go

@@ -7,9 +7,16 @@ import (
 	"io"
 	"strings"
 
+	"github.com/porter-dev/porter/internal/config"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/ecr"
+	"github.com/porter-dev/porter/internal/models/integrations"
+
 	"github.com/gorilla/websocket"
 	"github.com/porter-dev/porter/internal/helm/grapher"
 	appsv1 "k8s.io/api/apps/v1"
+	batchv1 "k8s.io/api/batch/v1"
 	v1 "k8s.io/api/core/v1"
 	v1beta1 "k8s.io/api/extensions/v1beta1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -122,7 +129,7 @@ func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn)
 		// listens for websocket closing handshake
 		for {
 			if _, _, err := conn.ReadMessage(); err != nil {
-				conn.Close()
+				defer conn.Close()
 				errorchan <- nil
 				fmt.Println("Successfully closed log stream")
 				return
@@ -223,3 +230,67 @@ func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string) error
 		}
 	}
 }
+
+// ProvisionECR spawns a new provisioning pod that creates an ECR instance
+func (a *Agent) ProvisionECR(
+	projectID uint,
+	awsConf *integrations.AWSIntegration,
+	ecrName string,
+) (*batchv1.Job, error) {
+	prov := &provisioner.Conf{
+		ID:   fmt.Sprintf("%s-%d", ecrName, projectID),
+		Name: fmt.Sprintf("prov-%s-%d", ecrName, projectID),
+		Kind: provisioner.ECR,
+		AWS: &aws.Conf{
+			AWSRegion:          awsConf.AWSRegion,
+			AWSAccessKeyID:     string(awsConf.AWSAccessKeyID),
+			AWSSecretAccessKey: string(awsConf.AWSSecretAccessKey),
+		},
+		ECR: &ecr.Conf{
+			ECRName: ecrName,
+		},
+	}
+
+	return a.provision(prov)
+}
+
+// ProvisionTest spawns a new provisioning pod that tests provisioning
+func (a *Agent) ProvisionTest(
+	projectID uint,
+) (*batchv1.Job, error) {
+	prov := &provisioner.Conf{
+		ID:   fmt.Sprintf("%s-%d", "testing", projectID),
+		Name: fmt.Sprintf("prov-%s-%d", "testing", projectID),
+		Kind: provisioner.Test,
+	}
+
+	return a.provision(prov)
+}
+
+func (a *Agent) provision(
+	prov *provisioner.Conf,
+) (*batchv1.Job, error) {
+	prov.Namespace = "default"
+
+	prov.Redis = &config.RedisConf{
+		Host: "redis-master.default.svc.cluster.local",
+		Port: "6379",
+	}
+
+	prov.Postgres = &provisioner.PostgresConf{
+		Host: "postgres-postgresql.default.svc.cluster.local",
+		Port: "5432",
+	}
+
+	job, err := prov.GetProvisionerJobTemplate()
+
+	if err != nil {
+		return nil, err
+	}
+
+	return a.Clientset.BatchV1().Jobs(prov.Namespace).Create(
+		context.TODO(),
+		job,
+		metav1.CreateOptions{},
+	)
+}

+ 30 - 0
internal/kubernetes/provisioner/aws/aws.go

@@ -0,0 +1,30 @@
+package aws
+
+import (
+	v1 "k8s.io/api/core/v1"
+)
+
+// Conf wraps the AWS integration model
+type Conf struct {
+	AWSRegion, AWSAccessKeyID, AWSSecretAccessKey string
+}
+
+// AttachAWSEnv adds the relevant AWS env for the provisioner
+func (conf *Conf) AttachAWSEnv(env []v1.EnvVar) []v1.EnvVar {
+	env = append(env, v1.EnvVar{
+		Name:  "AWS_REGION",
+		Value: conf.AWSRegion,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "AWS_ACCESS_KEY_ID",
+		Value: conf.AWSAccessKeyID,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "AWS_SECRET_ACCESS_KEY",
+		Value: conf.AWSSecretAccessKey,
+	})
+
+	return env
+}

+ 18 - 0
internal/kubernetes/provisioner/aws/ecr/ecr.go

@@ -0,0 +1,18 @@
+package ecr
+
+import v1 "k8s.io/api/core/v1"
+
+// Conf is the ECR cluster config required for the provisioner
+type Conf struct {
+	ECRName string
+}
+
+// AttachECREnv adds the relevant ECR env for the provisioner
+func (conf *Conf) AttachECREnv(env []v1.EnvVar) []v1.EnvVar {
+	env = append(env, v1.EnvVar{
+		Name:  "ECR_NAME",
+		Value: conf.ECRName,
+	})
+
+	return env
+}

+ 18 - 0
internal/kubernetes/provisioner/aws/eks/eks.go

@@ -0,0 +1,18 @@
+package eks
+
+import v1 "k8s.io/api/core/v1"
+
+// Conf is the EKS cluster config required for the provisioner
+type Conf struct {
+	ClusterName string
+}
+
+// AttachEKSEnv adds the relevant EKS env for the provisioner
+func (conf *Conf) AttachEKSEnv(env []v1.EnvVar) []v1.EnvVar {
+	env = append(env, v1.EnvVar{
+		Name:  "EKS_CLUSTER_NAME",
+		Value: conf.ClusterName,
+	})
+
+	return env
+}

+ 133 - 0
internal/kubernetes/provisioner/global_stream.go

@@ -0,0 +1,133 @@
+package provisioner
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/porter-dev/porter/internal/repository"
+
+	redis "github.com/go-redis/redis/v8"
+
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// GlobalStreamName is the name of the Redis stream for global operations
+const GlobalStreamName = "global"
+
+// GlobalStreamGroupName is the name of the Redis consumer group that this server
+// is a part of
+const GlobalStreamGroupName = "portersvr"
+
+// InitGlobalStream initializes the global stream if it does not exist, and the
+// global consumer group if it does not exist
+func InitGlobalStream(client *redis.Client) error {
+	// determine if the stream exists
+	x, err := client.Exists(
+		context.Background(),
+		GlobalStreamName,
+	).Result()
+
+	// if it does not exist, create group and stream
+	if x == 0 {
+		_, err := client.XGroupCreateMkStream(
+			context.Background(),
+			GlobalStreamName,
+			GlobalStreamGroupName,
+			">",
+		).Result()
+
+		return err
+	}
+
+	// otherwise, check if the group exists
+	xInfoGroups, err := client.XInfoGroups(
+		context.Background(),
+		GlobalStreamName,
+	).Result()
+
+	if err != nil {
+		return err
+	}
+
+	for _, group := range xInfoGroups {
+		// if the group exists, return with no error
+		if group.Name == GlobalStreamGroupName {
+			return nil
+		}
+	}
+
+	// if the group does not exist, create it
+	_, err = client.XGroupCreate(
+		context.Background(),
+		GlobalStreamName,
+		GlobalStreamGroupName,
+		">",
+	).Result()
+
+	return err
+}
+
+// ResourceCRUDHandler is a handler for updates to an infra resource
+type ResourceCRUDHandler interface {
+	OnCreate(id uint) error
+}
+
+// GlobalStreamListener performs an XREADGROUP operation on a given stream
+// and sends a GlobalStreamMessage to the msgChan
+func GlobalStreamListener(
+	client *redis.Client,
+	infraRepo repository.AWSInfraRepository,
+	errorChan chan error,
+) {
+	for {
+		xstreams, err := client.XReadGroup(
+			context.Background(),
+			&redis.XReadGroupArgs{
+				Group:    GlobalStreamGroupName,
+				Consumer: "portersvr-0", // just static consumer for now
+				Streams:  []string{GlobalStreamName, ">"},
+				Block:    0,
+			},
+		).Result()
+
+		if err != nil {
+			errorChan <- err
+			return
+		}
+
+		// parse messages from the global stream
+		for _, msg := range xstreams[0].Messages {
+			// parse the id to identify the infra
+			infraID, err := models.GetInfraIDFromWorkspaceID(fmt.Sprintf("%v", msg.Values["id"]))
+
+			if fmt.Sprintf("%v", msg.Values["status"]) == "created" {
+				infra, err := infraRepo.ReadAWSInfra(infraID)
+
+				if err != nil {
+					continue
+				}
+
+				infra.Status = models.StatusCreated
+
+				infra, err = infraRepo.UpdateAWSInfra(infra)
+
+				if err != nil {
+					continue
+				}
+			}
+
+			// acknowledge the message as read
+			_, err = client.XAck(
+				context.Background(),
+				GlobalStreamName,
+				GlobalStreamGroupName,
+				msg.ID,
+			).Result()
+
+			// if error, continue for now
+			if err != nil {
+				continue
+			}
+		}
+	}
+}

+ 236 - 0
internal/kubernetes/provisioner/provisioner.go

@@ -0,0 +1,236 @@
+package provisioner
+
+import (
+	batchv1 "k8s.io/api/batch/v1"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/ecr"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/eks"
+
+	"github.com/porter-dev/porter/internal/config"
+)
+
+// InfraOption is a type of infrastructure that can be provisioned
+type InfraOption string
+
+// The list of infra options
+const (
+	Test InfraOption = "test"
+	ECR  InfraOption = "ecr"
+	EKS  InfraOption = "eks"
+)
+
+// Conf is the config required to start a provisioner container
+type Conf struct {
+	Kind      InfraOption
+	Name      string
+	Namespace string
+	ID        string
+	Redis     *config.RedisConf
+	Postgres  *PostgresConf
+
+	// provider-specific configurations
+	AWS *aws.Conf
+	ECR *ecr.Conf
+	EKS *eks.Conf
+}
+
+// PostgresConf is the postgres config for the provisioner container
+type PostgresConf struct {
+	Host string
+	Port string
+}
+
+// GetProvisionerJobTemplate returns the manifest that should be applied to
+// create a provisioning job
+func (conf *Conf) GetProvisionerJobTemplate() (*batchv1.Job, error) {
+	env := make([]v1.EnvVar, 0)
+
+	env = conf.attachDefaultEnv(env)
+
+	ttl := int32(3600)
+	backoffLimit := int32(3)
+
+	labels := map[string]string{
+		"app": "provisioner",
+	}
+
+	args := make([]string, 0)
+
+	if conf.Kind == Test {
+		args = []string{"test", "hello"}
+	} else if conf.Kind == ECR {
+		args = []string{"ecr"}
+		env = conf.AWS.AttachAWSEnv(env)
+		env = conf.ECR.AttachECREnv(env)
+	} else if conf.Kind == EKS {
+		args = []string{"eks"}
+		env = conf.AWS.AttachAWSEnv(env)
+		env = conf.EKS.AttachEKSEnv(env)
+	}
+
+	return &batchv1.Job{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      conf.Name,
+			Namespace: conf.Namespace,
+			Labels:    labels,
+		},
+		Spec: batchv1.JobSpec{
+			TTLSecondsAfterFinished: &ttl,
+			BackoffLimit:            &backoffLimit,
+			Template: v1.PodTemplateSpec{
+				ObjectMeta: metav1.ObjectMeta{
+					Labels: labels,
+				},
+				Spec: v1.PodSpec{
+					RestartPolicy: v1.RestartPolicyOnFailure,
+					Containers: []v1.Container{
+						{
+							Name:  "provisioner",
+							Image: "gcr.io/porter-dev-273614/provisioner:latest",
+							Args:  args,
+							Env:   env,
+							VolumeMounts: []v1.VolumeMount{
+								v1.VolumeMount{
+									MountPath: "/.terraform/plugin-cache",
+									Name:      "tf-cache",
+									ReadOnly:  true,
+								},
+							},
+						},
+					},
+					Volumes: []v1.Volume{
+						v1.Volume{
+							Name: "tf-cache",
+							VolumeSource: v1.VolumeSource{
+								PersistentVolumeClaim: &v1.PersistentVolumeClaimVolumeSource{
+									ClaimName: "tf-cache-pvc",
+									ReadOnly:  true,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}, nil
+}
+
+// GetRedisStreamID returns the stream id that should be used
+func (conf *Conf) GetRedisStreamID() string {
+	return conf.ID
+}
+
+// GetTFWorkspaceID returns the workspace id that should be used
+func (conf *Conf) GetTFWorkspaceID() string {
+	return conf.ID
+}
+
+// attaches the env variables required by all provisioner instances
+func (conf *Conf) attachDefaultEnv(env []v1.EnvVar) []v1.EnvVar {
+	env = conf.addRedisEnv(env)
+	env = conf.addPostgresEnv(env)
+	env = conf.addTFEnv(env)
+
+	return env
+}
+
+// adds the env variables required for the Redis stream
+func (conf *Conf) addRedisEnv(env []v1.EnvVar) []v1.EnvVar {
+	env = append(env, v1.EnvVar{
+		Name:  "REDIS_ENABLED",
+		Value: "true",
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "REDIS_HOST",
+		Value: conf.Redis.Host,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "REDIS_PORT",
+		Value: conf.Redis.Port,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "REDIS_USER",
+		Value: "default",
+	})
+
+	env = append(env, v1.EnvVar{
+		Name: "REDIS_PASS",
+		ValueFrom: &v1.EnvVarSource{
+			SecretKeyRef: &v1.SecretKeySelector{
+				LocalObjectReference: v1.LocalObjectReference{
+					Name: "redis",
+				},
+				Key: "redis-password",
+			},
+		},
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "REDIS_STREAM_ID",
+		Value: conf.GetRedisStreamID(),
+	})
+
+	return env
+}
+
+// adds the env variables required for the PG backend
+func (conf *Conf) addPostgresEnv(env []v1.EnvVar) []v1.EnvVar {
+	env = append(env, v1.EnvVar{
+		Name:  "PG_HOST",
+		Value: conf.Postgres.Host,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "PG_PORT",
+		Value: conf.Postgres.Port,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "PG_USER",
+		Value: "postgres",
+	})
+
+	env = append(env, v1.EnvVar{
+		Name: "PG_PASS",
+		ValueFrom: &v1.EnvVarSource{
+			SecretKeyRef: &v1.SecretKeySelector{
+				LocalObjectReference: v1.LocalObjectReference{
+					Name: "postgres-postgresql",
+				},
+				Key: "postgresql-password",
+			},
+		},
+	})
+
+	return env
+}
+
+func (conf *Conf) addTFEnv(env []v1.EnvVar) []v1.EnvVar {
+	env = append(env, v1.EnvVar{
+		Name:  "TF_DIR",
+		Value: "./terraform",
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "TF_PLUGIN_CACHE_DIR",
+		Value: "/.terraform/plugin-cache",
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "TF_PORTER_BACKEND",
+		Value: "postgres",
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "TF_PORTER_WORKSPACE",
+		Value: conf.GetTFWorkspaceID(),
+	})
+
+	return env
+}

+ 63 - 0
internal/kubernetes/provisioner/resource_stream.go

@@ -0,0 +1,63 @@
+package provisioner
+
+import (
+	"context"
+	"fmt"
+
+	redis "github.com/go-redis/redis/v8"
+	"github.com/gorilla/websocket"
+)
+
+// ResourceStream performs an XREAD operation on the given stream and outputs it to the given websocket conn.
+func ResourceStream(client *redis.Client, streamName string, conn *websocket.Conn) error {
+	errorchan := make(chan error)
+
+	go func() {
+		// listens for websocket closing handshake
+		for {
+			_, _, err := conn.ReadMessage()
+
+			if err != nil {
+				defer conn.Close()
+				errorchan <- err
+				return
+			}
+		}
+	}()
+
+	go func() {
+		lastID := "0-0"
+
+		for {
+
+			xstream, err := client.XRead(
+				context.Background(),
+				&redis.XReadArgs{
+					Streams: []string{streamName, lastID},
+					Block:   0,
+				},
+			).Result()
+
+			if err != nil {
+				return
+			}
+
+			messages := xstream[0].Messages
+			lastID = messages[len(messages)-1].ID
+
+			if writeErr := conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprint(xstream))); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		}
+	}()
+
+	for {
+		select {
+		case err := <-errorchan:
+			close(errorchan)
+			client.Close()
+			return err
+		}
+	}
+}

+ 91 - 0
internal/models/infra.go

@@ -0,0 +1,91 @@
+package models
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+
+	"gorm.io/gorm"
+)
+
+// InfraStatus is the status that an infrastructure can take
+type InfraStatus string
+
+// The allowed statuses
+const (
+	StatusCreating InfraStatus = "creating"
+	StatusCreated  InfraStatus = "created"
+)
+
+// AWSInfraKind is the kind that aws infra can be
+type AWSInfraKind string
+
+// The supported AWS infra kinds
+const (
+	AWSInfraECR AWSInfraKind = "ecr"
+	AWSInfraEKS AWSInfraKind = "eks"
+)
+
+// AWSInfra represents the metadata for an infrastructure type provisioned on
+// AWS
+type AWSInfra struct {
+	gorm.Model
+
+	// The type of infra that was provisioned
+	Kind AWSInfraKind `json:"kind"`
+
+	// The project that this infra belongs to
+	ProjectID uint `json:"project_id"`
+
+	// Status is the status of the infra
+	Status InfraStatus `json:"status"`
+
+	// The AWS integration that was used to create the infra
+	AWSIntegrationID uint
+}
+
+// AWSInfraExternal is an external AWSInfra to be shared over REST
+type AWSInfraExternal struct {
+	ID uint `json:"id"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	// The type of infra that was provisioned
+	Kind AWSInfraKind `json:"kind"`
+
+	// Status is the status of the infra
+	Status InfraStatus `json:"status"`
+}
+
+// Externalize generates an external AWSInfra to be shared over REST
+func (ai *AWSInfra) Externalize() *AWSInfraExternal {
+	return &AWSInfraExternal{
+		ID:        ai.ID,
+		ProjectID: ai.ProjectID,
+		Kind:      ai.Kind,
+		Status:    ai.Status,
+	}
+}
+
+// GetWorkspaceID returns the unique workspace id for this infra
+func (ai *AWSInfra) GetWorkspaceID() string {
+	return fmt.Sprintf("%s-%d-%d", ai.Kind, ai.ProjectID, ai.ID)
+}
+
+// GetInfraIDFromWorkspaceID returns the infra id given a workspace id
+func GetInfraIDFromWorkspaceID(workspaceID string) (uint, error) {
+	strArr := strings.Split(workspaceID, "-")
+
+	if len(strArr) != 3 {
+		return 0, fmt.Errorf("workspace id improperly formatted")
+	}
+
+	u, err := strconv.ParseUint(strArr[2], 10, 64)
+
+	if err != nil {
+		return 0, err
+	}
+
+	return uint(u), nil
+}

+ 31 - 15
internal/models/integrations/aws.go

@@ -4,6 +4,7 @@ import (
 	"gorm.io/gorm"
 
 	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/service/sts"
 
 	"github.com/aws/aws-sdk-go/aws/credentials"
 	"github.com/aws/aws-sdk-go/aws/session"
@@ -21,11 +22,8 @@ type AWSIntegration struct {
 	// The project that this integration belongs to
 	ProjectID uint `json:"project_id"`
 
-	// The AWS entity this is linked to (individual or organization)
-	AWSEntityID string `json:"aws-entity-id"`
-
-	// The AWS caller identity (ARN) which linked this service
-	AWSCallerID string `json:"aws-caller-id"`
+	// The AWS arn this is integration is linked to
+	AWSArn string `json:"aws_arn"`
 
 	// The optional AWS region (required by some session configurations)
 	AWSRegion string `json:"aws_region"`
@@ -58,21 +56,17 @@ type AWSIntegrationExternal struct {
 	// The project that this integration belongs to
 	ProjectID uint `json:"project_id"`
 
-	// The AWS entity this is linked to (individual or organization)
-	AWSEntityID string `json:"aws-entity-id"`
-
-	// The AWS caller identity (ARN) which linked this service
-	AWSCallerID string `json:"aws-caller-id"`
+	// The AWS arn this is integration is linked to
+	AWSArn string `json:"aws_arn"`
 }
 
 // Externalize generates an external KubeIntegration to be shared over REST
 func (a *AWSIntegration) Externalize() *AWSIntegrationExternal {
 	return &AWSIntegrationExternal{
-		ID:          a.ID,
-		UserID:      a.UserID,
-		ProjectID:   a.ProjectID,
-		AWSEntityID: a.AWSEntityID,
-		AWSCallerID: a.AWSCallerID,
+		ID:        a.ID,
+		UserID:    a.UserID,
+		ProjectID: a.ProjectID,
+		AWSArn:    a.AWSArn,
 	}
 }
 
@@ -111,6 +105,28 @@ func (a *AWSIntegration) GetSession() (*session.Session, error) {
 	})
 }
 
+// PopulateAWSArn uses the access key/secret to get the caller identity, and
+// attaches it to the AWS integration
+func (a *AWSIntegration) PopulateAWSArn() error {
+	sess, err := a.GetSession()
+
+	if err != nil {
+		return err
+	}
+
+	svc := sts.New(sess)
+
+	result, err := svc.GetCallerIdentity(&sts.GetCallerIdentityInput{})
+
+	if err != nil {
+		return err
+	}
+
+	a.AWSArn = *result.Arn
+
+	return nil
+}
+
 // GetBearerToken retrieves a bearer token for an AWS account
 func (a *AWSIntegration) GetBearerToken(
 	getTokenCache GetTokenCacheFunc,

+ 3 - 0
internal/models/project.go

@@ -26,6 +26,9 @@ type Project struct {
 	// linked helm repos
 	HelmRepos []HelmRepo `json:"helm_repos"`
 
+	// provisioned aws infra
+	AWSInfras []AWSInfra `json:"aws_infras"`
+
 	// auth mechanisms
 	KubeIntegrations  []ints.KubeIntegration  `json:"kube_integrations"`
 	BasicIntegrations []ints.BasicIntegration `json:"basic_integrations"`

+ 0 - 4
internal/repository/gorm/auth_test.go

@@ -465,8 +465,6 @@ func TestCreateAWSIntegration(t *testing.T) {
 	aws := &ints.AWSIntegration{
 		ProjectID:          tester.initProjects[0].ID,
 		UserID:             tester.initUsers[0].ID,
-		AWSEntityID:        "entity",
-		AWSCallerID:        "caller",
 		AWSClusterID:       []byte("example-cluster-0"),
 		AWSAccessKeyID:     []byte("accesskey"),
 		AWSSecretAccessKey: []byte("secret"),
@@ -527,8 +525,6 @@ func TestListAWSIntegrationsByProjectID(t *testing.T) {
 	expAWS := ints.AWSIntegration{
 		ProjectID:          tester.initProjects[0].ID,
 		UserID:             tester.initUsers[0].ID,
-		AWSEntityID:        "entity",
-		AWSCallerID:        "caller",
 		AWSClusterID:       []byte("example-cluster-0"),
 		AWSAccessKeyID:     []byte("accesskey"),
 		AWSSecretAccessKey: []byte("secret"),

+ 40 - 18
internal/repository/gorm/helpers_test.go

@@ -13,22 +13,23 @@ import (
 )
 
 type tester struct {
-	repo         *repository.Repository
-	key          *[32]byte
-	dbFileName   string
-	initUsers    []*models.User
-	initProjects []*models.Project
-	initGRs      []*models.GitRepo
-	initRegs     []*models.Registry
-	initClusters []*models.Cluster
-	initHRs      []*models.HelmRepo
-	initCCs      []*models.ClusterCandidate
-	initKIs      []*ints.KubeIntegration
-	initBasics   []*ints.BasicIntegration
-	initOIDCs    []*ints.OIDCIntegration
-	initOAuths   []*ints.OAuthIntegration
-	initGCPs     []*ints.GCPIntegration
-	initAWSs     []*ints.AWSIntegration
+	repo          *repository.Repository
+	key           *[32]byte
+	dbFileName    string
+	initUsers     []*models.User
+	initProjects  []*models.Project
+	initGRs       []*models.GitRepo
+	initRegs      []*models.Registry
+	initClusters  []*models.Cluster
+	initHRs       []*models.HelmRepo
+	initAWSInfras []*models.AWSInfra
+	initCCs       []*models.ClusterCandidate
+	initKIs       []*ints.KubeIntegration
+	initBasics    []*ints.BasicIntegration
+	initOIDCs     []*ints.OIDCIntegration
+	initOAuths    []*ints.OAuthIntegration
+	initGCPs      []*ints.GCPIntegration
+	initAWSs      []*ints.AWSIntegration
 }
 
 func setupTestEnv(tester *tester, t *testing.T) {
@@ -56,6 +57,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&models.Cluster{},
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
+		&models.AWSInfra{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
@@ -294,8 +296,6 @@ func initAWSIntegration(tester *tester, t *testing.T) {
 	aws := &ints.AWSIntegration{
 		ProjectID:          tester.initProjects[0].ID,
 		UserID:             tester.initUsers[0].ID,
-		AWSEntityID:        "entity",
-		AWSCallerID:        "caller",
 		AWSClusterID:       []byte("example-cluster-0"),
 		AWSAccessKeyID:     []byte("accesskey"),
 		AWSSecretAccessKey: []byte("secret"),
@@ -435,3 +435,25 @@ func initHelmRepo(tester *tester, t *testing.T) {
 
 	tester.initHRs = append(tester.initHRs, hr)
 }
+
+func initAWSInfra(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	infra := &models.AWSInfra{
+		Kind:      models.AWSInfraECR,
+		ProjectID: tester.initProjects[0].Model.ID,
+		Status:    models.StatusCreated,
+	}
+
+	infra, err := tester.repo.AWSInfra.CreateAWSInfra(infra)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initAWSInfras = append(tester.initAWSInfras, infra)
+}

+ 75 - 0
internal/repository/gorm/infra.go

@@ -0,0 +1,75 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// AWSInfraRepository uses gorm.DB for querying the database
+type AWSInfraRepository struct {
+	db *gorm.DB
+}
+
+// NewAWSInfraRepository returns a AWSInfraRepository which uses
+// gorm.DB for querying the database
+func NewAWSInfraRepository(db *gorm.DB) repository.AWSInfraRepository {
+	return &AWSInfraRepository{db}
+}
+
+// CreateAWSInfra creates a new aws infra
+func (repo *AWSInfraRepository) CreateAWSInfra(infra *models.AWSInfra) (*models.AWSInfra, error) {
+	project := &models.Project{}
+
+	if err := repo.db.Where("id = ?", infra.ProjectID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&project).Association("AWSInfras")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(infra); err != nil {
+		return nil, err
+	}
+
+	return infra, nil
+}
+
+// ReadAWSInfra gets a aws infra specified by a unique id
+func (repo *AWSInfraRepository) ReadAWSInfra(id uint) (*models.AWSInfra, error) {
+	infra := &models.AWSInfra{}
+
+	if err := repo.db.Where("id = ?", id).First(&infra).Error; err != nil {
+		return nil, err
+	}
+
+	return infra, nil
+}
+
+// ListAWSInfrasByProjectID finds all aws infras
+// for a given project id
+func (repo *AWSInfraRepository) ListAWSInfrasByProjectID(
+	projectID uint,
+) ([]*models.AWSInfra, error) {
+	infras := []*models.AWSInfra{}
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&infras).Error; err != nil {
+		return nil, err
+	}
+
+	return infras, nil
+}
+
+// UpdateAWSInfra modifies an existing AWSInfra in the database
+func (repo *AWSInfraRepository) UpdateAWSInfra(
+	ai *models.AWSInfra,
+) (*models.AWSInfra, error) {
+	if err := repo.db.Save(ai).Error; err != nil {
+		return nil, err
+	}
+
+	return ai, nil
+}

+ 90 - 0
internal/repository/gorm/infra_test.go

@@ -0,0 +1,90 @@
+package gorm_test
+
+import (
+	"testing"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+func TestCreateAWSInfra(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_aws_infra.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	defer cleanup(tester, t)
+
+	infra := &models.AWSInfra{
+		Kind:      models.AWSInfraECR,
+		ProjectID: tester.initProjects[0].Model.ID,
+		Status:    models.StatusCreated,
+	}
+
+	infra, err := tester.repo.AWSInfra.CreateAWSInfra(infra)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	infra, err = tester.repo.AWSInfra.ReadAWSInfra(infra.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1 and name is "ecr"
+	if infra.Model.ID != 1 {
+		t.Errorf("incorrect registry ID: expected %d, got %d\n", 1, infra.Model.ID)
+	}
+
+	if infra.Kind != models.AWSInfraECR {
+		t.Errorf("incorrect aws infra kind: expected %s, got %s\n", models.AWSInfraECR, infra.Kind)
+	}
+
+	if infra.Status != models.StatusCreated {
+		t.Errorf("incorrect aws infra status: expected %s, got %s\n", models.StatusCreated, infra.Status)
+	}
+}
+
+func TestListAWSInfrasByProjectID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_list_aws_infras.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initAWSInfra(tester, t)
+	defer cleanup(tester, t)
+
+	infras, err := tester.repo.AWSInfra.ListAWSInfrasByProjectID(
+		tester.initProjects[0].Model.ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(infras) != 1 {
+		t.Fatalf("length of aws infras incorrect: expected %d, got %d\n", 1, len(infras))
+	}
+
+	// make sure data is correct
+	expAWSInfra := models.AWSInfra{
+		Kind:      "ecr",
+		ProjectID: tester.initProjects[0].Model.ID,
+		Status:    models.StatusCreated,
+	}
+
+	infra := infras[0]
+
+	// reset fields for reflect.DeepEqual
+	infra.Model = gorm.Model{}
+
+	if diff := deep.Equal(expAWSInfra, *infra); diff != nil {
+		t.Errorf("incorrect aws infra")
+		t.Error(diff)
+	}
+}

+ 1 - 0
internal/repository/gorm/repository.go

@@ -17,6 +17,7 @@ func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 		Cluster:          NewClusterRepository(db, key),
 		HelmRepo:         NewHelmRepoRepository(db, key),
 		Registry:         NewRegistryRepository(db, key),
+		AWSInfra:         NewAWSInfraRepository(db),
 		KubeIntegration:  NewKubeIntegrationRepository(db, key),
 		BasicIntegration: NewBasicIntegrationRepository(db, key),
 		OIDCIntegration:  NewOIDCIntegrationRepository(db, key),

+ 13 - 0
internal/repository/infra.go

@@ -0,0 +1,13 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// AWSInfraRepository represents the set of queries on the AWSInfra model
+type AWSInfraRepository interface {
+	CreateAWSInfra(repo *models.AWSInfra) (*models.AWSInfra, error)
+	ReadAWSInfra(id uint) (*models.AWSInfra, error)
+	ListAWSInfrasByProjectID(projectID uint) ([]*models.AWSInfra, error)
+	UpdateAWSInfra(repo *models.AWSInfra) (*models.AWSInfra, error)
+}

+ 91 - 0
internal/repository/memory/infra.go

@@ -0,0 +1,91 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// AWSInfraRepository implements repository.AWSInfraRepository
+type AWSInfraRepository struct {
+	canQuery  bool
+	awsInfras []*models.AWSInfra
+}
+
+// NewAWSInfraRepository will return errors if canQuery is false
+func NewAWSInfraRepository(canQuery bool) repository.AWSInfraRepository {
+	return &AWSInfraRepository{
+		canQuery,
+		[]*models.AWSInfra{},
+	}
+}
+
+// CreateAWSInfra creates a new aws infra
+func (repo *AWSInfraRepository) CreateAWSInfra(
+	infra *models.AWSInfra,
+) (*models.AWSInfra, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.awsInfras = append(repo.awsInfras, infra)
+	infra.ID = uint(len(repo.awsInfras))
+
+	return infra, nil
+}
+
+// ReadAWSInfra finds a aws infra by id
+func (repo *AWSInfraRepository) ReadAWSInfra(
+	id uint,
+) (*models.AWSInfra, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.awsInfras) || repo.awsInfras[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.awsInfras[index], nil
+}
+
+// ListAWSInfrasByProjectID finds all aws infras
+// for a given project id
+func (repo *AWSInfraRepository) ListAWSInfrasByProjectID(
+	projectID uint,
+) ([]*models.AWSInfra, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	res := make([]*models.AWSInfra, 0)
+
+	for _, infra := range repo.awsInfras {
+		if infra != nil && infra.ProjectID == projectID {
+			res = append(res, infra)
+		}
+	}
+
+	return res, nil
+}
+
+// UpdateAWSInfra modifies an existing AWSInfra in the database
+func (repo *AWSInfraRepository) UpdateAWSInfra(
+	ai *models.AWSInfra,
+) (*models.AWSInfra, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(ai.ID-1) >= len(repo.awsInfras) || repo.awsInfras[ai.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(ai.ID - 1)
+	repo.awsInfras[index] = ai
+
+	return ai, nil
+}

+ 1 - 0
internal/repository/repository.go

@@ -10,6 +10,7 @@ type Repository struct {
 	Cluster          ClusterRepository
 	HelmRepo         HelmRepoRepository
 	Registry         RegistryRepository
+	AWSInfra         AWSInfraRepository
 	KubeIntegration  KubeIntegrationRepository
 	BasicIntegration BasicIntegrationRepository
 	OIDCIntegration  OIDCIntegrationRepository

+ 17 - 70
server/api/api.go

@@ -6,6 +6,7 @@ import (
 	"github.com/go-playground/locales/en"
 	ut "github.com/go-playground/universal-translator"
 	vr "github.com/go-playground/validator/v10"
+	"github.com/go-redis/redis/v8"
 	sessionstore "github.com/porter-dev/porter/internal/auth"
 	"github.com/porter-dev/porter/internal/oauth"
 	"golang.org/x/oauth2"
@@ -32,10 +33,11 @@ type TestAgents struct {
 
 // AppConfig is the configuration required for creating a new App
 type AppConfig struct {
-	DB         *gorm.DB
-	Logger     *lr.Logger
-	Repository *repository.Repository
-	ServerConf config.ServerConf
+	DB          *gorm.DB
+	Logger      *lr.Logger
+	Repository  *repository.Repository
+	ServerConf  config.ServerConf
+	RedisClient *redis.Client
 
 	// TestAgents if API is in testing mode
 	TestAgents *TestAgents
@@ -59,6 +61,9 @@ type App struct {
 	// agents exposed for testing
 	TestAgents *TestAgents
 
+	// redis conf for redis connection
+	RedisClient *redis.Client
+
 	// oauth-specific clients
 	GithubConf *oauth2.Config
 
@@ -81,13 +86,14 @@ func New(conf *AppConfig) (*App, error) {
 	}
 
 	app := &App{
-		Logger:     conf.Logger,
-		Repo:       conf.Repository,
-		ServerConf: conf.ServerConf,
-		TestAgents: conf.TestAgents,
-		db:         conf.DB,
-		validator:  validator,
-		translator: &translator,
+		Logger:      conf.Logger,
+		Repo:        conf.Repository,
+		ServerConf:  conf.ServerConf,
+		RedisClient: conf.RedisClient,
+		TestAgents:  conf.TestAgents,
+		db:          conf.DB,
+		validator:   validator,
+		translator:  &translator,
 	}
 
 	// if repository not specified, default to in-memory
@@ -116,62 +122,3 @@ func New(conf *AppConfig) (*App, error) {
 
 	return app, nil
 }
-
-// // New returns a new App instance
-// // TODO -- this should accept an app/server config
-// func New(
-// 	logger *lr.Logger,
-// 	db *gorm.DB,
-// 	repo *repository.Repository,
-// 	validator *validator.Validate,
-// 	store sessions.Store,
-// 	cookieName string,
-// 	testing bool,
-// 	isLocal bool,
-// 	githubConfig *oauth.Config,
-// 	serverConf config.ServerConf,
-// ) *App {
-// 	// for now, will just support the english translator from the
-// 	// validator/translations package
-// 	en := en.New()
-// 	uni := ut.New(en, en)
-// 	trans, _ := uni.GetTranslator("en")
-
-// 	var testAgents *TestAgents = nil
-
-// 	if testing {
-// 		memStorage := helm.StorageMap["memory"](nil, nil, "")
-
-// 		testAgents = &TestAgents{
-// 			HelmAgent:             helm.GetAgentTesting(&helm.Form{}, nil, logger),
-// 			HelmTestStorageDriver: memStorage,
-// 			K8sAgent:              kubernetes.GetAgentTesting(),
-// 		}
-// 	}
-
-// 	var oauthGithubConf *oauth2.Config
-
-// 	if githubConfig != nil {
-// 		oauthGithubConf = oauth.NewGithubClient(githubConfig)
-// 	}
-
-// 	return &App{
-// 		db:           db,
-// 		logger:       logger,
-// 		repo:         repo,
-// 		validator:    validator,
-// 		store:        store,
-// 		translator:   &trans,
-// 		cookieName:   cookieName,
-// 		testing:      testing,
-// 		isLocal:      isLocal,
-// 		TestAgents:   testAgents,
-// 		GithubConfig: oauthGithubConf,
-// 		ServerConf:   serverConf,
-// 	}
-// }
-
-// // Logger returns the logger instance in use by App
-// func (app *App) Logger() *lr.Logger {
-// 	return app.logger
-// }

+ 147 - 0
server/api/provision_handler.go

@@ -0,0 +1,147 @@
+package api
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strconv"
+
+	"github.com/go-chi/chi"
+
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
+)
+
+// HandleProvisionTest will create a test resource by deploying a provisioner
+// container pod
+func (app *App) HandleProvisionTest(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// create a new agent
+	agent, err := kubernetes.GetAgentInClusterConfig()
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	_, err = agent.ProvisionTest(uint(projID))
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}
+
+// HandleProvisionAWSECRInfra provisions a new aws ECR instance for a project
+func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	form := &forms.CreateECRInfra{
+		ProjectID: uint(projID),
+	}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// convert the form to an aws infra instance
+	infra, err := form.ToAWSInfra()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	infra, err = app.Repo.AWSInfra.CreateAWSInfra(infra)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	awsInt, err := app.Repo.AWSIntegration.ReadAWSIntegration(infra.AWSIntegrationID)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// launch provisioning pod
+	agent, err := kubernetes.GetAgentInClusterConfig()
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	_, err = agent.ProvisionECR(
+		uint(projID),
+		awsInt,
+		form.ECRName,
+	)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	app.Logger.Info().Msgf("New aws ecr infra created: %d", infra.ID)
+
+	w.WriteHeader(http.StatusCreated)
+
+	infraExt := infra.Externalize()
+
+	if err := json.NewEncoder(w).Encode(infraExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleGetProvisioningLogs returns real-time logs of the provisioning process via websockets
+func (app *App) HandleGetProvisioningLogs(w http.ResponseWriter, r *http.Request) {
+	// get path parameters
+	kind := chi.URLParam(r, "kind")
+	projectID := chi.URLParam(r, "project_id")
+	infraID := chi.URLParam(r, "infra_id")
+
+	streamName := fmt.Sprintf("%s-%s-%s", kind, projectID, infraID)
+
+	upgrader.CheckOrigin = func(r *http.Request) bool { return true }
+
+	// upgrade to websocket.
+	conn, err := upgrader.Upgrade(w, r, nil)
+
+	if err != nil {
+		app.handleErrorUpgradeWebsocket(err, w)
+	}
+
+	err = provisioner.ResourceStream(app.RedisClient, streamName, conn)
+
+	if err != nil {
+		app.handleErrorWebsocketWrite(err, w)
+		return
+	}
+}

+ 33 - 0
server/router/router.go

@@ -177,6 +177,39 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		// /api/projects/{project_id}/provision routes
+
+		// TODO -- restrict this endpoint
+		r.Method(
+			"GET",
+			"/projects/{project_id}/provision/test",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleProvisionTest, l),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/provision/ecr",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleProvisionAWSECRInfra, l),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/projects/{project_id}/provision/{kind}/{infra_id}/logs",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleGetProvisioningLogs, l),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		// /api/projects/{project_id}/clusters routes
 		r.Method(
 			"GET",

Некоторые файлы не были показаны из-за большого количества измененных файлов