Bläddra i källkod

Merge pull request #367 from porter-dev/beta.3.buildpack-ci

Beta.3.buildpack ci
abelanger5 5 år sedan
förälder
incheckning
c33b9cbab1
35 ändrade filer med 1565 tillägg och 612 borttagningar
  1. 66 0
      dashboard/src/components/RadioSelector.tsx
  2. 71 34
      dashboard/src/components/repo-selector/ActionConfEditor.tsx
  3. 251 37
      dashboard/src/components/repo-selector/ActionDetails.tsx
  4. 18 1
      dashboard/src/components/repo-selector/BranchList.tsx
  5. 0 158
      dashboard/src/components/repo-selector/ButtonTray.tsx
  6. 228 30
      dashboard/src/components/repo-selector/ContentsList.tsx
  7. 32 1
      dashboard/src/components/repo-selector/RepoList.tsx
  8. 0 177
      dashboard/src/components/repo-selector/RepoSelector.tsx
  9. 7 0
      dashboard/src/components/values-form/CheckboxRow.tsx
  10. 0 2
      dashboard/src/components/values-form/Helper.tsx
  11. 1 1
      dashboard/src/components/values-form/InputRow.tsx
  12. 8 1
      dashboard/src/components/values-form/ValuesForm.tsx
  13. 0 69
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  14. 283 71
      dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx
  15. 1 0
      dashboard/src/main/home/new-project/NewProject.tsx
  16. 62 3
      dashboard/src/main/home/provisioner/AWSFormSection.tsx
  17. 50 1
      dashboard/src/main/home/provisioner/DOFormSection.tsx
  18. 61 2
      dashboard/src/main/home/provisioner/GCPFormSection.tsx
  19. 2 0
      dashboard/src/main/home/provisioner/ProvisionerSettings.tsx
  20. 1 1
      dashboard/src/main/home/sidebar/Sidebar.tsx
  21. 3 0
      dashboard/src/shared/api.tsx
  22. 0 1
      dashboard/src/shared/types.tsx
  23. 16 9
      internal/forms/git_action.go
  24. 12 0
      internal/forms/metrics.go
  25. 43 7
      internal/integrations/ci/actions/actions.go
  26. 20 2
      internal/integrations/ci/actions/steps.go
  27. 145 0
      internal/kubernetes/prometheus/metrics.go
  28. 9 2
      internal/models/gitrepo.go
  29. 42 0
      internal/registry/registry.go
  30. 2 0
      server/api/deploy_handler.go
  31. 30 0
      server/api/git_action_handler.go
  32. 71 0
      server/api/k8s_handler.go
  33. 3 2
      server/router/middleware/auth.go
  34. 14 0
      server/router/router.go
  35. 13 0
      services/deploy_init_container/Dockerfile

+ 66 - 0
dashboard/src/components/RadioSelector.tsx

@@ -0,0 +1,66 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+type PropsType = {
+  selected: string;
+  setSelected: (x: string) => void;
+  options: { value: string; label: string }[];
+};
+
+type StateType = {};
+
+export default class RadioSelector extends Component<PropsType, StateType> {
+  render() {
+    return (
+      <StyledRadioSelector>
+        {this.props.options.map(
+          (option: { label: string; value: string }, i: number) => {
+            let selected = option.value === this.props.selected;
+            return (
+              <RadioRow onClick={() => this.props.setSelected(option.value)}>
+                <Indicator selected={selected}>
+                  {selected && <Circle />}
+                </Indicator>
+                {option.label}
+              </RadioRow>
+            );
+          }
+        )}
+      </StyledRadioSelector>
+    );
+  }
+}
+
+const RadioRow = styled.div`
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  margin-bottom: 12px;
+  :hover {
+    > div {
+      background: #ffffff22;
+    }
+  }
+`;
+
+const Indicator = styled.div<{ selected: boolean }>`
+  border-radius: 15px;
+  display: flex;
+  margin-right: 4px;
+  align-items: center;
+  justify-content: center;
+  width: 16px;
+  height: 16px;
+  border: 1px solid #ffffff55;
+  margin: 1px 10px 0px 1px;
+  background: ${(props) => (props.selected ? "#ffffff22" : "#ffffff11")};
+`;
+
+const Circle = styled.div`
+  width: 8px;
+  height: 8px;
+  background: #ffffff55;
+  border-radius: 15px;
+`;
+
+const StyledRadioSelector = styled.div``;

+ 71 - 34
dashboard/src/components/repo-selector/ActionConfEditor.tsx

@@ -12,11 +12,15 @@ import ActionDetails from "./ActionDetails";
 type PropsType = {
   actionConfig: ActionConfigType | null;
   branch: string;
-  pathIsSet: boolean;
   setActionConfig: (x: ActionConfigType) => void;
   setBranch: (x: string) => void;
-  setPath: (x: boolean) => void;
   reset: any;
+  dockerfilePath: string;
+  setDockerfilePath: (x: string) => void;
+  folderPath: string;
+  setFolderPath: (x: string) => void;
+  setSelectedRegistry: (x: any) => void;
+  selectedRegistry: any;
 };
 
 type StateType = {
@@ -24,6 +28,12 @@ type StateType = {
   error: boolean;
 };
 
+const defaultActionConfig: ActionConfigType = {
+  git_repo: "",
+  image_repo_uri: "",
+  git_repo_id: 0,
+};
+
 export default class ActionConfEditor extends Component<PropsType, StateType> {
   state = {
     loading: true,
@@ -31,14 +41,7 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
   };
 
   renderExpanded = () => {
-    let {
-      actionConfig,
-      branch,
-      pathIsSet,
-      setActionConfig,
-      setBranch,
-      setPath,
-    } = this.props;
+    let { actionConfig, branch, setActionConfig, setBranch } = this.props;
 
     if (!actionConfig.git_repo) {
       return (
@@ -50,7 +53,8 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
           />
         </ExpandedWrapper>
       );
-    } else if (!branch) {
+    } else if (!this.props.dockerfilePath && !this.props.folderPath) {
+      /* else if (!branch) {
       return (
         <>
           <ExpandedWrapperAlt>
@@ -59,10 +63,14 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
               setBranch={(branch: string) => setBranch(branch)}
             />
           </ExpandedWrapperAlt>
-          {this.renderResetButton()}
+          <Br />
+          <BackButton width="135px" onClick={() => setActionConfig({ ...defaultActionConfig })}>
+            <i className="material-icons">keyboard_backspace</i>
+            Select Repo
+          </BackButton>
         </>
       );
-    } else if (!pathIsSet) {
+    } */
       return (
         <>
           <ExpandedWrapperAlt>
@@ -70,32 +78,33 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
               actionConfig={actionConfig}
               branch={branch}
               setActionConfig={setActionConfig}
-              setPath={() => setPath(true)}
+              setDockerfilePath={(x: string) => this.props.setDockerfilePath(x)}
+              setFolderPath={(x: string) => this.props.setFolderPath(x)}
             />
           </ExpandedWrapperAlt>
-          {this.renderResetButton()}
+          <Br />
+          <BackButton
+            width="135px"
+            onClick={() => setActionConfig({ ...defaultActionConfig })}
+          >
+            <i className="material-icons">keyboard_backspace</i>
+            Select Repo
+          </BackButton>
         </>
       );
     }
     return (
-      <>
-        <ExpandedWrapperAlt>
-          <ActionDetails
-            actionConfig={actionConfig}
-            setActionConfig={setActionConfig}
-          />
-        </ExpandedWrapperAlt>
-        {this.renderResetButton()}
-      </>
-    );
-  };
-
-  renderResetButton = () => {
-    return (
-      <BackButton width="150px" onClick={this.props.reset}>
-        <i className="material-icons">keyboard_backspace</i>
-        Reset Selection
-      </BackButton>
+      <ActionDetails
+        branch={branch}
+        setDockerfilePath={this.props.setDockerfilePath}
+        setFolderPath={this.props.setFolderPath}
+        actionConfig={actionConfig}
+        setActionConfig={setActionConfig}
+        dockerfilePath={this.props.dockerfilePath}
+        folderPath={this.props.folderPath}
+        setSelectedRegistry={this.props.setSelectedRegistry}
+        selectedRegistry={this.props.selectedRegistry}
+      />
     );
   };
 
@@ -106,6 +115,31 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
 
 ActionConfEditor.contextType = Context;
 
+const Br = styled.div`
+  width: 100%;
+  height: 8px;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const HeaderButton = styled.div`
+  margin-bottom: 5px;
+  padding: 5px 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: 500;
+  margin-right: 10px;
+`;
+
+const RepoHeader = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
 const ExpandedWrapper = styled.div`
   margin-top: 10px;
   width: 100%;
@@ -121,10 +155,13 @@ const BackButton = styled.div`
   display: flex;
   align-items: center;
   justify-content: space-between;
-  margin-top: 10px;
+  margin-top: 22px;
   cursor: pointer;
   font-size: 13px;
+  height: 35px;
   padding: 5px 13px;
+  margin-bottom: -7px;
+  padding-right: 15px;
   border: 1px solid #ffffff55;
   border-radius: 3px;
   width: ${(props: { width: string }) => props.width};

+ 251 - 37
dashboard/src/components/repo-selector/ActionDetails.tsx

@@ -2,86 +2,300 @@ import ImageSelector from "components/image-selector/ImageSelector";
 import React, { Component } from "react";
 import styled from "styled-components";
 
+import { integrationList } from "shared/common";
 import { Context } from "../../shared/Context";
+import api from "../../shared/api";
+import Loading from "components/Loading";
 import { ActionConfigType } from "../../shared/types";
 import InputRow from "../values-form/InputRow";
 
 type PropsType = {
   actionConfig: ActionConfigType | null;
   setActionConfig: (x: ActionConfigType) => void;
+  branch: string;
+  dockerfilePath: string;
+  folderPath: string;
+  setSelectedRegistry: (x: any) => void;
+  selectedRegistry: any;
+  setDockerfilePath: (x: string) => void;
+  setFolderPath: (x: string) => void;
 };
 
 type StateType = {
   dockerRepo: string;
   error: boolean;
+  registries: any[] | null;
+  loading: boolean;
 };
 
+const dummyRegistries = [
+  { id: 1, service: "ecr", url: "https://idfkasdfasdf" },
+  { id: 12, service: "ecr", url: "https://dfasdfidfkasdfasdf" },
+  { id: 11, service: "gcr", url: "https://idfkasdfasdf" },
+] as any[];
+
 export default class ActionDetails extends Component<PropsType, StateType> {
   state = {
     dockerRepo: "",
     error: false,
+    registries: null as any[] | null,
+    loading: true,
   };
 
-  setPath = (x: string) => {
-    let { actionConfig, setActionConfig } = this.props;
-    let updatedConfig = actionConfig;
-    updatedConfig.dockerfile_path = updatedConfig.dockerfile_path.concat(x);
-    setActionConfig(updatedConfig);
+  componentDidMount() {
+    api
+      .getProjectRegistries(
+        "<token>",
+        {},
+        { id: this.context.currentProject.id }
+      )
+      .then((res: any) => {
+        this.setState({ registries: res.data, loading: false });
+        if (res.data.length === 1) {
+          this.props.setSelectedRegistry(res.data[0]);
+        }
+      })
+      .catch((err: any) => console.log(err));
+  }
+
+  renderIntegrationList = () => {
+    let { loading, registries } = this.state;
+    if (loading) {
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
+    }
+
+    return registries.map((registry: any, i: number) => {
+      let icon =
+        integrationList[registry.service] &&
+        integrationList[registry.service].icon;
+      if (!icon) {
+        icon = integrationList["docker"].icon;
+      }
+      return (
+        <RegistryItem
+          key={i}
+          isSelected={registry.id === this.props.selectedRegistry.id}
+          lastItem={i === registries.length - 1}
+          onClick={() => this.props.setSelectedRegistry(registry)}
+        >
+          <img src={icon && icon} />
+          {registry.url}
+        </RegistryItem>
+      );
+    });
   };
 
-  setURL = (x: string) => {
-    let { actionConfig, setActionConfig } = this.props;
-    let updatedConfig = actionConfig;
-    updatedConfig.image_repo_uri = x;
-    setActionConfig(updatedConfig);
+  renderRegistrySection = () => {
+    let { registries } = this.state;
+    if (!registries || registries.length === 0 || registries.length === 1) {
+      return;
+    } else {
+      return (
+        <>
+          <Subtitle>Container Registry</Subtitle>
+          <ExpandedWrapper>{this.renderIntegrationList()}</ExpandedWrapper>
+        </>
+      );
+    }
   };
 
-  renderConfirmation = () => {
+  render() {
     return (
-      <Holder>
+      <>
+        <DarkMatter />
         <InputRow
           disabled={true}
           label="Git Repository"
           type="text"
           width="100%"
           value={this.props.actionConfig.git_repo}
-          setValue={(x: string) => console.log(x)}
         />
-        <InputRow
-          disabled={true}
-          label="Dockerfile Path"
-          type="text"
-          width="100%"
-          value={this.props.actionConfig.dockerfile_path}
-          setValue={(x: string) => console.log(x)}
-        />
-        <Label>Target Image URL</Label>
-        <ImageSelector
-          selectedTag="latest"
-          selectedImageUrl={this.props.actionConfig.image_repo_uri}
-          setSelectedImageUrl={this.setURL}
-          setSelectedTag={() => null}
-          forceExpanded={true}
-          noTagSelection={true}
-        />
-      </Holder>
-    );
-  };
+        {this.props.dockerfilePath ? (
+          <InputRow
+            disabled={true}
+            label="Dockerfile Path"
+            type="text"
+            width="100%"
+            value={this.props.dockerfilePath}
+          />
+        ) : (
+          <InputRow
+            disabled={true}
+            label="Folder Path"
+            type="text"
+            width="100%"
+            value={this.props.folderPath}
+          />
+        )}
+        {this.renderRegistrySection()}
+        <Br />
 
-  render() {
-    return <div>{this.renderConfirmation()}</div>;
+        <Flex>
+          <BackButton
+            width="140px"
+            onClick={() => {
+              this.props.setDockerfilePath(null);
+              this.props.setFolderPath(null);
+            }}
+          >
+            <i className="material-icons">keyboard_backspace</i>
+            Select Folder
+          </BackButton>
+          {this.props.selectedRegistry ? (
+            <StatusWrapper successful={true}>
+              <i className="material-icons">done</i> Source selected.
+            </StatusWrapper>
+          ) : (
+            <StatusWrapper>
+              <i className="material-icons">error_outline</i> A connected
+              container registry is required
+            </StatusWrapper>
+          )}
+        </Flex>
+      </>
+    );
   }
 }
 
-const Label = styled.div`
+ActionDetails.contextType = Context;
+
+const Subtitle = styled.div`
+  margin-top: 21px;
+`;
+
+const RegistryItem = styled.div`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  border-bottom: 1px solid
+    ${(props: { lastItem: boolean; isSelected: boolean }) =>
+      props.lastItem ? "#00000000" : "#606166"};
   color: #ffffff;
+  user-select: none;
+  align-items: center;
+  padding: 10px 0px;
+  cursor: pointer;
+  background: ${(props: { isSelected: boolean; lastItem: boolean }) =>
+    props.isSelected ? "#ffffff11" : ""};
+  :hover {
+    background: #ffffff22;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > img {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+    filter: grayscale(100%);
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding: 30px 0px;
   display: flex;
   align-items: center;
   font-size: 13px;
+  justify-content: center;
+  color: #ffffff44;
+`;
+
+const ExpandedWrapper = styled.div`
+  margin-top: 10px;
+  width: 100%;
+  border-radius: 3px;
+  border: 1px solid #ffffff44;
+  max-height: 275px;
+  background: #ffffff11;
+  overflow-y: auto;
+  margin-bottom: 15px;
+`;
+
+const StatusWrapper = styled.div<{ successful?: boolean }>`
+  display: flex;
+  align-items: center;
   font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #ffffff55;
+  margin-right: 25px;
+  margin-left: 20px;
+  margin-top: 26px;
+
+  > i {
+    font-size: 18px;
+    margin-right: 10px;
+    color: ${(props) => (props.successful ? "#4797ff" : "#fcba03")};
+  }
+
+  animation: statusFloatIn 0.5s;
+  animation-fill-mode: forwards;
+
+  @keyframes statusFloatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
 `;
 
-ActionDetails.contextType = Context;
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 22px;
+  cursor: pointer;
+  font-size: 13px;
+  height: 35px;
+  padding: 5px 13px;
+  margin-bottom: -7px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
+const AdvancedHeader = styled.div`
+  margin-top: 15px;
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 1px;
+  margin-bottom: -8px;
+`;
+
+const DarkMatter = styled.div`
+  width: 100%;
+  margin-bottom: -18px;
+`;
 
 const Holder = styled.div`
   padding: 0px 12px 24px 12px;

+ 18 - 1
dashboard/src/components/repo-selector/BranchList.tsx

@@ -1,6 +1,7 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 import branch_icon from "assets/branch.png";
+import info from "assets/info.svg";
 
 import api from "../../shared/api";
 import { Context } from "../../shared/Context";
@@ -79,7 +80,15 @@ export default class BranchList extends Component<PropsType, StateType> {
   };
 
   render() {
-    return <div>{this.renderBranchList()}</div>;
+    return (
+      <>
+        <InfoRow lastItem={false}>
+          <img src={info} />
+          Select Branch
+        </InfoRow>
+        {this.renderBranchList()}
+      </>
+    );
   }
 }
 
@@ -114,6 +123,14 @@ const BranchName = styled.div`
   }
 `;
 
+const InfoRow = styled(BranchName)`
+  cursor: default;
+  color: #ffffff55;
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
 const LoadingWrapper = styled.div`
   padding: 30px 0px;
   background: #ffffff11;

+ 0 - 158
dashboard/src/components/repo-selector/ButtonTray.tsx

@@ -1,158 +0,0 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-
-import api from "../../shared/api";
-import { ActionConfigType } from "../../shared/types";
-import { Context } from "../../shared/Context";
-
-type PropsType = {
-  chartName: string | null;
-  chartNamespace: string | null;
-  pathIsSet: boolean;
-  branch: string;
-  actionConfig: ActionConfigType | null;
-  setBranch: (x: string) => void;
-  setActionConfig: (x: ActionConfigType) => void;
-  setPath: (x: boolean) => void;
-};
-
-type StateType = {};
-
-export default class RepoSelector extends Component<PropsType, StateType> {
-  createGHAction = () => {
-    let { currentProject, currentCluster } = this.context;
-    let { actionConfig, chartName, chartNamespace } = this.props;
-
-    api
-      .createGHAction(
-        "<token>",
-        {
-          git_repo: actionConfig.git_repo,
-          image_repo_uri: actionConfig.image_repo_uri,
-          dockerfile_path: actionConfig.dockerfile_path,
-          git_repo_id: actionConfig.git_repo_id,
-        },
-        {
-          project_id: currentProject.id,
-          CLUSTER_ID: currentCluster.id,
-          RELEASE_NAME: chartName,
-          RELEASE_NAMESPACE: chartNamespace,
-        }
-      )
-      .then((res) => console.log(res.data))
-      .catch(console.log);
-  };
-
-  setSelectedRepo = () => {
-    let { actionConfig, setActionConfig } = this.props;
-    let updatedConfig = actionConfig;
-    updatedConfig.git_repo = "";
-    updatedConfig.git_repo_id = null as number;
-    setActionConfig(updatedConfig);
-  };
-
-  goToBranchSelect = () => {
-    let { actionConfig, setActionConfig, setBranch } = this.props;
-    let updatedConfig = actionConfig;
-    updatedConfig.dockerfile_path = "";
-    setBranch("");
-    setActionConfig(updatedConfig);
-  };
-
-  goToPathSelect = () => {
-    let { actionConfig, setActionConfig, setPath } = this.props;
-    let updatedConfig = actionConfig;
-    updatedConfig.image_repo_uri = "";
-    updatedConfig.dockerfile_path = updatedConfig.dockerfile_path.slice(0, -11);
-    setPath(false);
-    setActionConfig(updatedConfig);
-  };
-
-  renderExpanded = () => {
-    let { actionConfig, pathIsSet, branch } = this.props;
-
-    if (!actionConfig.git_repo) {
-      return <></>;
-    } else if (!branch) {
-      return (
-        <ButtonTray>
-          <BackButton width="130px" onClick={() => this.setSelectedRepo()}>
-            <i className="material-icons">keyboard_backspace</i>
-            Select Repo
-          </BackButton>
-        </ButtonTray>
-      );
-    } else if (!pathIsSet) {
-      return (
-        <ButtonTray>
-          <BackButton onClick={() => this.goToBranchSelect()} width="140px">
-            <i className="material-icons">keyboard_backspace</i>
-            Select Branch
-          </BackButton>
-        </ButtonTray>
-      );
-    }
-    return (
-      <ButtonTray>
-        <BackButton width="130px" onClick={() => this.goToPathSelect()}>
-          <i className="material-icons">keyboard_backspace</i>
-          Select Dockerfile
-        </BackButton>
-        <BackButton
-          disabled={
-            !actionConfig.git_repo ||
-            !actionConfig.dockerfile_path ||
-            !actionConfig.image_repo_uri
-          }
-          width="146px"
-          onClick={() => this.createGHAction()}
-        >
-          <i className="material-icons">local_shipping</i>
-          Create Github Action
-        </BackButton>
-      </ButtonTray>
-    );
-  };
-
-  render() {
-    return <>{this.renderExpanded()}</>;
-  }
-}
-
-RepoSelector.contextType = Context;
-
-const BackButton = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  margin-top: 10px;
-  cursor: pointer;
-  font-size: 13px;
-  padding: 5px 10px;
-  border: 1px solid #ffffff55;
-  border-radius: 3px;
-  width: ${(props: { width: string; disabled?: boolean }) => props.width};
-  color: ${(props: { width: string; disabled?: boolean }) =>
-    props.disabled ? "#ffffff55" : "white"};
-  pointer-events: ${(props: { width: string; disabled?: boolean }) =>
-    props.disabled ? "none" : "auto"};
-
-  :hover {
-    background: #ffffff11;
-  }
-
-  > i {
-    color: ${(props: { width: string; disabled?: boolean }) =>
-      props.disabled ? "#ffffff55" : "white"};
-    font-size: 18px;
-    margin-right: 10px;
-  }
-`;
-
-const ButtonTray = styled.div`
-  margin-top: 10px;
-  display: flex;
-  flex-direction: row;
-  justify-content: space-between;
-  align-items: center;
-`;

+ 228 - 30
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -3,6 +3,7 @@ import styled from "styled-components";
 import file from "assets/file.svg";
 import folder from "assets/folder.svg";
 import info from "assets/info.svg";
+import close from "assets/close.png";
 
 import api from "../../shared/api";
 import { Context } from "../../shared/Context";
@@ -14,44 +15,46 @@ type PropsType = {
   actionConfig: ActionConfigType | null;
   branch: string;
   setActionConfig: (x: ActionConfigType) => void;
-  setPath: () => void;
+  setDockerfilePath: (x: string) => void;
+  setFolderPath: (x: string) => void;
 };
 
 type StateType = {
   loading: boolean;
   error: boolean;
   contents: FileType[];
+  currentDir: string;
+  dockerfiles: string[];
 };
 
+const dummyDockerfiles = ["dev.Dockerfile", "prod.Dockerfile", "Dockerfile"];
+
 export default class ContentsList extends Component<PropsType, StateType> {
   state = {
     loading: true,
     error: false,
     contents: [] as FileType[],
+    currentDir: "",
+    dockerfiles: [] as string[],
   };
 
-  setSubdirectory = (x: string, fileName?: string) => {
-    let { actionConfig, setActionConfig } = this.props;
-    let updatedConfig = actionConfig;
-    let path = x;
-    console.log(fileName);
-    updatedConfig.dockerfile_path = path;
-    setActionConfig(updatedConfig);
+  componentDidMount() {
     this.updateContents();
-    if (fileName?.includes("Dockerfile")) {
-      this.props.setPath();
-    }
+  }
+
+  setSubdirectory = (x: string) => {
+    this.setState({ currentDir: x }, () => this.updateContents());
   };
 
   updateContents = () => {
-    let { actionConfig, branch } = this.props;
     let { currentProject } = this.context;
-
+    let { actionConfig, branch } = this.props;
+    console.log(this.state.currentDir);
     // Get branch contents
     api
       .getBranchContents(
         "<token>",
-        { dir: actionConfig.dockerfile_path },
+        { dir: this.state.currentDir },
         {
           project_id: currentProject.id,
           git_repo_id: actionConfig.git_repo_id,
@@ -85,10 +88,6 @@ export default class ContentsList extends Component<PropsType, StateType> {
       });
   };
 
-  componentDidMount() {
-    this.updateContents();
-  }
-
   renderContentList = () => {
     let { contents, loading, error } = this.state;
     if (loading) {
@@ -108,7 +107,7 @@ export default class ContentsList extends Component<PropsType, StateType> {
         return (
           <Item
             key={i}
-            isSelected={item.Path === this.props.actionConfig.dockerfile_path}
+            isSelected={item.Path === this.state.currentDir}
             lastItem={i === contents.length - 1}
             onClick={() => this.setSubdirectory(item.Path)}
           >
@@ -124,7 +123,7 @@ export default class ContentsList extends Component<PropsType, StateType> {
             key={i}
             lastItem={i === contents.length - 1}
             isADocker
-            onClick={() => this.setSubdirectory(item.Path, fileName)}
+            onClick={() => this.props.setDockerfilePath(item.Path)}
           >
             <img src={file} />
             {fileName}
@@ -141,15 +140,11 @@ export default class ContentsList extends Component<PropsType, StateType> {
   };
 
   renderJumpToParent = () => {
-    let { actionConfig } = this.props;
-    if (actionConfig.dockerfile_path !== "") {
-      let splits = actionConfig.dockerfile_path.split("/");
+    if (this.state.currentDir !== "") {
+      let splits = this.state.currentDir.split("/");
       let subdir = "";
       if (splits.length !== 1) {
-        subdir = actionConfig.dockerfile_path.replace(
-          splits[splits.length - 1],
-          ""
-        );
+        subdir = this.state.currentDir.replace(splits[splits.length - 1], "");
         if (subdir.charAt(subdir.length - 1) === "/") {
           subdir = subdir.slice(0, subdir.length - 1);
         }
@@ -165,23 +160,226 @@ export default class ContentsList extends Component<PropsType, StateType> {
     return (
       <FileItem lastItem={false}>
         <img src={info} />
-        Select path to Dockerfile
+        Select Application Folder
       </FileItem>
     );
   };
 
+  handleContinue = () => {
+    let dockerfiles = [] as string[];
+    this.state.contents.forEach((item: FileType, i: number) => {
+      let splits = item.Path.split("/");
+      let fileName = splits[splits.length - 1];
+      if (fileName.includes("Dockerfile")) {
+        dockerfiles.push(fileName);
+      }
+    });
+    if (dockerfiles.length > 0) {
+      this.setState({ dockerfiles });
+    } else {
+      if (this.state.currentDir !== "") {
+        this.props.setFolderPath(this.state.currentDir);
+      } else {
+        this.props.setFolderPath("./");
+      }
+    }
+  };
+
+  renderOverlay = () => {
+    if (this.state.dockerfiles.length > 0) {
+      return (
+        <Overlay>
+          <BgOverlay onClick={() => this.setState({ dockerfiles: [] })} />
+          <CloseButton onClick={() => this.setState({ dockerfiles: [] })}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
+          <Label>
+            Porter has detected at least one Dockerfile in this folder. Would
+            you like to use an existing Dockerfile?
+          </Label>
+          <DockerfileList>
+            {this.state.dockerfiles.map((dockerfile: string, i: number) => {
+              return (
+                <Row
+                  key={i}
+                  onClick={() =>
+                    this.props.setDockerfilePath(
+                      `${this.state.currentDir || "."}/${dockerfile}`
+                    )
+                  }
+                  isLast={this.state.dockerfiles.length - 1 === i}
+                >
+                  <Indicator selected={false}></Indicator>
+                  {dockerfile}
+                </Row>
+              );
+            })}
+          </DockerfileList>
+          <ConfirmButton
+            onClick={() =>
+              this.props.setFolderPath(this.state.currentDir || "./")
+            }
+          >
+            No, I don't want to use a Dockerfile
+          </ConfirmButton>
+        </Overlay>
+      );
+    }
+  };
+
   render() {
     return (
-      <div>
+      <>
         {this.renderJumpToParent()}
         {this.renderContentList()}
-      </div>
+        <UseButton onClick={this.handleContinue}>Continue</UseButton>
+        {this.renderOverlay()}
+      </>
     );
   }
 }
 
 ContentsList.contextType = Context;
 
+const BgOverlay = styled.div`
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  background-color: rgba(0, 0, 0, 0.8);
+  z-index: -1;
+`;
+
+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 Indicator = styled.div<{ selected: boolean }>`
+  border-radius: 15px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 16px;
+  height: 16px;
+  border: 1px solid #ffffff55;
+  margin: 1px 10px 0px 1px;
+  margin-right: 13px;
+  background: ${(props) => (props.selected ? "#ffffff22" : "#ffffff11")};
+`;
+
+const Label = styled.div`
+  max-width: 420px;
+  line-height: 1.5em;
+  text-align: center;
+  font-size: 14px;
+`;
+
+const DockerfileList = styled.div`
+  border-radius: 3px;
+  margin-top: 20px;
+  border: 1px solid #aaaabb;
+  background: #ffffff22;
+  width: 100%;
+  max-width: 500px;
+  max-height: 140px;
+  overflow-y: auto;
+`;
+
+const Row = styled.div<{ isLast: boolean }>`
+  height: 35px;
+  padding-left: 10px;
+  display: flex;
+  align-items: center;
+  border-bottom: ${(props) => !props.isLast && "1px solid #aaaabb"};
+  cursor: pointer;
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
+const ConfirmButton = styled.div`
+  font-size: 18px;
+  padding: 7px 12px;
+  outline: none;
+  border: 1px solid white;
+  margin-top: 25px;
+  border-radius: 10px;
+  text-align: center;
+  cursor: pointer;
+  opacity: 0;
+  font-family: "Work Sans", sans-serif;
+  font-size: 14px;
+  font-weight: 500;
+  animation: linEnter 0.3s 0.1s;
+  animation-fill-mode: forwards;
+  @keyframes linEnter {
+    from {
+      transform: translateY(20px);
+      opacity: 0;
+    }
+    to {
+      transform: translateY(0px);
+      opacity: 1;
+    }
+  }
+  :hover {
+    background: white;
+    color: #232323;
+  }
+`;
+
+const Overlay = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 999;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+  padding: 0 90px;
+`;
+
+const UseButton = styled.div`
+  position: absolute;
+  bottom: 28px;
+  left: 185px;
+  height: 35px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #616feecc;
+  font-weight: 500;
+  padding: 10px 15px;
+  border-radius: 3px;
+  box-shadow: 0 2px 5px 0 #00000030;
+  cursor: pointer;
+  :hover {
+    filter: brightness(120%);
+  }
+`;
+
 const BackLabel = styled.div`
   font-size: 16px;
   padding-left: 16px;

+ 32 - 1
dashboard/src/components/repo-selector/RepoList.tsx

@@ -1,6 +1,7 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 import github from "assets/github.png";
+import info from "assets/info.svg";
 
 import api from "shared/api";
 import { RepoType, ActionConfigType } from "shared/types";
@@ -52,6 +53,15 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
                   repo.GHRepoID = grid;
                 });
                 allRepos = allRepos.concat(res.data);
+                allRepos.sort((a: any, b: any) => {
+                  if (a.FullName < b.FullName) {
+                    return -1;
+                  } else if (a.FullName > b.FullName) {
+                    return 1;
+                  } else {
+                    return 0;
+                  }
+                });
                 this.setState({
                   repos: allRepos,
                   loading: false,
@@ -80,6 +90,7 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
           res.data.forEach((repo: any, id: number) => {
             repo.GHRepoID = grid;
           });
+          // TODO: sort repos alphabetically
           this.setState({ repos: res.data, loading: false, error: false });
         })
         .catch((err) => {
@@ -142,7 +153,19 @@ export default class ActionConfEditor extends Component<PropsType, StateType> {
     if (this.props.readOnly) {
       return <ExpandedWrapperAlt>{this.renderRepoList()}</ExpandedWrapperAlt>;
     } else {
-      return <ExpandedWrapper>{this.renderRepoList()}</ExpandedWrapper>;
+      return (
+        <ExpandedWrapper>
+          <InfoRow
+            isSelected={false}
+            lastItem={false}
+            readOnly={this.props.readOnly}
+          >
+            <img src={info} />
+            Select Repo
+          </InfoRow>
+          {this.renderRepoList()}
+        </ExpandedWrapper>
+      );
     }
   };
 
@@ -195,6 +218,14 @@ const RepoName = styled.div`
   }
 `;
 
+const InfoRow = styled(RepoName)`
+  cursor: default;
+  color: #ffffff55;
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
 const LoadingWrapper = styled.div`
   padding: 30px 0px;
   background: #ffffff11;

+ 0 - 177
dashboard/src/components/repo-selector/RepoSelector.tsx

@@ -1,177 +0,0 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-import github from "assets/github.png";
-import info from "assets/info.svg";
-import { RepoType, ChartType, ActionConfigType } from "shared/types";
-import { Context } from "shared/Context";
-
-import ButtonTray from "./ButtonTray";
-import ActionConfEditor from "./ActionConfEditor";
-
-type PropsType = {
-  chart: ChartType | null;
-  forceExpanded?: boolean;
-  actionConfig: ActionConfigType | null;
-  setActionConfig: (x: ActionConfigType) => void;
-  resetActionConfig: () => void;
-};
-
-type StateType = {
-  isExpanded: boolean;
-  repos: RepoType[];
-  branch: string;
-  pathIsSet: boolean;
-  dockerfileSelected: boolean;
-};
-
-export default class RepoSelector extends Component<PropsType, StateType> {
-  state = {
-    isExpanded: this.props.forceExpanded,
-    repos: [] as RepoType[],
-    branch: "",
-    pathIsSet: false,
-    dockerfileSelected: false,
-  };
-
-  renderExpanded = () => {
-    let { actionConfig, setActionConfig, chart } = this.props;
-
-    return (
-      <div>
-        <ActionConfEditor
-          actionConfig={actionConfig}
-          branch={this.state.branch}
-          pathIsSet={this.state.pathIsSet}
-          setActionConfig={setActionConfig}
-          setBranch={(branch: string) => this.setState({ branch })}
-          setPath={(pathIsSet: boolean) => this.setState({ pathIsSet })}
-          reset={() => {
-            this.setState({
-              branch: "",
-              pathIsSet: false,
-              dockerfileSelected: false,
-            });
-            this.props.resetActionConfig();
-          }}
-        />
-        <ButtonTray
-          chartName={chart.name}
-          chartNamespace={chart.namespace}
-          pathIsSet={this.state.pathIsSet}
-          branch={this.state.branch}
-          actionConfig={actionConfig}
-          setBranch={(branch: string) => this.setState({ branch })}
-          setActionConfig={setActionConfig}
-          setPath={(pathIsSet: boolean) => this.setState({ pathIsSet })}
-        />
-      </div>
-    );
-  };
-
-  renderSelected = () => {
-    let { actionConfig } = this.props;
-    if (actionConfig.git_repo) {
-      let subdir =
-        actionConfig.dockerfile_path === ""
-          ? ""
-          : "/" + actionConfig.dockerfile_path;
-      return (
-        <RepoLabel>
-          <img src={github} />
-          {actionConfig.git_repo + subdir}
-          <SelectedBranch>
-            {!this.state.branch ? "(Select Branch)" : this.state.branch}
-          </SelectedBranch>
-        </RepoLabel>
-      );
-    }
-    return (
-      <RepoLabel>
-        <img src={info} />
-        No source selected
-      </RepoLabel>
-    );
-  };
-
-  handleClick = () => {
-    if (!this.props.forceExpanded) {
-      this.setState({ isExpanded: !this.state.isExpanded });
-    }
-  };
-
-  render() {
-    return (
-      <>
-        <StyledRepoSelector
-          onClick={this.handleClick}
-          isExpanded={this.state.isExpanded}
-          forceExpanded={this.props.forceExpanded}
-        >
-          {this.renderSelected()}
-          {this.props.forceExpanded ? null : (
-            <i className="material-icons">
-              {this.state.isExpanded ? "close" : "build"}
-            </i>
-          )}
-        </StyledRepoSelector>
-
-        {this.state.isExpanded ? this.renderExpanded() : null}
-      </>
-    );
-  }
-}
-
-RepoSelector.contextType = Context;
-
-const SelectedBranch = styled.div`
-  color: #ffffff55;
-  margin-left: 10px;
-`;
-
-const RepoLabel = styled.div`
-  display: flex;
-  align-items: center;
-
-  > img {
-    width: 18px;
-    height: 18px;
-    margin-left: 12px;
-    margin-right: 12px;
-  }
-`;
-
-const StyledRepoSelector = styled.div`
-  width: 100%;
-  margin-top: 22px;
-  border: 1px solid #ffffff55;
-  background: ${(props: { isExpanded: boolean; forceExpanded: boolean }) =>
-    props.isExpanded ? "#ffffff11" : ""};
-  border-radius: 3px;
-  user-select: none;
-  height: 40px;
-  font-size: 13px;
-  color: #ffffff;
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  cursor: ${(props: { isExpanded: boolean; forceExpanded: boolean }) =>
-    props.forceExpanded ? "" : "pointer"};
-  :hover {
-    background: #ffffff11;
-
-    > i {
-      background: #ffffff22;
-    }
-  }
-
-  > i {
-    font-size: 16px;
-    color: #ffffff66;
-    margin-right: 10px;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    border-radius: 20px;
-    padding: 4px;
-  }
-`;

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

@@ -5,6 +5,7 @@ type PropsType = {
   label: string;
   checked: boolean;
   toggle: () => void;
+  required?: boolean;
 };
 
 type StateType = {};
@@ -18,12 +19,18 @@ export default class CheckboxRow extends Component<PropsType, StateType> {
             <i className="material-icons">done</i>
           </Checkbox>
           {this.props.label}
+          {this.props.required && <Required>*</Required>}
         </CheckboxWrapper>
       </StyledCheckboxRow>
     );
   }
 }
 
+const Required = styled.section`
+  margin-left: 8px;
+  color: #fc4976;
+`;
+
 const CheckboxWrapper = styled.div`
   display: flex;
   align-items: center;

+ 0 - 2
dashboard/src/components/values-form/Helper.tsx

@@ -11,6 +11,4 @@ const StyledHelper = styled.div`
   font-size: 13px;
   margin-bottom: 15px;
   margin-top: 20px;
-  display: flex;
-  align-items: center;
 `;

+ 1 - 1
dashboard/src/components/values-form/InputRow.tsx

@@ -5,7 +5,7 @@ type PropsType = {
   label?: string;
   type: string;
   value: string | number;
-  setValue: (x: string | number) => void;
+  setValue?: (x: string | number) => void;
   unit?: string;
   placeholder?: string;
   width?: string;

+ 8 - 1
dashboard/src/components/values-form/ValuesForm.tsx

@@ -19,6 +19,7 @@ type PropsType = {
   sections?: Section[];
   metaState?: any;
   setMetaState?: any;
+  handleEnvChange?: (x: any) => void;
 };
 
 type StateType = any;
@@ -47,7 +48,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
         case "resource-list":
           if (Array.isArray(item.value)) {
             return (
-              <ResourceList>
+              <ResourceList key={i}>
                 {item.value.map((resource: any, i: number) => {
                   return (
                     <ExpandableResource
@@ -75,9 +76,15 @@ export default class ValuesForm extends Component<PropsType, StateType> {
         case "key-value-array":
           return (
             <KeyValueArray
+              key={i}
               values={this.props.metaState[key]}
               setValues={(x: any) => {
                 this.props.setMetaState({ [key]: x });
+
+                // Need to pull env vars out of form.yaml for createGHA build env vars
+                if (this.props.handleEnvChange && key === "container.env.normal") {
+                  this.props.handleEnvChange(x);
+                }
               }}
               label={item.label}
             />

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

@@ -12,7 +12,6 @@ import {
 import { Context } from "shared/Context";
 
 import ImageSelector from "components/image-selector/ImageSelector";
-import RepoSelector from "components/repo-selector/RepoSelector";
 import SaveButton from "components/SaveButton";
 import Heading from "components/values-form/Heading";
 import Helper from "components/values-form/Helper";
@@ -26,7 +25,6 @@ type PropsType = {
 };
 
 type StateType = {
-  actionConfig: ActionConfigType;
   sourceType: string;
   selectedImageUrl: string | null;
   selectedTag: string | null;
@@ -37,17 +35,8 @@ type StateType = {
   action: ActionConfigType;
 };
 
-// TODO: put in shared, duped from LaunchTemplate.tsx
-const defaultActionConfig: ActionConfigType = {
-  git_repo: "",
-  image_repo_uri: "",
-  git_repo_id: 0,
-  dockerfile_path: "",
-};
-
 export default class SettingsSection extends Component<PropsType, StateType> {
   state = {
-    actionConfig: defaultActionConfig,
     sourceType: "",
     selectedImageUrl: "",
     selectedTag: "",
@@ -59,7 +48,6 @@ export default class SettingsSection extends Component<PropsType, StateType> {
       git_repo: "",
       image_repo_uri: "",
       git_repo_id: 0,
-      dockerfile_path: "",
     } as ActionConfigType,
   };
 
@@ -148,62 +136,6 @@ export default class SettingsSection extends Component<PropsType, StateType> {
       });
   };
 
-  renderSourceSection = () => {
-    if (!this.props.currentChart?.form?.hasSource) {
-      return;
-    }
-
-    if (this.state.action.git_repo.length > 0) {
-      return (
-        <>
-          <Heading>Connected Source</Heading>
-          <Holder>
-            <InputRow
-              disabled={true}
-              label="Git Repository"
-              type="text"
-              width="100%"
-              value={this.state.action.git_repo}
-              setValue={(x: string) => console.log(x)}
-            />
-            <InputRow
-              disabled={true}
-              label="Dockerfile Path"
-              type="text"
-              width="100%"
-              value={this.state.action.dockerfile_path}
-              setValue={(x: string) => console.log(x)}
-            />
-            <InputRow
-              disabled={true}
-              label="Docker Image Repository"
-              type="text"
-              width="100%"
-              value={this.state.action.image_repo_uri}
-              setValue={(x: string) => console.log(x)}
-            />
-          </Holder>
-        </>
-      );
-    }
-
-    return (
-      <>
-        <Heading>Connected Source</Heading>
-        <Helper>Specify a container image and tag.</Helper>
-        <ImageSelector
-          selectedImageUrl={this.state.selectedImageUrl}
-          selectedTag={this.state.selectedTag}
-          setSelectedImageUrl={(x: string) =>
-            this.setState({ selectedImageUrl: x })
-          }
-          setSelectedTag={(x: string) => this.setState({ selectedTag: x })}
-          forceExpanded={true}
-        />
-      </>
-    );
-  };
-
   renderWebhookSection = () => {
     if (!this.props.currentChart?.form?.hasSource) {
       return;
@@ -239,7 +171,6 @@ export default class SettingsSection extends Component<PropsType, StateType> {
     return (
       <Wrapper>
         <StyledSettingsSection>
-          {this.renderSourceSection()}
           {this.renderWebhookSection()}
           <Heading>Additional Settings</Heading>
           <Button

+ 283 - 71
dashboard/src/main/home/launch/expanded-template/LaunchTemplate.tsx

@@ -4,6 +4,7 @@ import randomWords from "random-words";
 import _ from "lodash";
 import { Context } from "shared/Context";
 import api from "shared/api";
+import close from "assets/close.png";
 import { RouteComponentProps, withRouter } from "react-router";
 
 import {
@@ -20,6 +21,7 @@ import SaveButton from "components/SaveButton";
 import ActionConfEditor from "components/repo-selector/ActionConfEditor";
 import ValuesWrapper from "components/values-form/ValuesWrapper";
 import ValuesForm from "components/values-form/ValuesForm";
+import RadioSelector from "components/RadioSelector";
 import { isAlphanumeric } from "shared/common";
 
 type PropsType = RouteComponentProps & {
@@ -47,14 +49,17 @@ type StateType = {
   namespaceOptions: { label: string; value: string }[];
   actionConfig: ActionConfigType;
   branch: string;
-  pathIsSet: boolean;
+  repoType: string;
+  dockerfilePath: string | null;
+  folderPath: string | null;
+  selectedRegistry: any | null;
+  env: any;
 };
 
 const defaultActionConfig: ActionConfigType = {
   git_repo: "",
   image_repo_uri: "",
   git_repo_id: 0,
-  dockerfile_path: "",
 };
 
 class LaunchTemplate extends Component<PropsType, StateType> {
@@ -62,11 +67,11 @@ class LaunchTemplate extends Component<PropsType, StateType> {
     currentView: "repo",
     clusterOptions: [] as { label: string; value: string }[],
     clusterMap: {} as { [clusterId: string]: ClusterType },
-    saveValuesStatus: "No container image specified" as string | null,
+    saveValuesStatus: "" as string | null,
     selectedCluster: this.context.currentCluster.name,
     selectedNamespace: "default",
     selectedImageUrl: "" as string | null,
-    sourceType: "registry",
+    sourceType: "",
     templateName: "",
     selectedTag: "" as string | null,
     tabOptions: [] as ChoiceType[],
@@ -75,7 +80,11 @@ class LaunchTemplate extends Component<PropsType, StateType> {
     namespaceOptions: [] as { label: string; value: string }[],
     actionConfig: { ...defaultActionConfig },
     branch: "",
-    pathIsSet: false,
+    repoType: "",
+    dockerfilePath: null as string | null,
+    folderPath: null as string | null,
+    selectedRegistry: null as any | null,
+    env: {},
   };
 
   createGHAction = (chartName: string, chartNamespace: string) => {
@@ -86,9 +95,12 @@ class LaunchTemplate extends Component<PropsType, StateType> {
         "<token>",
         {
           git_repo: actionConfig.git_repo,
-          image_repo_uri: actionConfig.image_repo_uri,
-          dockerfile_path: actionConfig.dockerfile_path,
+          registry_id: this.state.selectedRegistry.id,
+          dockerfile_path: this.state.dockerfilePath,
+          folder_path: this.state.folderPath,
+          image_repo_uri: `${this.state.selectedRegistry.url}/${chartName}-${chartNamespace}`,
           git_repo_id: actionConfig.git_repo_id,
+          env: this.state.env,
         },
         {
           project_id: currentProject.id,
@@ -130,10 +142,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
         }
       )
       .then((_) => {
-        if (this.state.sourceType === "repo") {
-          this.createGHAction(name, this.state.selectedNamespace);
-        }
-
+        // this.props.setCurrentView('cluster-dashboard');
         this.setState({ saveValuesStatus: "successful" }, () => {
           // redirect to dashboard
           setTimeout(() => {
@@ -209,16 +218,6 @@ class LaunchTemplate extends Component<PropsType, StateType> {
 
     _.set(values, "ingress.provider", provider);
 
-    console.log(`
-      ${this.props.currentTemplate.name}\n
-      ${this.state.selectedImageUrl}\n
-      ${values}\n
-      ${this.state.selectedNamespace}\n
-      ${name}\n
-      ${currentProject.id}\n
-      ${currentCluster.id}\n}
-    `);
-
     api
       .deployTemplate(
         "<token>",
@@ -238,7 +237,9 @@ class LaunchTemplate extends Component<PropsType, StateType> {
         }
       )
       .then((_) => {
+        console.log("Deployed template.");
         if (this.state.sourceType === "repo") {
+          console.log("Creating GHA");
           this.createGHAction(name, this.state.selectedNamespace);
         }
         // this.props.setCurrentView('cluster-dashboard');
@@ -248,6 +249,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             this.props.history.push("cluster-dashboard");
           }, 1000);
         });
+        /*
         try {
           window.analytics.track("Deployed Application", {
             name: this.props.currentTemplate.name,
@@ -258,10 +260,11 @@ class LaunchTemplate extends Component<PropsType, StateType> {
         } catch (error) {
           console.log(error);
         }
+        */
       })
       .catch((err) => {
         this.setState({ saveValuesStatus: "error" });
-
+        /*
         try {
           window.analytics.track("Failed to Deploy Application", {
             name: this.props.currentTemplate.name,
@@ -273,9 +276,67 @@ class LaunchTemplate extends Component<PropsType, StateType> {
         } catch (error) {
           console.log(error);
         }
+        */
       });
   };
 
+  submitIsDisabled = () => {
+    let {
+      templateName,
+      sourceType,
+      selectedImageUrl,
+      dockerfilePath,
+      folderPath,
+    } = this.state;
+
+    // Allow if name is invalid
+    if (templateName.length > 0 && !isAlphanumeric(templateName)) {
+      return true;
+    }
+
+    if (this.props.form?.hasSource) {
+      // Allow if source type is registry and image URL is specified
+      if (sourceType === "registry" && selectedImageUrl) {
+        return false;
+      }
+
+      // Allow if source type is repo and dockerfile or folder path is set
+      if (sourceType === "repo" && (dockerfilePath || folderPath)) {
+        return !this.state.selectedRegistry;
+      }
+
+      return true;
+    } else {
+      return false;
+    }
+  };
+
+  getStatus = () => {
+    let {
+      selectedRegistry,
+      sourceType,
+      dockerfilePath,
+      folderPath,
+    } = this.state;
+
+    if (this.submitIsDisabled()) {
+      if (
+        sourceType === "repo" &&
+        (dockerfilePath || folderPath) &&
+        !selectedRegistry
+      ) {
+        return "A connected container registry is required";
+      }
+      let { templateName } = this.state;
+      if (templateName.length > 0 && !isAlphanumeric(templateName)) {
+        return "Template name contains illegal characters";
+      }
+      return "No application source specified";
+    } else {
+      return this.state.saveValuesStatus;
+    }
+  };
+
   renderTabContents = () => {
     return (
       <ValuesWrapper
@@ -285,12 +346,8 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             ? this.onSubmit
             : this.onSubmitAddon
         }
-        saveValuesStatus={this.state.saveValuesStatus}
-        disabled={
-          (this.state.templateName.length > 0 &&
-            !isAlphanumeric(this.state.templateName)) ||
-          (this.props.form?.hasSource ? !this.state.selectedImageUrl : false)
-        }
+        saveValuesStatus={this.getStatus()}
+        disabled={this.submitIsDisabled()}
       >
         {(metaState: any, setMetaState: any) => {
           return this.props.form?.tabs.map((tab: any, i: number) => {
@@ -299,6 +356,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
               return (
                 <ValuesForm
                   metaState={metaState}
+                  handleEnvChange={(x: any) => this.setState({ env: x })}
                   setMetaState={setMetaState}
                   key={tab.name}
                   sections={tab.sections}
@@ -373,11 +431,6 @@ class LaunchTemplate extends Component<PropsType, StateType> {
   };
 
   setSelectedImageUrl = (x: string) => {
-    if (x === "") {
-      this.setState({ saveValuesStatus: "No container image specified" });
-    } else {
-      this.setState({ saveValuesStatus: "" });
-    }
     this.setState({ selectedImageUrl: x });
   };
 
@@ -397,6 +450,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
     if (this.state.tabOptions.length > 0) {
       return (
         <>
+          <Heading>Additional Settings</Heading>
           <Subtitle>
             Configure additional settings for this template. (Optional)
           </Subtitle>
@@ -435,18 +489,45 @@ class LaunchTemplate extends Component<PropsType, StateType> {
 
   // Display if current template uses source (image or repo)
   renderSourceSelectorContent = () => {
-    if (this.state.sourceType === "registry") {
+    if (this.state.sourceType === "") {
       return (
-        <>
+        <BlockList>
+          <Block
+            onClick={() => {
+              this.setState({ sourceType: "repo" });
+            }}
+          >
+            <BlockIcon src="https://3.bp.blogspot.com/-xhNpNJJyQhk/XIe4GY78RQI/AAAAAAAAItc/ouueFUj2Hqo5dntmnKqEaBJR4KQ4Q2K3ACK4BGAYYCw/s1600/logo%2Bgit%2Bicon.png" />
+            <BlockTitle>Git Repository</BlockTitle>
+            <BlockDescription>
+              Deploy using source from a Git repo.
+            </BlockDescription>
+          </Block>
+          <Block
+            onClick={() => {
+              this.setState({ sourceType: "registry" });
+            }}
+          >
+            <BlockIcon src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png" />
+            <BlockTitle>Docker Registry</BlockTitle>
+            <BlockDescription>
+              Deploy a container from an image registry.
+            </BlockDescription>
+          </Block>
+        </BlockList>
+      );
+    } else if (this.state.sourceType === "registry") {
+      return (
+        <StyledSourceBox>
+          <CloseButton onClick={() => this.setState({ sourceType: "" })}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
           <Subtitle>
-            Select the container image you would like to connect to this
-            template
-            {/* <Highlight onClick={() => this.setState({ sourceType: "repo" })}>
-              link a git repository
-            </Highlight> */}
-            .<Required>*</Required>
+            Specify the container image you would like to connect to this
+            template.
+            <Required>*</Required>
           </Subtitle>
-          <DarkMatter />
+          <DarkMatter antiHeight="-4px" />
           <ImageSelector
             selectedTag={this.state.selectedTag}
             selectedImageUrl={this.state.selectedImageUrl}
@@ -455,19 +536,48 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             forceExpanded={true}
           />
           <br />
-        </>
+        </StyledSourceBox>
+      );
+    } else if (this.state.repoType === "" && false) {
+      return (
+        <StyledSourceBox>
+          <CloseButton onClick={() => this.setState({ sourceType: "" })}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
+          <Subtitle>
+            Are you using an existing Dockerfile from your repo?
+            <Required>*</Required>
+          </Subtitle>
+          <RadioSelector
+            options={[
+              {
+                value: "dockerfile",
+                label: "Yes, I am using an existing Dockerfile",
+              },
+              {
+                value: "buildpack",
+                label: "No, I am not using an existing Dockerfile",
+              },
+            ]}
+            selected={this.state.repoType}
+            setSelected={(x: string) => this.setState({ repoType: x })}
+          />
+        </StyledSourceBox>
       );
     } else {
       return (
-        <>
+        <StyledSourceBox>
+          <CloseButton onClick={() => this.setState({ sourceType: "" })}>
+            <CloseButtonImg src={close} />
+          </CloseButton>
           <Subtitle>
-            Select a repo to connect to, then a Dockerfile to build from.
+            Provide a repo folder to use as source.
             <Required>*</Required>
           </Subtitle>
+          <DarkMatter antiHeight="-4px" />
           <ActionConfEditor
             actionConfig={this.state.actionConfig}
             branch={this.state.branch}
-            pathIsSet={this.state.pathIsSet}
             setActionConfig={(actionConfig: ActionConfigType) =>
               this.setState({ actionConfig }, () => {
                 this.setSelectedImageUrl(
@@ -476,40 +586,41 @@ class LaunchTemplate extends Component<PropsType, StateType> {
               })
             }
             setBranch={(branch: string) => this.setState({ branch })}
-            setPath={(pathIsSet: boolean) => this.setState({ pathIsSet })}
+            setDockerfilePath={(x: string) =>
+              this.setState({ dockerfilePath: x })
+            }
+            dockerfilePath={this.state.dockerfilePath}
+            folderPath={this.state.folderPath}
+            setFolderPath={(x: string) => this.setState({ folderPath: x })}
             reset={() => {
               this.setState({
                 actionConfig: { ...defaultActionConfig },
                 branch: "",
-                pathIsSet: false,
+                dockerfilePath: null,
+                folderPath: null,
               });
             }}
+            setSelectedRegistry={(x: any) => {
+              this.setState({ selectedRegistry: x });
+            }}
+            selectedRegistry={this.state.selectedRegistry}
           />
           <br />
-        </>
+        </StyledSourceBox>
       );
     }
   };
 
   renderSourceSelector = () => {
-    if (!this.props.form?.hasSource) {
-      return;
-    }
-
     return (
       <>
-        <TabRegion
-          options={[
-            { label: "Registry", value: "registry" },
-            { label: "Github", value: "repo" },
-          ]}
-          currentTab={this.state.sourceType}
-          setCurrentTab={(x) => this.setState({ sourceType: x })}
-        >
-          <StyledSourceBox>
-            {this.renderSourceSelectorContent()}
-          </StyledSourceBox>
-        </TabRegion>
+        <Heading>Deployment Method</Heading>
+        <Subtitle>
+          Choose the deployment method you would like to use for this
+          application.
+          <Required>*</Required>
+        </Subtitle>
+        {this.renderSourceSelectorContent()}
       </>
     );
   };
@@ -532,7 +643,7 @@ class LaunchTemplate extends Component<PropsType, StateType> {
           </HeaderSection>
         )}
         <DarkMatter antiHeight="-13px" />
-        <Heading isAtTop={name !== "docker"}>Name</Heading>
+        <Heading isAtTop={true}>Name</Heading>
         <Subtitle>
           Randomly generated if left blank.
           <Warning
@@ -552,6 +663,9 @@ class LaunchTemplate extends Component<PropsType, StateType> {
           placeholder="ex: doctor-scientist"
           width="100%"
         />
+
+        {this.props.form?.hasSource && this.renderSourceSelector()}
+
         <Heading>Destination</Heading>
         <Subtitle>
           Specify the cluster and namespace you would like to deploy your
@@ -589,7 +703,6 @@ class LaunchTemplate extends Component<PropsType, StateType> {
             closeOverlay={true}
           />
         </ClusterSection>
-        {this.renderSourceSelector()}
         {this.renderSettingsRegion()}
       </StyledLaunchTemplate>
     );
@@ -597,9 +710,106 @@ class LaunchTemplate extends Component<PropsType, StateType> {
 }
 
 LaunchTemplate.contextType = Context;
-
 export default withRouter(LaunchTemplate);
 
+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 BlockIcon = styled.img<{ bw?: boolean }>`
+  height: 38px;
+  padding: 2px;
+  margin-top: 30px;
+  margin-bottom: 15px;
+  filter: ${(props) => (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<{ disabled?: boolean }>`
+  align-items: center;
+  user-select: none;
+  border-radius: 5px;
+  display: flex;
+  font-size: 13px;
+  overflow: hidden;
+  font-weight: 500;
+  padding: 3px 0px 12px;
+  flex-direction: column;
+  align-item: center;
+  justify-content: space-between;
+  height: 170px;
+  cursor: ${(props) => (props.disabled ? "" : "pointer")};
+  color: #ffffff;
+  position: relative;
+  background: #26282f;
+  box-shadow: 0 3px 5px 0px #00000022;
+  :hover {
+    background: ${(props) => (props.disabled ? "" : "#ffffff11")};
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const BlockList = styled.div`
+  overflow: visible;
+  margin-top: 6px;
+  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;
@@ -630,7 +840,7 @@ const Heading = styled.div<{ isAtTop?: boolean }>`
   font-weight: 500;
   font-size: 16px;
   margin-bottom: 5px;
-  margin-top: ${(props) => (props.isAtTop ? "30px" : "10px")};
+  margin-top: ${(props) => (props.isAtTop ? "10px" : "30px")};
   display: flex;
   align-items: center;
 `;
@@ -643,6 +853,7 @@ const Warning = styled.span<{ highlight: boolean; makeFlush?: boolean }>`
 const Required = styled.div`
   margin-left: 8px;
   color: #fc4976;
+  dislay: inline-block;
 `;
 
 const Link = styled.a`
@@ -734,7 +945,7 @@ const ClusterSection = styled.div`
   font-size: 14px;
   margin-top: 2px;
   font-weight: 500;
-  margin-bottom: 22px;
+  margin-bottom: 32px;
 
   > i {
     font-size: 25px;
@@ -778,10 +989,11 @@ const StyledSourceBox = styled.div`
   height: 100%;
   background: #ffffff11;
   color: #ffffff;
-  padding: 10px 35px 25px;
+  padding: 14px 35px 20px;
   position: relative;
   border-radius: 5px;
   font-size: 13px;
+  margin-top: 6px;
   overflow: auto;
   margin-bottom: 25px;
 `;

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

@@ -182,6 +182,7 @@ const Placeholder = styled.div`
 const Required = styled.div`
   margin-left: 8px;
   color: #fc4976;
+  display: inline-block;
 `;
 
 const Highlight = styled.div`

+ 62 - 3
dashboard/src/main/home/provisioner/AWSFormSection.tsx

@@ -9,6 +9,7 @@ import { InfraType } from "shared/types";
 
 import SelectRow from "components/values-form/SelectRow";
 import InputRow from "components/values-form/InputRow";
+import CheckboxRow from "components/values-form/CheckboxRow";
 import Helper from "components/values-form/Helper";
 import Heading from "components/values-form/Heading";
 import SaveButton from "components/SaveButton";
@@ -28,6 +29,7 @@ type StateType = {
   awsSecretKey: string;
   selectedInfras: { value: string; label: string }[];
   buttonStatus: string;
+  provisionConfirmed: boolean;
 };
 
 const provisionOptions = [
@@ -41,7 +43,7 @@ const regionOptions = [
   { value: "us-west-1", label: "US West (N. California) us-west-1" },
   { value: "us-west-2", label: "US West (Oregon) us-west-2" },
   { value: "af-south-1", label: "Africa (Cape Town) af-south-1" },
-  { value: "ap-east-1", label: "Asia Pacific (Hong Kong)ap-east-1" },
+  { value: "ap-east-1", label: "Asia Pacific (Hong Kong) ap-east-1" },
   { value: "ap-south-1", label: "Asia Pacific (Mumbai) ap-south-1" },
   { value: "ap-northeast-2", label: "Asia Pacific (Seoul) ap-northeast-2" },
   { value: "ap-southeast-1", label: "Asia Pacific (Singapore) ap-southeast-1" },
@@ -66,6 +68,7 @@ class AWSFormSection extends Component<PropsType, StateType> {
     awsSecretKey: "",
     selectedInfras: [...provisionOptions],
     buttonStatus: "",
+    provisionConfirmed: false,
   };
 
   componentDidMount = () => {
@@ -88,6 +91,10 @@ class AWSFormSection extends Component<PropsType, StateType> {
   };
 
   checkFormDisabled = () => {
+    if (!this.state.provisionConfirmed) {
+      return true;
+    }
+
     let { awsRegion, awsAccessId, awsSecretKey, selectedInfras } = this.state;
     let { projectName } = this.props;
     if (projectName || projectName === "") {
@@ -202,6 +209,7 @@ class AWSFormSection extends Component<PropsType, StateType> {
 
   // TODO: handle generically (with > 2 steps)
   onCreateAWS = () => {
+    this.setState({ buttonStatus: "loading" });
     let { projectName } = this.props;
     let { selectedInfras } = this.state;
 
@@ -236,6 +244,23 @@ class AWSFormSection extends Component<PropsType, StateType> {
     }
   };
 
+  getButtonStatus = () => {
+    if (this.props.projectName) {
+      if (!isAlphanumeric(this.props.projectName)) {
+        return "Project name contains illegal characters";
+      }
+    }
+    if (
+      !this.state.awsAccessId ||
+      !this.state.awsSecretKey ||
+      !this.state.provisionConfirmed ||
+      this.props.projectName === ""
+    ) {
+      return "Required fields missing";
+    }
+    return this.state.buttonStatus;
+  };
+
   render() {
     let { setSelectedProvisioner } = this.props;
     let { awsRegion, awsAccessId, awsSecretKey, selectedInfras } = this.state;
@@ -284,7 +309,9 @@ class AWSFormSection extends Component<PropsType, StateType> {
           />
           <Br />
           <Heading>AWS Resources</Heading>
-          <Helper>Porter will provision the following AWS resources</Helper>
+          <Helper>
+            Porter will provision the following AWS resources in your own cloud.
+          </Helper>
           <CheckboxList
             options={provisionOptions}
             selected={selectedInfras}
@@ -292,13 +319,38 @@ class AWSFormSection extends Component<PropsType, StateType> {
               this.setState({ selectedInfras: x });
             }}
           />
+          <Helper>
+            By default, Porter creates a cluster with three t2.medium instances
+            (2vCPUs and 4GB RAM each). AWS will bill you for any provisioned
+            resources. Learn more about EKS pricing
+            <Highlight
+              href="https://aws.amazon.com/eks/pricing/"
+              target="_blank"
+            >
+              here
+            </Highlight>
+            .
+          </Helper>
+          <CheckboxRow
+            required={true}
+            checked={this.state.provisionConfirmed}
+            toggle={() =>
+              this.setState({
+                provisionConfirmed: !this.state.provisionConfirmed,
+              })
+            }
+            label="I understand and wish to proceed"
+          />
         </FormSection>
         {this.props.children ? this.props.children : <Padding />}
         <SaveButton
           text="Submit"
-          disabled={this.checkFormDisabled()}
+          disabled={
+            this.checkFormDisabled() || this.state.buttonStatus === "loading"
+          }
           onClick={this.onCreateAWS}
           makeFlush={true}
+          status={this.getButtonStatus()}
           helper="Note: Provisioning can take up to 15 minutes"
         />
       </StyledAWSFormSection>
@@ -310,6 +362,13 @@ AWSFormSection.contextType = Context;
 
 export default withRouter(AWSFormSection);
 
+const Highlight = styled.a`
+  color: #8590ff;
+  cursor: pointer;
+  text-decoration: none;
+  margin-left: 5px;
+`;
+
 const Padding = styled.div`
   height: 15px;
 `;

+ 50 - 1
dashboard/src/main/home/provisioner/DOFormSection.tsx

@@ -7,6 +7,7 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import { InfraType } from "shared/types";
 
+import CheckboxRow from "components/values-form/CheckboxRow";
 import SelectRow from "components/values-form/SelectRow";
 import Helper from "components/values-form/Helper";
 import Heading from "components/values-form/Heading";
@@ -24,6 +25,7 @@ type StateType = {
   selectedInfras: { value: string; label: string }[];
   subscriptionTier: string;
   doRegion: string;
+  provisionConfirmed: boolean;
 };
 
 const provisionOptions = [
@@ -56,6 +58,7 @@ export default class DOFormSection extends Component<PropsType, StateType> {
     selectedInfras: [...provisionOptions],
     subscriptionTier: "starter",
     doRegion: "nyc1",
+    provisionConfirmed: false,
   };
 
   componentDidMount = () => {
@@ -78,6 +81,10 @@ export default class DOFormSection extends Component<PropsType, StateType> {
   };
 
   checkFormDisabled = () => {
+    if (!this.state.provisionConfirmed) {
+      return true;
+    }
+
     let { selectedInfras } = this.state;
     let { projectName } = this.props;
     if (projectName || projectName === "") {
@@ -143,6 +150,17 @@ export default class DOFormSection extends Component<PropsType, StateType> {
     }
   };
 
+  getButtonStatus = () => {
+    if (this.props.projectName) {
+      if (!isAlphanumeric(this.props.projectName)) {
+        return "Project name contains illegal characters";
+      }
+    }
+    if (!this.state.provisionConfirmed || this.props.projectName === "") {
+      return "Required fields missing";
+    }
+  };
+
   render() {
     let { setSelectedProvisioner } = this.props;
     let { selectedInfras, subscriptionTier, doRegion } = this.state;
@@ -174,7 +192,8 @@ export default class DOFormSection extends Component<PropsType, StateType> {
           <Br />
           <Heading>DigitalOcean Resources</Heading>
           <Helper>
-            Porter will provision the following DigitalOcean resources
+            Porter will provision the following DigitalOcean resources in your
+            own cloud.
           </Helper>
           <CheckboxList
             options={provisionOptions}
@@ -183,6 +202,28 @@ export default class DOFormSection extends Component<PropsType, StateType> {
               this.setState({ selectedInfras: x });
             }}
           />
+          <Helper>
+            By default, Porter creates a cluster with three Standard (2vCPUs /
+            2GB RAM) droplets. DigitalOcean will bill you for any provisioned
+            resources. Learn more about DOKS pricing
+            <Highlight
+              href="https://www.digitalocean.com/products/kubernetes/"
+              target="_blank"
+            >
+              here
+            </Highlight>
+            .
+          </Helper>
+          <CheckboxRow
+            required={true}
+            checked={this.state.provisionConfirmed}
+            toggle={() =>
+              this.setState({
+                provisionConfirmed: !this.state.provisionConfirmed,
+              })
+            }
+            label="I understand and wish to proceed"
+          />
         </FormSection>
         {this.props.children ? this.props.children : <Padding />}
         <SaveButton
@@ -190,6 +231,7 @@ export default class DOFormSection extends Component<PropsType, StateType> {
           disabled={this.checkFormDisabled()}
           onClick={this.onCreateDO}
           makeFlush={true}
+          status={this.getButtonStatus()}
           helper="Note: Provisioning can take up to 15 minutes"
         />
       </StyledAWSFormSection>
@@ -199,6 +241,13 @@ export default class DOFormSection extends Component<PropsType, StateType> {
 
 DOFormSection.contextType = Context;
 
+const Highlight = styled.a`
+  color: #8590ff;
+  cursor: pointer;
+  text-decoration: none;
+  margin-left: 5px;
+`;
+
 const Padding = styled.div`
   height: 15px;
 `;

+ 61 - 2
dashboard/src/main/home/provisioner/GCPFormSection.tsx

@@ -8,6 +8,7 @@ import { Context } from "shared/Context";
 import { InfraType } from "shared/types";
 
 import SelectRow from "components/values-form/SelectRow";
+import CheckboxRow from "components/values-form/CheckboxRow";
 import InputRow from "components/values-form/InputRow";
 import Helper from "components/values-form/Helper";
 import Heading from "components/values-form/Heading";
@@ -28,6 +29,7 @@ type StateType = {
   gcpKeyData: string;
   selectedInfras: { value: string; label: string }[];
   buttonStatus: string;
+  provisionConfirmed: boolean;
 };
 
 const provisionOptions = [
@@ -69,6 +71,7 @@ class GCPFormSection extends Component<PropsType, StateType> {
     gcpKeyData: "",
     selectedInfras: [...provisionOptions],
     buttonStatus: "",
+    provisionConfirmed: false,
   };
 
   componentDidMount = () => {
@@ -91,6 +94,10 @@ class GCPFormSection extends Component<PropsType, StateType> {
   };
 
   checkFormDisabled = () => {
+    if (!this.state.provisionConfirmed) {
+      return true;
+    }
+
     let { gcpRegion, gcpProjectId, gcpKeyData, selectedInfras } = this.state;
     let { projectName } = this.props;
     if (projectName || projectName === "") {
@@ -216,6 +223,7 @@ class GCPFormSection extends Component<PropsType, StateType> {
 
   // TODO: handle generically (with > 2 steps)
   onCreateGCP = () => {
+    this.setState({ buttonStatus: "loading" });
     let { projectName } = this.props;
 
     if (!projectName) {
@@ -225,6 +233,23 @@ class GCPFormSection extends Component<PropsType, StateType> {
     }
   };
 
+  getButtonStatus = () => {
+    if (this.props.projectName) {
+      if (!isAlphanumeric(this.props.projectName)) {
+        return "Project name contains illegal characters";
+      }
+    }
+    if (
+      !this.state.gcpProjectId ||
+      !this.state.gcpKeyData ||
+      !this.state.provisionConfirmed ||
+      this.props.projectName === ""
+    ) {
+      return "Required fields missing";
+    }
+    return this.state.buttonStatus;
+  };
+
   render() {
     let { setSelectedProvisioner } = this.props;
     let { gcpRegion, gcpProjectId, gcpKeyData, selectedInfras } = this.state;
@@ -273,7 +298,9 @@ class GCPFormSection extends Component<PropsType, StateType> {
           />
           <Br />
           <Heading>GCP Resources</Heading>
-          <Helper>Porter will provision the following GCP resources</Helper>
+          <Helper>
+            Porter will provision the following GCP resources in your own cloud.
+          </Helper>
           <CheckboxList
             options={provisionOptions}
             selected={selectedInfras}
@@ -281,13 +308,38 @@ class GCPFormSection extends Component<PropsType, StateType> {
               this.setState({ selectedInfras: x });
             }}
           />
+          <Helper>
+            By default, Porter creates a cluster with three e2-medium instances
+            (2vCPUs and 4GB RAM each). Google Cloud will bill you for any
+            provisioned resources. Learn more about GKE pricing
+            <Highlight
+              href="https://cloud.google.com/kubernetes-engine/pricing"
+              target="_blank"
+            >
+              here
+            </Highlight>
+            .
+          </Helper>
+          <CheckboxRow
+            required={true}
+            checked={this.state.provisionConfirmed}
+            toggle={() =>
+              this.setState({
+                provisionConfirmed: !this.state.provisionConfirmed,
+              })
+            }
+            label="I understand and wish to proceed"
+          />
         </FormSection>
         {this.props.children ? this.props.children : <Padding />}
         <SaveButton
           text="Submit"
-          disabled={this.checkFormDisabled()}
+          disabled={
+            this.checkFormDisabled() || this.state.buttonStatus === "loading"
+          }
           onClick={this.onCreateGCP}
           makeFlush={true}
+          status={this.getButtonStatus()}
           helper="Note: Provisioning can take up to 15 minutes"
         />
       </StyledGCPFormSection>
@@ -299,6 +351,13 @@ GCPFormSection.contextType = Context;
 
 export default withRouter(GCPFormSection);
 
+const Highlight = styled.a`
+  color: #8590ff;
+  cursor: pointer;
+  text-decoration: none;
+  margin-left: 5px;
+`;
+
 const Padding = styled.div`
   height: 15px;
 `;

+ 2 - 0
dashboard/src/main/home/provisioner/ProvisionerSettings.tsx

@@ -203,6 +203,7 @@ const PositionWrapper = styled.div<{ selectedProvider: string | null }>``;
 const Highlight = styled.div`
   margin-left: 5px;
   color: #8590ff;
+  display: inline-block;
   cursor: pointer;
 `;
 
@@ -219,6 +220,7 @@ const BlockList = styled.div`
 const Required = styled.div`
   margin-left: 8px;
   color: #fc4976;
+  display: inline-block;
 `;
 
 const Icon = styled.img<{ bw?: boolean }>`

+ 1 - 1
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -284,7 +284,7 @@ const BottomSection = styled.div`
 const DiscordButton = styled.a`
   position: absolute;
   text-decoration: none;
-  bottom: 15px;
+  bottom: 17px;
   display: flex;
   align-items: center;
   width: calc(100% - 30px);

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

@@ -98,9 +98,12 @@ const createGCR = baseApi<
 const createGHAction = baseApi<
   {
     git_repo: string;
+    registry_id: number;
     image_repo_uri: string;
     dockerfile_path: string;
+    folder_path: string;
     git_repo_id: number;
+    env: any;
   },
   {
     project_id: number;

+ 0 - 1
dashboard/src/shared/types.tsx

@@ -165,5 +165,4 @@ export interface ActionConfigType {
   git_repo: string;
   image_repo_uri: string;
   git_repo_id: number;
-  dockerfile_path: string;
 }

+ 16 - 9
internal/forms/git_action.go

@@ -7,11 +7,14 @@ import (
 // CreateGitAction represents the accepted values for creating a
 // github action integration
 type CreateGitAction struct {
-	ReleaseID      uint   `json:"release_id" form:"required"`
-	GitRepo        string `json:"git_repo" form:"required"`
-	ImageRepoURI   string `json:"image_repo_uri" form:"required"`
-	DockerfilePath string `json:"dockerfile_path" form:"required"`
-	GitRepoID      uint   `json:"git_repo_id" form:"required"`
+	ReleaseID      uint              `json:"release_id" form:"required"`
+	GitRepo        string            `json:"git_repo" form:"required"`
+	ImageRepoURI   string            `json:"image_repo_uri" form:"required"`
+	DockerfilePath string            `json:"dockerfile_path"`
+	FolderPath     string            `json:"folder_path"`
+	GitRepoID      uint              `json:"git_repo_id" form:"required"`
+	BuildEnv       map[string]string `json:"env"`
+	RegistryID     uint              `json:"registry_id"`
 }
 
 // ToGitActionConfig converts the form to a gorm git action config model
@@ -21,13 +24,17 @@ func (ca *CreateGitAction) ToGitActionConfig() (*models.GitActionConfig, error)
 		GitRepo:        ca.GitRepo,
 		ImageRepoURI:   ca.ImageRepoURI,
 		DockerfilePath: ca.DockerfilePath,
+		FolderPath:     ca.FolderPath,
 		GitRepoID:      ca.GitRepoID,
 	}, nil
 }
 
 type CreateGitActionOptional struct {
-	GitRepo        string `json:"git_repo"`
-	ImageRepoURI   string `json:"image_repo_uri"`
-	DockerfilePath string `json:"dockerfile_path"`
-	GitRepoID      uint   `json:"git_repo_id"`
+	GitRepo        string            `json:"git_repo"`
+	ImageRepoURI   string            `json:"image_repo_uri"`
+	DockerfilePath string            `json:"dockerfile_path"`
+	FolderPath     string            `json:"folder_path"`
+	GitRepoID      uint              `json:"git_repo_id"`
+	BuildEnv       map[string]string `json:"env"`
+	RegistryID     uint              `json:"registry_id"`
 }

+ 12 - 0
internal/forms/metrics.go

@@ -0,0 +1,12 @@
+package forms
+
+import (
+	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
+)
+
+// MetricsQueryForm is the form for querying pod usage metrics (cpu, memory)
+type MetricsQueryForm struct {
+	*K8sForm
+
+	*prometheus.QueryOpts
+}

+ 43 - 7
internal/integrations/ci/actions/actions.go

@@ -26,10 +26,12 @@ type GithubActions struct {
 
 	WebhookToken string
 	PorterToken  string
+	BuildEnv     map[string]string
 	ProjectID    uint
 	ReleaseName  string
 
 	DockerFilePath string
+	FolderPath     string
 	ImageRepoURL   string
 
 	defaultBranch string
@@ -69,6 +71,13 @@ func (g *GithubActions) Setup() (string, error) {
 		return "", err
 	}
 
+	// create a new secret with the build variables
+	err = g.createEnvSecret(client)
+
+	if err != nil {
+		return "", err
+	}
+
 	fileBytes, err := g.GetGithubActionYAML()
 
 	if err != nil {
@@ -107,6 +116,20 @@ type GithubActionYAML struct {
 }
 
 func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
+	gaSteps := []GithubActionYAMLStep{
+		getCheckoutCodeStep(),
+		getDownloadPorterStep(),
+		getConfigurePorterStep(g.getPorterTokenSecretName()),
+	}
+
+	if g.DockerFilePath == "" {
+		gaSteps = append(gaSteps, getBuildPackPushStep(g.getBuildEnvSecretName(), g.FolderPath, g.ImageRepoURL))
+	} else {
+		gaSteps = append(gaSteps, getDockerBuildPushStep(g.getBuildEnvSecretName(), g.DockerFilePath, g.ImageRepoURL))
+	}
+
+	gaSteps = append(gaSteps, deployPorterWebhookStep(g.getWebhookSecretName(), g.ImageRepoURL))
+
 	actionYAML := &GithubActionYAML{
 		On: GithubActionYAMLOnPush{
 			Push: GithubActionYAMLOnPushBranches{
@@ -119,13 +142,7 @@ func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
 		Jobs: map[string]GithubActionYAMLJob{
 			"porter-deploy": {
 				RunsOn: "ubuntu-latest",
-				Steps: []GithubActionYAMLStep{
-					getCheckoutCodeStep(),
-					getDownloadPorterStep(),
-					getConfigurePorterStep(g.getPorterTokenSecretName()),
-					getDockerBuildPushStep(g.DockerFilePath, g.ImageRepoURL),
-					deployPorterWebhookStep(g.getWebhookSecretName(), g.ImageRepoURL),
-				},
+				Steps:  gaSteps,
 			},
 		},
 	}
@@ -195,12 +212,31 @@ func (g *GithubActions) createGithubSecret(
 	return nil
 }
 
+func (g *GithubActions) createEnvSecret(client *github.Client) error {
+	// convert the env object to a string
+	lines := make([]string, 0)
+
+	for key, val := range g.BuildEnv {
+		lines = append(lines, fmt.Sprintf(`%s=%s`, key, val))
+	}
+
+	secretName := g.getBuildEnvSecretName()
+
+	return g.createGithubSecret(client, secretName, strings.Join(lines, "\n"))
+}
+
 func (g *GithubActions) getWebhookSecretName() string {
 	return fmt.Sprintf("WEBHOOK_%s", strings.Replace(
 		strings.ToUpper(g.ReleaseName), "-", "_", -1),
 	)
 }
 
+func (g *GithubActions) getBuildEnvSecretName() string {
+	return fmt.Sprintf("ENV_%s", strings.Replace(
+		strings.ToUpper(g.ReleaseName), "-", "_", -1),
+	)
+}
+
 func (g *GithubActions) getPorterYMLFileName() string {
 	return fmt.Sprintf("porter_%s.yml", strings.Replace(
 		strings.ToLower(g.ReleaseName), "-", "_", -1),

+ 20 - 2
internal/integrations/ci/actions/steps.go

@@ -44,15 +44,33 @@ func getConfigurePorterStep(porterTokenSecretName string) GithubActionYAMLStep {
 }
 
 const dockerBuildPush string = `
+export $(echo "${{secrets.%s}}" | xargs)
 docker build %s --file %s -t %s:$(git rev-parse --short HEAD)
 sudo docker push %s:$(git rev-parse --short HEAD)
 `
 
-func getDockerBuildPushStep(dockerFilePath, repoURL string) GithubActionYAMLStep {
+func getDockerBuildPushStep(envSecretName, dockerFilePath, repoURL string) GithubActionYAMLStep {
 	return GithubActionYAMLStep{
 		Name: "Docker build, push",
 		ID:   "docker_build_push",
-		Run:  fmt.Sprintf(dockerBuildPush, filepath.Dir(dockerFilePath), dockerFilePath, repoURL, repoURL),
+		Run:  fmt.Sprintf(dockerBuildPush, envSecretName, filepath.Dir(dockerFilePath), dockerFilePath, repoURL, repoURL),
+	}
+}
+
+const buildPackPush string = `
+export $(echo "${{secrets.%s}}" | xargs)
+sudo add-apt-repository ppa:cncf-buildpacks/pack-cli
+sudo apt-get update
+sudo apt-get install pack-cli
+pack build %s:$(git rev-parse --short HEAD) --path %s --builder heroku/buildpacks:18
+sudo docker push %s:$(git rev-parse --short HEAD)
+`
+
+func getBuildPackPushStep(envSecretName, folderPath, repoURL string) GithubActionYAMLStep {
+	return GithubActionYAMLStep{
+		Name: "Docker build, push",
+		ID:   "docker_build_push",
+		Run:  fmt.Sprintf(buildPackPush, envSecretName, repoURL, folderPath, repoURL),
 	}
 }
 

+ 145 - 0
internal/kubernetes/prometheus/metrics.go

@@ -0,0 +1,145 @@
+package prometheus
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	v1 "k8s.io/api/core/v1"
+	"k8s.io/client-go/kubernetes"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// returns the prometheus service name
+func GetPrometheusService(clientset kubernetes.Interface) (*v1.Service, bool, error) {
+	services, err := clientset.CoreV1().Services("").List(context.TODO(), metav1.ListOptions{
+		LabelSelector: "app=prometheus,component=server,heritage=Helm",
+	})
+
+	if err != nil {
+		return nil, false, err
+	}
+
+	if len(services.Items) == 0 {
+		return nil, false, nil
+	}
+
+	return &services.Items[0], true, nil
+}
+
+type QueryOpts struct {
+	Metric     string   `schema:"metric"`
+	ShouldSum  bool     `schema:"shouldsum"`
+	PodList    []string `schema:"pods"`
+	Namespace  string   `schema:"namespace"`
+	StartRange uint     `schema:"startrange"`
+	EndRange   uint     `schema:"endrange"`
+	Resolution string   `schema:"resolution"`
+}
+
+func QueryPrometheus(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+	opts *QueryOpts,
+) ([]byte, error) {
+	if len(service.Spec.Ports) == 0 {
+		return nil, fmt.Errorf("prometheus service has no exposed ports to query")
+	}
+
+	podSelector := fmt.Sprintf(`namespace="%s",pod=~"%s",container!="POD",container!=""`, opts.Namespace, strings.Join(opts.PodList, "|"))
+	query := ""
+
+	if opts.Metric == "cpu" {
+		query = fmt.Sprintf("rate(container_cpu_usage_seconds_total{%s}[5m])", podSelector)
+	} else if opts.Metric == "memory" {
+		query = fmt.Sprintf("container_memory_usage_bytes{%s}", podSelector)
+	}
+
+	if opts.ShouldSum {
+		query = fmt.Sprintf("sum(%s)", query)
+	}
+
+	queryParams := map[string]string{
+		"query": query,
+		"start": fmt.Sprintf("%d", opts.StartRange),
+		"end":   fmt.Sprintf("%d", opts.EndRange),
+		"step":  opts.Resolution,
+	}
+
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		"/api/v1/query_range",
+		queryParams,
+	)
+
+	rawQuery, err := resp.DoRaw(context.TODO())
+
+	if err != nil {
+		return nil, err
+	}
+
+	return parseQuery(rawQuery, opts.Metric)
+}
+
+type promRawQuery struct {
+	Data struct {
+		Result []struct {
+			Metric struct {
+				Pod string `json:"pod,omitempty"`
+			} `json:"metric,omitempty"`
+
+			Values [][]interface{} `json:"values"`
+		} `json:"result"`
+	} `json:"data"`
+}
+
+type promParsedSingletonQueryResult struct {
+	Date   interface{} `json:"date,omitempty"`
+	CPU    interface{} `json:"cpu,omitempty"`
+	Memory interface{} `json:"memory,omitempty"`
+}
+
+type promParsedSingletonQuery struct {
+	Pod     string                           `json:"pod,omitempty"`
+	Results []promParsedSingletonQueryResult `json:"results"`
+}
+
+func parseQuery(rawQuery []byte, metric string) ([]byte, error) {
+	rawQueryObj := &promRawQuery{}
+
+	json.Unmarshal(rawQuery, rawQueryObj)
+
+	res := make([]*promParsedSingletonQuery, 0)
+
+	for _, result := range rawQueryObj.Data.Result {
+		singleton := &promParsedSingletonQuery{
+			Pod: result.Metric.Pod,
+		}
+
+		singletonResults := make([]promParsedSingletonQueryResult, 0)
+
+		for _, values := range result.Values {
+			singletonResult := &promParsedSingletonQueryResult{
+				Date: values[0],
+			}
+
+			if metric == "cpu" {
+				singletonResult.CPU = values[1]
+			} else if metric == "memory" {
+				singletonResult.Memory = values[1]
+			}
+
+			singletonResults = append(singletonResults, *singletonResult)
+		}
+
+		singleton.Results = singletonResults
+
+		res = append(res, singleton)
+	}
+
+	return json.Marshal(res)
+}

+ 9 - 2
internal/models/gitrepo.go

@@ -14,7 +14,7 @@ type GitRepo struct {
 	ProjectID uint `json:"project_id"`
 
 	// The username/organization that this repo integration is linked to
-	RepoEntity string `json:"repo_entity"`
+	RepoEntity string `json:"repo_entity" gorm:"unique"`
 
 	// The various auth mechanisms available to the integration
 	OAuthIntegrationID uint
@@ -62,7 +62,10 @@ type GitActionConfig struct {
 	GitRepoID uint `json:"git_repo_id"`
 
 	// The path to the dockerfile in the git repo
-	DockerfilePath string `json:"dockerfile_path" form:"required"`
+	DockerfilePath string `json:"dockerfile_path"`
+
+	// The build context
+	FolderPath string `json:"folder_path"`
 }
 
 // GitActionConfigExternal is an external GitActionConfig to be shared over REST
@@ -78,6 +81,9 @@ type GitActionConfigExternal struct {
 
 	// The path to the dockerfile in the git repo
 	DockerfilePath string `json:"dockerfile_path" form:"required"`
+
+	// The build context
+	FolderPath string `json:"folder_path"`
 }
 
 // Externalize generates an external GitActionConfig to be shared over REST
@@ -87,5 +93,6 @@ func (r *GitActionConfig) Externalize() *GitActionConfigExternal {
 		ImageRepoURI:   r.ImageRepoURI,
 		GitRepoID:      r.GitRepoID,
 		DockerfilePath: r.DockerfilePath,
+		FolderPath:     r.FolderPath,
 	}
 }

+ 42 - 0
internal/registry/registry.go

@@ -373,6 +373,48 @@ func (r *Registry) setTokenCacheFunc(
 	}
 }
 
+// CreateRepository creates a repository for a registry, if needed
+// (currently only required for ECR)
+func (r *Registry) CreateRepository(
+	repo repository.Repository,
+	name string,
+) error {
+	// if aws, create repository
+	if r.AWSIntegrationID != 0 {
+		return r.createECRRepository(repo, name)
+	}
+
+	// otherwise, no-op
+	return nil
+}
+
+func (r *Registry) createECRRepository(
+	repo repository.Repository,
+	name string,
+) error {
+	aws, err := repo.AWSIntegration.ReadAWSIntegration(
+		r.AWSIntegrationID,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	sess, err := aws.GetSession()
+
+	if err != nil {
+		return err
+	}
+
+	svc := ecr.New(sess)
+
+	_, err = svc.CreateRepository(&ecr.CreateRepositoryInput{
+		RepositoryName: &name,
+	})
+
+	return err
+}
+
 // ListImages lists the images for an image repository
 func (r *Registry) ListImages(
 	repoName string,

+ 2 - 0
server/api/deploy_handler.go

@@ -146,6 +146,8 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 			ImageRepoURI:   form.GithubActionConfig.ImageRepoURI,
 			DockerfilePath: form.GithubActionConfig.DockerfilePath,
 			GitRepoID:      form.GithubActionConfig.GitRepoID,
+			BuildEnv:       form.GithubActionConfig.BuildEnv,
+			RegistryID:     form.GithubActionConfig.RegistryID,
 		}
 
 		// validate the form

+ 30 - 0
server/api/git_action_handler.go

@@ -13,6 +13,7 @@ import (
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/integrations/ci/actions"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/registry"
 )
 
 // HandleCreateGitAction creates a new Github action in a repository for a given
@@ -81,6 +82,33 @@ func (app *App) createGitActionFromForm(
 		return nil
 	}
 
+	// if the registry was provisioned through Porter, create a repository if necessary
+	if form.RegistryID != 0 {
+		// read the registry
+		reg, err := app.Repo.Registry.ReadRegistry(form.RegistryID)
+
+		if err != nil {
+			app.handleErrorDataRead(err, w)
+			return nil
+		}
+
+		if reg.InfraID != 0 {
+			_reg := registry.Registry(*reg)
+			regAPI := &_reg
+
+			// parse the name from the registry
+			nameSpl := strings.Split(form.ImageRepoURI, "/")
+			repoName := nameSpl[len(nameSpl)-1]
+
+			err := regAPI.CreateRepository(*app.Repo, repoName)
+
+			if err != nil {
+				app.handleErrorInternal(err, w)
+				return nil
+			}
+		}
+	}
+
 	// convert the form to a git action config
 	gitAction, err := form.ToGitActionConfig()
 
@@ -136,8 +164,10 @@ func (app *App) createGitActionFromForm(
 		ProjectID:      uint(projID),
 		ReleaseName:    name,
 		DockerFilePath: gitAction.DockerfilePath,
+		FolderPath:     gitAction.FolderPath,
 		ImageRepoURL:   gitAction.ImageRepoURI,
 		PorterToken:    encoded,
+		BuildEnv:       form.BuildEnv,
 	}
 
 	_, err = gaRunner.Setup()

+ 71 - 0
server/api/k8s_handler.go

@@ -2,13 +2,16 @@ package api
 
 import (
 	"encoding/json"
+	"fmt"
 	"net/http"
 	"net/url"
 
 	"github.com/go-chi/chi"
+	"github.com/gorilla/schema"
 	"github.com/gorilla/websocket"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
 	v1 "k8s.io/api/core/v1"
 )
 
@@ -321,3 +324,71 @@ func (app *App) HandleStreamControllerStatus(w http.ResponseWriter, r *http.Requ
 		return
 	}
 }
+
+func (app *App) HandleGetPodMetrics(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.MetricsQueryForm{
+		K8sForm: &forms.K8sForm{
+			OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+				Repo:              app.Repo,
+				DigitalOceanOAuth: app.DOConf,
+			},
+		},
+		QueryOpts: &prometheus.QueryOpts{},
+	}
+
+	form.K8sForm.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// decode from JSON to form value
+	decoder := schema.NewDecoder()
+	decoder.IgnoreUnknownKeys(true)
+
+	if err := decoder.Decode(form.QueryOpts, vals); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	// get prometheus service
+	promSvc, found, err := prometheus.GetPrometheusService(agent.Clientset)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if !found {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	rawQuery, err := prometheus.QueryPrometheus(agent.Clientset, promSvc, form.QueryOpts)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	fmt.Fprint(w, string(rawQuery))
+}

+ 3 - 2
server/router/middleware/auth.go

@@ -4,7 +4,6 @@ import (
 	"bytes"
 	"encoding/json"
 	"errors"
-	"fmt"
 	"io/ioutil"
 	"net/http"
 	"net/url"
@@ -702,7 +701,9 @@ func (auth *Auth) getTokenFromRequest(r *http.Request) *token.Token {
 
 	tok, err := token.GetTokenFromEncoded(reqToken, auth.tokenConf)
 
-	fmt.Printf("ERROR WAS %v\n", err)
+	if err != nil {
+		return nil
+	}
 
 	return tok
 }

+ 14 - 0
server/router/router.go

@@ -1050,6 +1050,20 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		r.Method(
+			"GET",
+			"/projects/{project_id}/k8s/metrics",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleGetPodMetrics, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		r.Method(
 			"GET",
 			"/projects/{project_id}/k8s/{namespace}/pod/{name}/logs",

+ 13 - 0
services/deploy_init_container/Dockerfile

@@ -0,0 +1,13 @@
+FROM alpine
+CMD ["echo", "-------------------------------------------------------------------\n\
+👋 Hello from Porter!\n\
+-------------------------------------------------------------------\n\
+-------------------------------------------------------------------\n\
+Your application is being deployed.\n\
+To view build logs, navigate to your connected GitHub repo and     \n\
+select the Actions tab.\n\
+-------------------------------------------------------------------\n\
+-------------------------------------------------------------------\n\
+For more information, visit:\n\
+https://docs.getporter.dev/docs/setting-up-cicd-1\n\
+-------------------------------------------------------------------"]