Browse Source

merge with master and fix conflicts

Alexander Belanger 4 years ago
parent
commit
9e392993e3
100 changed files with 9531 additions and 1232 deletions
  1. 3 3
      .air.toml
  2. 8 3
      .github/workflows/dev.yaml
  3. 7 2
      .github/workflows/production.yaml
  4. 39 12
      .github/workflows/release.yaml
  5. 7 2
      .github/workflows/staging.yaml
  6. 6 0
      .gitignore
  7. 1 27
      CONTRIBUTING.md
  8. 14 0
      Makefile
  9. 10 5
      api/client/api.go
  10. 150 0
      api/client/deploy.go
  11. 60 0
      api/client/domain.go
  12. 33 1
      api/client/git_repo.go
  13. 10 5
      api/client/github_action.go
  14. 39 0
      api/client/registry.go
  15. 66 0
      api/client/template.go
  16. 2 0
      api/server/handlers/handler.go
  17. 56 0
      api/types/policy.go
  18. 72 50
      cli/cmd/auth.go
  19. 133 230
      cli/cmd/config.go
  20. 0 20
      cli/cmd/connect.go
  21. 0 125
      cli/cmd/connect/actions.go
  22. 296 0
      cli/cmd/create.go
  23. 0 30
      cli/cmd/create/create.go
  24. 125 110
      cli/cmd/deploy.go
  25. 140 0
      cli/cmd/deploy/build.go
  26. 504 0
      cli/cmd/deploy/create.go
  27. 91 87
      cli/cmd/deploy/deploy.go
  28. 12 0
      cli/cmd/deploy/shared.go
  29. 4 0
      cli/cmd/docker.go
  30. 4 2
      cli/cmd/docker/agent.go
  31. 79 5
      cli/cmd/docker/builder.go
  32. 6 2
      cli/cmd/errors.go
  33. 102 0
      cli/cmd/gitutils/git.go
  34. 89 0
      cli/cmd/job.go
  35. 15 3
      cli/cmd/login/server.go
  36. 113 0
      cli/cmd/logs.go
  37. 14 0
      cli/cmd/project.go
  38. 274 21
      cli/cmd/run.go
  39. 1 0
      cli/cmd/server.go
  40. 10 1
      cli/cmd/utils/browser.go
  41. 29 0
      cli/cmd/utils/wsl.go
  42. 1 1
      cli/cmd/version.go
  43. 1 30
      cmd/app/main.go
  44. 8 6
      cmd/migrate/keyrotate/helpers_test.go
  45. 4 33
      cmd/migrate/main.go
  46. 4 0
      dashboard/babel.config.json
  47. 2340 25
      dashboard/package-lock.json
  48. 27 10
      dashboard/package.json
  49. 121 0
      dashboard/react-table.d.ts
  50. 44 10
      dashboard/src/App.tsx
  51. BIN
      dashboard/src/assets/back_arrow.png
  52. BIN
      dashboard/src/assets/node.png
  53. BIN
      dashboard/src/assets/trash.png
  54. 57 0
      dashboard/src/components/Button.tsx
  55. 110 0
      dashboard/src/components/CopyToClipboard.tsx
  56. 1 1
      dashboard/src/components/ExpandableResource.tsx
  57. 0 0
      dashboard/src/components/Helper.tsx
  58. 12 4
      dashboard/src/components/Loading.tsx
  59. 189 0
      dashboard/src/components/PageNotFound.tsx
  60. 4 1
      dashboard/src/components/RadioSelector.tsx
  61. 1 5
      dashboard/src/components/ResourceTab.tsx
  62. 112 49
      dashboard/src/components/SaveButton.tsx
  63. 93 0
      dashboard/src/components/SearchBar.tsx
  64. 53 1
      dashboard/src/components/Selector.tsx
  65. 86 0
      dashboard/src/components/StatusSection.tsx
  66. 28 40
      dashboard/src/components/TabRegion.tsx
  67. 221 0
      dashboard/src/components/Table.tsx
  68. 96 0
      dashboard/src/components/TitleSection.tsx
  69. 59 0
      dashboard/src/components/UnauthorizedPage.tsx
  70. 117 0
      dashboard/src/components/UnexpectedErrorPage.tsx
  71. 4 3
      dashboard/src/components/YamlEditor.tsx
  72. 0 0
      dashboard/src/components/form-components/CheckboxList.tsx
  73. 0 0
      dashboard/src/components/form-components/CheckboxRow.tsx
  74. 43 0
      dashboard/src/components/form-components/Heading.tsx
  75. 0 0
      dashboard/src/components/form-components/Helper.tsx
  76. 56 7
      dashboard/src/components/form-components/InputRow.tsx
  77. 34 17
      dashboard/src/components/form-components/KeyValueArray.tsx
  78. 0 0
      dashboard/src/components/form-components/SelectRow.tsx
  79. 0 0
      dashboard/src/components/form-components/TextArea.tsx
  80. 0 1
      dashboard/src/components/form-components/UploadArea.tsx
  81. 3 7
      dashboard/src/components/image-selector/ImageList.tsx
  82. 5 101
      dashboard/src/components/image-selector/ImageSelector.tsx
  83. 54 3
      dashboard/src/components/image-selector/TagList.tsx
  84. 537 0
      dashboard/src/components/porter-form/FormDebugger.tsx
  85. 233 0
      dashboard/src/components/porter-form/PorterForm.tsx
  86. 462 0
      dashboard/src/components/porter-form/PorterFormContextProvider.tsx
  87. 99 0
      dashboard/src/components/porter-form/PorterFormWrapper.tsx
  88. 183 0
      dashboard/src/components/porter-form/field-components/ArrayInput.tsx
  89. 76 0
      dashboard/src/components/porter-form/field-components/Checkbox.tsx
  90. 108 0
      dashboard/src/components/porter-form/field-components/Input.tsx
  91. 516 0
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  92. 0 0
      dashboard/src/components/porter-form/field-components/MultiSelect.tsx
  93. 32 0
      dashboard/src/components/porter-form/field-components/ResourceList.tsx
  94. 96 0
      dashboard/src/components/porter-form/field-components/Select.tsx
  95. 23 0
      dashboard/src/components/porter-form/field-components/ServiceIPList.tsx
  96. 114 0
      dashboard/src/components/porter-form/field-components/ServiceRow.tsx
  97. 3 3
      dashboard/src/components/porter-form/field-components/VeleroForm.tsx
  98. 81 0
      dashboard/src/components/porter-form/hooks/useFormField.tsx
  99. 248 0
      dashboard/src/components/porter-form/types.ts
  100. 108 128
      dashboard/src/components/repo-selector/ActionConfEditor.tsx

+ 3 - 3
.air.toml

@@ -7,11 +7,11 @@ tmp_dir = "tmp"
 
 [build]
 # Just plain old shell command. You could use `make` as well.
-cmd = "go build -o ./tmp/ready ./cmd/ready; go build -o ./tmp/migrate ./cmd/migrate; go build -o ./tmp/app ./cmd/app"
+cmd = "go build -o ./tmp/app ./cmd/app"
 # Binary file yields from `cmd`.
-bin = "tmp/migrate; tmp/app"
+bin = "tmp/app"
 # Customize binary.
-full_bin = "tmp/migrate; tmp/app"
+full_bin = "tmp/app"
 # Watch these filename extensions.
 include_ext = ["go", "mod", "sum", "html"]
 # Ignore these filename extensions or directories.

+ 8 - 3
.github/workflows/dev.yaml

@@ -13,6 +13,12 @@ jobs:
           project_id: ${{ secrets.GCP_PROJECT_ID }}
           service_account_key: ${{ secrets.GCP_SA_KEY }}
           export_default_credentials: true
+      - name: Configure AWS Credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+          aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
         uses: azure/setup-kubectl@v1
       - name: Log in to gcloud CLI
@@ -42,7 +48,6 @@ jobs:
           docker push gcr.io/porter-dev-273614/porter:dev
       - name: Deploy to cluster
         run: |
-          gcloud container clusters get-credentials \
-            dev --region us-central1 --project ${{ secrets.GCP_PROJECT_ID }}
+          aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name dev
             
-          kubectl rollout restart deployment/porter
+          kubectl rollout restart deployment/porter

+ 7 - 2
.github/workflows/production.yaml

@@ -13,6 +13,12 @@ jobs:
           project_id: ${{ secrets.GCP_PROJECT_ID }}
           service_account_key: ${{ secrets.GCP_SA_KEY }}
           export_default_credentials: true
+      - name: Configure AWS Credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+          aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
         uses: azure/setup-kubectl@v1
       - name: Log in to gcloud CLI
@@ -42,7 +48,6 @@ jobs:
           docker push gcr.io/porter-dev-273614/porter:latest
       - name: Deploy to cluster
         run: |
-          gcloud container clusters get-credentials \
-            production-2 --region us-central1 --project ${{ secrets.GCP_PROJECT_ID }}
+          aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name production-2
             
           kubectl rollout restart deployment/porter

+ 39 - 12
.github/workflows/release.yaml

@@ -27,11 +27,8 @@ jobs:
         run: |
           cat >./dashboard/.env <<EOL
           NODE_ENV=production
-          API_SERVER=dashboard.getporter.dev
-          FULLSTORY_ORG_ID=${{secrets.FULLSTORY_ORG_ID}}
-          DISCORD_KEY=${{secrets.DISCORD_KEY}}
-          DISCORD_CID=${{secrets.DISCORD_CID}}
-          FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
+          APPLICATION_CHART_REPO_URL=https://charts.getporter.dev
+          ADDON_CHART_REPO_URL=https://chart-addons.getporter.dev
           EOL
 
           cat ./dashboard/.env
@@ -62,13 +59,8 @@ jobs:
         run: |
           cat >./dashboard/.env <<EOL
           NODE_ENV=production
-          API_SERVER=dashboard.getporter.dev
-          FULLSTORY_ORG_ID=${{secrets.FULLSTORY_ORG_ID}}
-          DISCORD_KEY=${{secrets.DISCORD_KEY}}
-          DISCORD_CID=${{secrets.DISCORD_CID}}
-          FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
-          POSTHOG_API_KEY=${{secrets.POSTHOG_API_KEY}}
-          POSTHOG_HOST=${{secrets.POSTHOG_HOST}}
+          APPLICATION_CHART_REPO_URL=https://charts.getporter.dev
+          ADDON_CHART_REPO_URL=https://chart-addons.getporter.dev
           EOL
       - name: Build and zip static folder
         run: |
@@ -355,3 +347,38 @@ jobs:
           asset_path: ./release/static/static_${{steps.tag_name.outputs.tag}}.zip
           asset_name: static_${{steps.tag_name.outputs.tag}}.zip
           asset_content_type: application/zip
+  build-push-docker-cli:
+    name: Build a new porter-cli docker image
+    runs-on: ubuntu-latest
+    needs: release
+    steps:
+      - name: Get tag name
+        id: tag_name
+        run: |
+          tag=${GITHUB_TAG/refs\/tags\//}
+          echo ::set-output name=tag::$tag
+        env:
+          GITHUB_TAG: ${{ github.ref }}
+      - name: Checkout
+        uses: actions/checkout@v2.3.4
+      - name: Configure AWS credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.ECR_AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.ECR_AWS_SECRET_ACCESS_KEY }}
+          aws-region: us-east-2
+      - name: Login to ECR public
+        id: login-ecr
+        run: |
+          aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/o1j4x7p4
+      - name: Build
+        run: |
+          docker build ./services/porter_cli_container \
+            -t public.ecr.aws/o1j4x7p4/porter-cli:${{steps.tag_name.outputs.tag}} \
+            -t public.ecr.aws/o1j4x7p4/porter-cli:latest \
+            -f ./services/porter_cli_container/Dockerfile \
+            --build-arg VERSION=${{steps.tag_name.outputs.tag}}
+      - name: Push
+        run: |
+          docker push public.ecr.aws/o1j4x7p4/porter-cli:${{steps.tag_name.outputs.tag}}
+          docker push public.ecr.aws/o1j4x7p4/porter-cli:latest

+ 7 - 2
.github/workflows/staging.yaml

@@ -13,6 +13,12 @@ jobs:
           project_id: ${{ secrets.GCP_PROJECT_ID }}
           service_account_key: ${{ secrets.GCP_SA_KEY }}
           export_default_credentials: true
+      - name: Configure AWS Credentials
+        uses: aws-actions/configure-aws-credentials@v1
+        with:
+          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+          aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
         uses: azure/setup-kubectl@v1
       - name: Log in to gcloud CLI
@@ -42,7 +48,6 @@ jobs:
           docker push gcr.io/porter-dev-273614/porter:staging
       - name: Deploy to cluster
         run: |
-          gcloud container clusters get-credentials \
-            staging --region us-central1 --project ${{ secrets.GCP_PROJECT_ID }}
+          aws eks --region ${{ secrets.AWS_REGION }} update-kubeconfig --name staging
             
           kubectl rollout restart deployment/porter

+ 6 - 0
.gitignore

@@ -1,6 +1,7 @@
 .DS_Store
 .env
 docker/.env
+docker/github_app_private_key.pem
 app
 *.db
 test.yaml
@@ -10,6 +11,9 @@ internal/local_templates
 gon*.hcl
 *prod.Dockerfile
 staging.sh
+*.crt
+*.key
+bin
 
 # Local .terraform directories
 **/.terraform/*
@@ -52,3 +56,5 @@ override.tf.json
 # Ignore CLI configuration files
 .terraformrc
 terraform.rc
+
+tmp

+ 1 - 27
CONTRIBUTING.md

@@ -70,33 +70,7 @@ Here's an annotated directory structure to assist you in navigating the codebase
 
 ### Getting started
 
-If you've made it this far, you have all the information required to get your dev environment up and running! After forking and cloning the repo, you should save two `.env` files in the repo. 
-
-First, in `/dashboard/.env`:
-
-```
-NODE_ENV=development
-API_SERVER=localhost:8080
-```
-
-Next, in `/docker/.env`:
-
-```
-SERVER_URL=http://localhost:8080
-SERVER_PORT=8080
-DB_HOST=postgres
-DB_PORT=5432
-DB_USER=porter
-DB_PASS=porter
-DB_NAME=porter
-SQL_LITE=false
-```
-
-Once you've done this, go to the root repository, and run `docker-compose -f docker-compose.dev.yaml up`. You should see postgres, webpack, and porter containers spin up. When the webpack and porter containers have finished compiling and have spun up successfully (this will take 5-10 minutes after the containers start), you can navigate to `localhost:8080` and you should be greeted with the "Log In" screen. 
-
-At this point, you can make a change to any `.go` file to trigger a backend rebuild, and any file in `/dashboard/src` to trigger a hot reload. 
-
-For a more detailed development guide, [go here](/docs/developing/setup.md). 
+If you've made it this far, you have all the information required to get your dev environment up and running! After forking and cloning the repo, you should [follow this guide](/docs/developing/setup.md) for the development setup. 
 
 Happy developing!
 

+ 14 - 0
Makefile

@@ -0,0 +1,14 @@
+BINDIR      := $(CURDIR)/bin
+VERSION ?= dev
+
+start-dev: install setup-env-files
+	bash ./scripts/dev-environment/StartDevServer.sh
+
+install: 
+	bash ./scripts/dev-environment/SetupEnvironment.sh
+
+setup-env-files: 
+	bash ./scripts/dev-environment/CreateDefaultEnvFiles.sh
+
+build-cli: 
+	go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=${VERSION}'" -a -tags cli -o $(BINDIR)/porter ./cli

+ 10 - 5
api/client/api.go

@@ -144,11 +144,11 @@ type TokenProjectID struct {
 	ProjectID uint `json:"project_id"`
 }
 
-func GetProjectIDFromToken(token string) (uint, error) {
+func GetProjectIDFromToken(token string) (uint, bool, error) {
 	var encoded string
 
 	if tokenSplit := strings.Split(token, "."); len(tokenSplit) != 3 {
-		return 0, fmt.Errorf("invalid jwt token format")
+		return 0, false, fmt.Errorf("invalid jwt token format")
 	} else {
 		encoded = tokenSplit[1]
 	}
@@ -156,7 +156,7 @@ func GetProjectIDFromToken(token string) (uint, error) {
 	decodedBytes, err := base64.RawStdEncoding.DecodeString(encoded)
 
 	if err != nil {
-		return 0, fmt.Errorf("could not decode jwt token from base64: %v", err)
+		return 0, false, fmt.Errorf("could not decode jwt token from base64: %v", err)
 	}
 
 	res := &TokenProjectID{}
@@ -164,8 +164,13 @@ func GetProjectIDFromToken(token string) (uint, error) {
 	err = json.Unmarshal(decodedBytes, res)
 
 	if err != nil {
-		return 0, fmt.Errorf("could not get token project id: %v", err)
+		return 0, false, fmt.Errorf("could not get token project id: %v", err)
 	}
 
-	return res.ProjectID, nil
+	// if the project ID is 0, this is a token signed for a user, not a specific project
+	if res.ProjectID == 0 {
+		return 0, false, nil
+	}
+
+	return res.ProjectID, true, nil
 }

+ 150 - 0
api/client/deploy.go

@@ -2,9 +2,11 @@ package client
 
 import (
 	"context"
+	"encoding/json"
 	"fmt"
 	"net/http"
 	"net/url"
+	"strings"
 
 	"github.com/porter-dev/porter/internal/models"
 )
@@ -72,3 +74,151 @@ func (c *Client) DeployWithWebhook(
 
 	return nil
 }
+
+type UpdateBatchImageRequest struct {
+	ImageRepoURI string `json:"image_repo_uri"`
+	Tag          string `json:"tag"`
+}
+
+// UpdateBatchImage updates all releases that use a certain image with a new tag
+func (c *Client) UpdateBatchImage(
+	ctx context.Context,
+	projID, clusterID uint,
+	namespace string,
+	updateImageReq *UpdateBatchImageRequest,
+) error {
+	data, err := json.Marshal(updateImageReq)
+
+	if err != nil {
+		return nil
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/releases/image/update/batch?"+url.Values{
+			"cluster_id": []string{fmt.Sprintf("%d", clusterID)},
+			"namespace":  []string{namespace},
+			"storage":    []string{"secret"},
+		}.Encode(), c.BaseURL, projID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	req = req.WithContext(ctx)
+
+	if httpErr, err := c.sendRequest(req, nil, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return err
+	}
+
+	return nil
+}
+
+type DeployTemplateGitAction struct {
+	GitRepo        string            `json:"git_repo"`
+	GitBranch      string            `json:"git_branch"`
+	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"`
+}
+
+type DeployTemplateRequest struct {
+	TemplateName string                   `json:"templateName"`
+	ImageURL     string                   `json:"imageURL"`
+	FormValues   map[string]interface{}   `json:"formValues"`
+	Namespace    string                   `json:"namespace"`
+	Name         string                   `json:"name"`
+	GitAction    *DeployTemplateGitAction `json:"github_action"`
+}
+
+func (c *Client) DeployTemplate(
+	ctx context.Context,
+	projID, clusterID uint,
+	templateName string,
+	templateVersion string,
+	deployReq *DeployTemplateRequest,
+) error {
+	data, err := json.Marshal(deployReq)
+
+	if err != nil {
+		return err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/deploy/%s/%s?"+url.Values{
+			"cluster_id": []string{fmt.Sprintf("%d", clusterID)},
+			"storage":    []string{"secret"},
+		}.Encode(), c.BaseURL, projID, templateName, templateVersion),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	req = req.WithContext(ctx)
+
+	if httpErr, err := c.sendRequest(req, nil, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return err
+	}
+
+	return nil
+}
+
+type UpgradeReleaseRequest struct {
+	Values    string `json:"values"`
+	Namespace string `json:"namespace"`
+}
+
+func (c *Client) UpgradeRelease(
+	ctx context.Context,
+	projID, clusterID uint,
+	name string,
+	upgradeReq *UpgradeReleaseRequest,
+) error {
+	data, err := json.Marshal(upgradeReq)
+
+	if err != nil {
+		return err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/releases/%s/upgrade?"+url.Values{
+			"namespace":  []string{upgradeReq.Namespace},
+			"cluster_id": []string{fmt.Sprintf("%d", clusterID)},
+			"storage":    []string{"secret"},
+		}.Encode(), c.BaseURL, projID, name),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	req = req.WithContext(ctx)
+
+	if httpErr, err := c.sendRequest(req, nil, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return err
+	}
+
+	return nil
+}

+ 60 - 0
api/client/domain.go

@@ -0,0 +1,60 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type CreateDNSRecordRequest struct {
+	ReleaseName string `json:"release_name"`
+}
+
+// CreateDNSRecordResponse is the DNS record that was created
+type CreateDNSRecordResponse models.DNSRecordExternal
+
+// CreateDNSRecord creates a Github action with basic authentication
+func (c *Client) CreateDNSRecord(
+	ctx context.Context,
+	projectID, clusterID uint,
+	createDNS *CreateDNSRecordRequest,
+) (*CreateDNSRecordResponse, error) {
+	data, err := json.Marshal(createDNS)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf(
+			"%s/projects/%d/k8s/subdomain?cluster_id=%d",
+			c.BaseURL,
+			projectID,
+			clusterID,
+		),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+
+	res := &CreateDNSRecordResponse{}
+
+	if httpErr, err := c.sendRequest(req, res, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return res, nil
+}

+ 33 - 1
api/client/git_repo.go

@@ -10,7 +10,7 @@ import (
 )
 
 // ListGitRepoResponse is the list of Git repo integrations for a project
-type ListGitRepoResponse []models.GitRepoExternal
+type ListGitRepoResponse []uint
 
 // ListGitRepos returns a list of Git repos for a project
 func (c *Client) ListGitRepos(
@@ -80,3 +80,35 @@ func (c *Client) GetRepoZIPDownloadURL(
 
 	return bodyResp, nil
 }
+
+// ListGitRepoResponse is the list of Git repo integrations for a project
+type ListGithubReposResponse []*api.Repo
+
+// ListGitRepos returns a list of Git repos for a project
+func (c *Client) ListGithubRepos(
+	ctx context.Context,
+	projectID, gitRepoID uint,
+) (ListGithubReposResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/gitrepos/%d/repos", c.BaseURL, projectID, gitRepoID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &ListGithubReposResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return *bodyResp, nil
+}

+ 10 - 5
api/client/github_action.go

@@ -11,10 +11,15 @@ import (
 // CreateGithubActionRequest represents the accepted fields for creating
 // a Github action
 type CreateGithubActionRequest struct {
-	GitRepo        string `json:"git_repo"`
-	ImageRepoURI   string `json:"image_repo_uri"`
-	DockerfilePath string `json:"dockerfile_path"`
-	GitRepoID      uint   `json:"git_repo_id"`
+	ReleaseID            uint   `json:"release_id" form:"required"`
+	GitRepo              string `json:"git_repo" form:"required"`
+	GitBranch            string `json:"git_branch"`
+	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"`
+	RegistryID           uint   `json:"registry_id"`
+	ShouldCreateWorkflow bool   `json:"should_create_workflow"`
 }
 
 // CreateGithubAction creates a Github action with basic authentication
@@ -33,7 +38,7 @@ func (c *Client) CreateGithubAction(
 	req, err := http.NewRequest(
 		"POST",
 		fmt.Sprintf(
-			"%s/projects/%d/ci/actions?cluster_id=%d&name=%s&namespace=%s",
+			"%s/projects/%d/ci/actions/create?cluster_id=%d&name=%s&namespace=%s",
 			c.BaseURL,
 			projectID,
 			clusterID,

+ 39 - 0
api/client/registry.go

@@ -472,3 +472,42 @@ func (c *Client) ListImages(
 
 	return *bodyResp, nil
 }
+
+type CreateRepositoryRequest struct {
+	ImageRepoURI string `json:"image_repo_uri"`
+}
+
+// CreateECR creates an Elastic Container Registry integration
+func (c *Client) CreateRepository(
+	ctx context.Context,
+	projectID, regID uint,
+	createRepo *CreateRepositoryRequest,
+) error {
+	data, err := json.Marshal(createRepo)
+
+	if err != nil {
+		return err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/registries/%d/repository", c.BaseURL, projectID, regID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	req = req.WithContext(ctx)
+
+	if httpErr, err := c.sendRequest(req, nil, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return err
+	}
+
+	return nil
+}

+ 66 - 0
api/client/template.go

@@ -0,0 +1,66 @@
+package api
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/internal/models"
+)
+
+func (c *Client) ListTemplates(
+	ctx context.Context,
+) ([]*models.PorterChartList, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/templates", c.BaseURL),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+
+	bodyResp := make([]*models.PorterChartList, 0)
+
+	if httpErr, err := c.sendRequest(req, &bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+func (c *Client) GetTemplate(
+	ctx context.Context,
+	name, version string,
+) (*models.PorterChartRead, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/templates/%s/%s", c.BaseURL, name, version),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+
+	bodyResp := &models.PorterChartRead{}
+
+	if httpErr, err := c.sendRequest(req, &bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}

+ 2 - 0
api/server/handlers/handler.go

@@ -19,6 +19,8 @@ type PorterHandlerReadWriter interface{
 	PorterHandlerReader
 }
 
+// default
+
 // type PorterHandler struct {
 // 	config           *shared.Config
 // 	decoderValidator shared.RequestDecoderValidator

+ 56 - 0
api/types/policy.go

@@ -26,7 +26,10 @@ type PolicyDocument struct {
 type ScopeTree map[PermissionScope]ScopeTree
 
 /* ScopeHeirarchy describes the scope tree:
+<<<<<<< HEAD
 
+=======
+>>>>>>> master
 			Project
 		   /	   \
 		Cluster   Settings
@@ -47,3 +50,56 @@ var ScopeHeirarchy = ScopeTree{
 }
 
 type Policy []*PolicyDocument
+
+type APIVerb string
+
+const (
+	APIVerbGet    APIVerb = "get"
+	APIVerbCreate APIVerb = "create"
+	APIVerbList   APIVerb = "list"
+	APIVerbUpdate APIVerb = "update"
+	APIVerbDelete APIVerb = "delete"
+)
+
+type APIVerbGroup []APIVerb
+
+func ReadVerbGroup() APIVerbGroup {
+	return []APIVerb{APIVerbGet, APIVerbList}
+}
+
+func ReadWriteVerbGroup() APIVerbGroup {
+	return []APIVerb{APIVerbGet, APIVerbList, APIVerbCreate, APIVerbUpdate, APIVerbDelete}
+}
+
+var AdminPolicy = []*PolicyDocument{
+	{
+		Scope: ProjectScope,
+		Verbs: ReadWriteVerbGroup(),
+	},
+}
+
+var DeveloperPolicy = []*PolicyDocument{
+	{
+		Scope: ProjectScope,
+		Verbs: ReadWriteVerbGroup(),
+		Children: map[PermissionScope]*PolicyDocument{
+			SettingsScope: {
+				Scope: SettingsScope,
+				Verbs: ReadVerbGroup(),
+			},
+		},
+	},
+}
+
+var ViewerPolicy = []*PolicyDocument{
+	{
+		Scope: ProjectScope,
+		Verbs: ReadVerbGroup(),
+		Children: map[PermissionScope]*PolicyDocument{
+			SettingsScope: {
+				Scope: SettingsScope,
+				Verbs: []APIVerb{},
+			},
+		},
+	},
+}

+ 72 - 50
cli/cmd/auth.go

@@ -56,7 +56,6 @@ var logoutCmd = &cobra.Command{
 	},
 }
 
-var token string = ""
 var manual bool = false
 
 func init() {
@@ -80,7 +79,44 @@ func login() error {
 	user, _ := client.AuthCheck(context.Background())
 
 	if user != nil {
-		color.Yellow("You are already logged in. If you'd like to log out, run \"porter auth logout\".")
+		// set the token if the user calls login with the --token flag or the PORTER_TOKEN env
+		if config.Token != "" {
+			config.SetToken(config.Token)
+			color.New(color.FgGreen).Println("Successfully logged in!")
+
+			projID, exists, err := api.GetProjectIDFromToken(config.Token)
+
+			if err != nil {
+				return err
+			}
+
+			// if project ID does not exist for the token, this is a user-issued CLI token, so the project
+			// ID should be queried
+			if !exists {
+				err = setProjectForUser(client, user.ID)
+
+				if err != nil {
+					return err
+				}
+			} else {
+				// if the project ID does exist for the token, this is a project-issued token, and
+				// the project should be set automatically
+				err = config.SetProject(projID)
+
+				if err != nil {
+					return err
+				}
+
+				err = setProjectCluster(client, projID)
+
+				if err != nil {
+					return err
+				}
+			}
+		} else {
+			color.Yellow("You are already logged in. If you'd like to log out, run \"porter auth logout\".")
+		}
+
 		return nil
 	}
 
@@ -89,70 +125,50 @@ func login() error {
 		return loginManual()
 	}
 
-	// check for a token
-	var err error
-
-	if token == "" {
-		token, err = loginBrowser.Login(config.Host)
-
-		if err != nil {
-			return err
-		}
-
-		// set the token in config
-		err = config.SetToken(token)
-
-		if err != nil {
-			return err
-		}
+	// log the user in
+	token, err := loginBrowser.Login(config.Host)
 
-		client := api.NewClientWithToken(config.Host+"/api", token)
-
-		user, err := client.AuthCheck(context.Background())
+	if err != nil {
+		return err
+	}
 
-		if user == nil {
-			color.Red("Invalid token.")
-			return err
-		}
+	// set the token in config
+	err = config.SetToken(token)
 
-		color.New(color.FgGreen).Println("Successfully logged in!")
+	if err != nil {
+		return err
+	}
 
-		// get a list of projects, and set the current project
-		projects, err := client.ListUserProjects(context.Background(), user.ID)
+	client = api.NewClientWithToken(config.Host+"/api", token)
 
-		if err != nil {
-			return err
-		}
+	user, err = client.AuthCheck(context.Background())
 
-		if len(projects) > 0 {
-			config.SetProject(projects[0].ID)
-		}
-	} else {
-		// set the token in config
-		err = config.SetToken(token)
+	if user == nil {
+		color.Red("Invalid token.")
+		return err
+	}
 
-		if err != nil {
-			return err
-		}
+	color.New(color.FgGreen).Println("Successfully logged in!")
 
-		client := api.NewClientWithToken(config.Host+"/api", token)
+	return setProjectForUser(client, user.ID)
+}
 
-		user, err := client.AuthCheck(context.Background())
+func setProjectForUser(client *api.Client, userID uint) error {
+	// get a list of projects, and set the current project
+	projects, err := client.ListUserProjects(context.Background(), userID)
 
-		if user == nil {
-			color.Red("Invalid token.")
-			return err
-		}
+	if err != nil {
+		return err
+	}
 
-		color.New(color.FgGreen).Println("Successfully logged in!")
+	if len(projects) > 0 {
+		config.SetProject(projects[0].ID)
 
-		projID, err := api.GetProjectIDFromToken(token)
+		err = setProjectCluster(client, projects[0].ID)
 
 		if err != nil {
 			return err
 		}
-
-		config.SetProject(projID)
 	}
 
 	return nil
@@ -200,6 +216,12 @@ func loginManual() error {
 
 	if len(projects) > 0 {
 		config.SetProject(projects[0].ID)
+
+		err = setProjectCluster(client, projects[0].ID)
+
+		if err != nil {
+			return err
+		}
 	}
 
 	return nil

+ 133 - 230
cli/cmd/config.go

@@ -1,11 +1,14 @@
 package cmd
 
 import (
+	"fmt"
 	"io/ioutil"
 	"os"
 	"path/filepath"
+	"strconv"
 
 	"github.com/fatih/color"
+	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
 
 	flag "github.com/spf13/pflag"
@@ -258,233 +261,133 @@ func (c *CLIConfig) SetHelmRepo(helmRepoID uint) error {
 	return nil
 }
 
-// var configCmd = &cobra.Command{
-// 	Use:   "config",
-// 	Short: "Commands that control local configuration settings",
-// 	Run: func(cmd *cobra.Command, args []string) {
-// 		if err := printConfig(); err != nil {
-// 			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
-// 			os.Exit(1)
-// 		}
-// 	},
-// }
-
-// var config.SetProjectCmd = &cobra.Command{
-// 	Use:   "set-project [id]",
-// 	Args:  cobra.ExactArgs(1),
-// 	Short: "Saves the project id in the default configuration",
-// 	Run: func(cmd *cobra.Command, args []string) {
-// 		projID, err := strconv.ParseUint(args[0], 10, 64)
-
-// 		if err != nil {
-// 			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
-// 			os.Exit(1)
-// 		}
-
-// 		err = config.SetProject(uint(projID))
-
-// 		if err != nil {
-// 			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
-// 			os.Exit(1)
-// 		}
-// 	},
-// }
-
-// var config.SetClusterCmd = &cobra.Command{
-// 	Use:   "set-cluster [id]",
-// 	Args:  cobra.ExactArgs(1),
-// 	Short: "Saves the cluster id in the default configuration",
-// 	Run: func(cmd *cobra.Command, args []string) {
-// 		clusterID, err := strconv.ParseUint(args[0], 10, 64)
-
-// 		if err != nil {
-// 			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
-// 			os.Exit(1)
-// 		}
-
-// 		err = config.SetCluster(uint(clusterID))
-
-// 		if err != nil {
-// 			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
-// 			os.Exit(1)
-// 		}
-// 	},
-// }
-
-// var setRegistryCmd = &cobra.Command{
-// 	Use:   "set-registry [id]",
-// 	Args:  cobra.ExactArgs(1),
-// 	Short: "Saves the registry id in the default configuration",
-// 	Run: func(cmd *cobra.Command, args []string) {
-// 		registryID, err := strconv.ParseUint(args[0], 10, 64)
-
-// 		if err != nil {
-// 			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
-// 			os.Exit(1)
-// 		}
-
-// 		err = setRegistry(uint(registryID))
-
-// 		if err != nil {
-// 			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
-// 			os.Exit(1)
-// 		}
-// 	},
-// }
-
-// var setHelmRepoCmd = &cobra.Command{
-// 	Use:   "set-helmrepo [id]",
-// 	Args:  cobra.ExactArgs(1),
-// 	Short: "Saves the helm repo id in the default configuration",
-// 	Run: func(cmd *cobra.Command, args []string) {
-// 		hrID, err := strconv.ParseUint(args[0], 10, 64)
-
-// 		if err != nil {
-// 			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
-// 			os.Exit(1)
-// 		}
-
-// 		err = setHelmRepo(uint(hrID))
-
-// 		if err != nil {
-// 			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
-// 			os.Exit(1)
-// 		}
-// 	},
-// }
-
-// var config.SetHostCmd = &cobra.Command{
-// 	Use:   "set-host [host]",
-// 	Args:  cobra.ExactArgs(1),
-// 	Short: "Saves the host in the default configuration",
-// 	Run: func(cmd *cobra.Command, args []string) {
-// 		err := config.SetHost(args[0])
-
-// 		if err != nil {
-// 			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
-// 			os.Exit(1)
-// 		}
-// 	},
-// }
-
-// func init() {
-// 	rootCmd.AddCommand(configCmd)
-
-// 	configCmd.AddCommand(config.SetProjectCmd)
-// 	configCmd.AddCommand(config.SetClusterCmd)
-// 	configCmd.AddCommand(config.SetHostCmd)
-// 	configCmd.AddCommand(setRegistryCmd)
-// 	configCmd.AddCommand(setHelmRepoCmd)
-// }
-
-// func setDriver(driver string) error {
-// 	viper.Set("driver", driver)
-// 	err := viper.WriteConfig()
-// 	color.New(color.FgGreen).Printf("Set the current driver as %s\n", driver)
-// 	return err
-// }
-
-// func getDriver() string {
-// 	if driver != "" {
-// 		return driver
-// 	}
-
-// 	if opts.driver != "" {
-// 		return opts.driver
-// 	}
-
-// 	return viper.GetString("driver")
-// }
-
-// func printConfig() error {
-// 	config, err := ioutil.ReadFile(filepath.Join(home, ".porter", "porter.yaml"))
-
-// 	if err != nil {
-// 		return err
-// 	}
-
-// 	fmt.Printf(string(config))
-
-// 	return nil
-// }
-
-// func config.SetProject(id uint) error {
-// 	viper.Set("project", id)
-// 	color.New(color.FgGreen).Printf("Set the current project id as %d\n", id)
-// 	return viper.WriteConfig()
-// }
-
-// func config.SetCluster(id uint) error {
-// 	viper.Set("cluster", id)
-// 	color.New(color.FgGreen).Printf("Set the current cluster id as %d\n", id)
-// 	return viper.WriteConfig()
-// }
-
-// func setRegistry(id uint) error {
-// 	viper.Set("registry", id)
-// 	color.New(color.FgGreen).Printf("Set the current registry id as %d\n", id)
-// 	return viper.WriteConfig()
-// }
-
-// func setHelmRepo(id uint) error {
-// 	viper.Set("helm_repo", id)
-// 	color.New(color.FgGreen).Printf("Set the current helm repo id as %d\n", id)
-// 	return viper.WriteConfig()
-// }
-
-// func config.SetHost(host string) error {
-// 	viper.Set("host", host)
-// 	err := viper.WriteConfig()
-// 	color.New(color.FgGreen).Printf("Set the current host as %s\n", host)
-// 	return err
-// }
-
-// func config.SetToken(token string) error {
-// 	viper.Set("token", token)
-// 	err := viper.WriteConfig()
-// 	return err
-// }
-
-// func config.Host string {
-// 	if host != "" {
-// 		return host
-// 	}
-
-// 	return viper.GetString("host")
-// }
-
-// func config.Token string {
-// 	return viper.GetString("token")
-// }
-
-// func config.Cluster() uint {
-// 	if clusterID != 0 {
-// 		return clusterID
-// 	}
-
-// 	return viper.GetUint("cluster")
-// }
-
-// func config.Registry uint {
-// 	if registryID != 0 {
-// 		return registryID
-// 	}
-
-// 	return viper.GetUint("registry")
-// }
-
-// func config.HelmRepo() uint {
-// 	if helmRepoID != 0 {
-// 		return helmRepoID
-// 	}
-
-// 	return viper.GetUint("helm_repo")
-// }
-
-// func config.Project uint {
-// 	if projectID != 0 {
-// 		return projectID
-// 	}
-
-// 	return viper.GetUint("project")
-// }
+var configCmd = &cobra.Command{
+	Use:   "config",
+	Short: "Commands that control local configuration settings",
+	Run: func(cmd *cobra.Command, args []string) {
+		if err := printConfig(); err != nil {
+			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			os.Exit(1)
+		}
+	},
+}
+
+var configSetProjectCmd = &cobra.Command{
+	Use:   "set-project [id]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Saves the project id in the default configuration",
+	Run: func(cmd *cobra.Command, args []string) {
+		projID, err := strconv.ParseUint(args[0], 10, 64)
+
+		if err != nil {
+			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			os.Exit(1)
+		}
+
+		err = config.SetProject(uint(projID))
+
+		if err != nil {
+			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			os.Exit(1)
+		}
+	},
+}
+
+var configSetClusterCmd = &cobra.Command{
+	Use:   "set-cluster [id]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Saves the cluster id in the default configuration",
+	Run: func(cmd *cobra.Command, args []string) {
+		clusterID, err := strconv.ParseUint(args[0], 10, 64)
+
+		if err != nil {
+			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			os.Exit(1)
+		}
+
+		err = config.SetCluster(uint(clusterID))
+
+		if err != nil {
+			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			os.Exit(1)
+		}
+	},
+}
+
+var configSetRegistryCmd = &cobra.Command{
+	Use:   "set-registry [id]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Saves the registry id in the default configuration",
+	Run: func(cmd *cobra.Command, args []string) {
+		registryID, err := strconv.ParseUint(args[0], 10, 64)
+
+		if err != nil {
+			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			os.Exit(1)
+		}
+
+		err = config.SetRegistry(uint(registryID))
+
+		if err != nil {
+			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			os.Exit(1)
+		}
+	},
+}
+
+var configSetHelmRepoCmd = &cobra.Command{
+	Use:   "set-helmrepo [id]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Saves the helm repo id in the default configuration",
+	Run: func(cmd *cobra.Command, args []string) {
+		hrID, err := strconv.ParseUint(args[0], 10, 64)
+
+		if err != nil {
+			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			os.Exit(1)
+		}
+
+		err = config.SetHelmRepo(uint(hrID))
+
+		if err != nil {
+			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			os.Exit(1)
+		}
+	},
+}
+
+var configSetHostCmd = &cobra.Command{
+	Use:   "set-host [host]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Saves the host in the default configuration",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := config.SetHost(args[0])
+
+		if err != nil {
+			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(configCmd)
+
+	configCmd.AddCommand(configSetProjectCmd)
+	configCmd.AddCommand(configSetClusterCmd)
+	configCmd.AddCommand(configSetHostCmd)
+	configCmd.AddCommand(configSetRegistryCmd)
+	configCmd.AddCommand(configSetHelmRepoCmd)
+}
+
+func printConfig() error {
+	config, err := ioutil.ReadFile(filepath.Join(home, ".porter", "porter.yaml"))
+
+	if err != nil {
+		return err
+	}
+
+	fmt.Printf(string(config))
+
+	return nil
+}

+ 0 - 20
cli/cmd/connect.go

@@ -67,18 +67,6 @@ var connectRegistryCmd = &cobra.Command{
 	},
 }
 
-var connectActionsCmd = &cobra.Command{
-	Use:   "actions",
-	Short: "Adds Github Actions to a project",
-	Run: func(cmd *cobra.Command, args []string) {
-		err := checkLoginAndRun(args, runConnectActions)
-
-		if err != nil {
-			os.Exit(1)
-		}
-	},
-}
-
 var connectGCRCmd = &cobra.Command{
 	Use:   "gcr",
 	Short: "Adds a GCR instance to a project",
@@ -135,7 +123,6 @@ func init() {
 		"the context to connect (defaults to the current context)",
 	)
 
-	connectCmd.AddCommand(connectActionsCmd)
 	connectCmd.AddCommand(connectECRCmd)
 	connectCmd.AddCommand(connectRegistryCmd)
 	connectCmd.AddCommand(connectDockerhubCmd)
@@ -243,10 +230,3 @@ func runConnectHelmRepoBasic(_ *api.AuthCheckResponse, client *api.Client, _ []s
 
 	return config.SetHelmRepo(hrID)
 }
-
-func runConnectActions(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
-	return connect.Actions(
-		client,
-		config.Project,
-	)
-}

+ 0 - 125
cli/cmd/connect/actions.go

@@ -1,125 +0,0 @@
-package connect
-
-import (
-	"context"
-	"fmt"
-	"strconv"
-	"time"
-
-	api "github.com/porter-dev/porter/api/client"
-	"github.com/porter-dev/porter/cli/cmd/utils"
-
-	ints "github.com/porter-dev/porter/internal/models/integrations"
-)
-
-// Actions creates a github actions integration
-func Actions(
-	client *api.Client,
-	projectID uint,
-) error {
-	// if project ID is 0, ask the user to set the project ID or create a project
-	if projectID == 0 {
-		return fmt.Errorf("no project set, please run porter project set [id]")
-	}
-
-	// list oauth integrations and make sure Github exists
-	oauthInts, err := client.ListOAuthIntegrations(context.TODO(), projectID)
-
-	if err != nil {
-		return err
-	}
-
-	linkedGH := false
-
-	// iterate through oauth integrations to find do
-	for _, oauthInt := range oauthInts {
-		if oauthInt.Client == ints.OAuthGithub {
-			linkedGH = true
-			break
-		}
-	}
-
-	if !linkedGH {
-		_, err = triggerGithubOAuth(client, projectID)
-
-		if err != nil {
-			return err
-		}
-	}
-
-	gitRepos, err := client.ListGitRepos(context.TODO(), projectID)
-
-	gitRepoID := gitRepos[0].ID
-
-	// prompts (unfortunately a lot)
-	clusterIDStr, _ := utils.PromptPlaintext(fmt.Sprintf(`Please provide the cluster id (can be found with "porter clusters list").
-Cluster ID: `))
-	clusterID, err := strconv.ParseUint(clusterIDStr, 10, 64)
-
-	if err != nil {
-		return err
-	}
-
-	releaseName, _ := utils.PromptPlaintext(fmt.Sprintf(`Release name:`))
-	releaseNamespace, _ := utils.PromptPlaintext(fmt.Sprintf(`Release namespace:`))
-	gitRepo, _ := utils.PromptPlaintext(fmt.Sprintf(`Please enter the Github repo, in the form ${owner}/${repo_name}. For example, porter-dev/porter.
-Github repo:`))
-
-	imageRepo, _ := utils.PromptPlaintext(fmt.Sprintf(`Please enter the image repo url.
-Image repo:`))
-
-	dockerfilePath, _ := utils.PromptPlaintext(fmt.Sprintf(`Please enter the path in the repo to your dockerfile.
-Dockerfile path:`))
-
-	err = client.CreateGithubAction(
-		context.Background(),
-		projectID,
-		uint(clusterID),
-		releaseName,
-		releaseNamespace,
-		&api.CreateGithubActionRequest{
-			GitRepo:        gitRepo,
-			ImageRepoURI:   imageRepo,
-			DockerfilePath: dockerfilePath,
-			GitRepoID:      gitRepoID,
-		},
-	)
-
-	return err
-}
-
-func triggerGithubOAuth(client *api.Client, projectID uint) (ints.OAuthIntegrationExternal, error) {
-	var ghAuth ints.OAuthIntegrationExternal
-
-	oauthURL := fmt.Sprintf("%s/oauth/projects/%d/github", client.BaseURL, projectID)
-
-	fmt.Printf("Please visit %s in your browser to connect to Github (it should open automatically).", oauthURL)
-	utils.OpenBrowser(oauthURL)
-
-	for {
-		oauthInts, err := client.ListOAuthIntegrations(context.TODO(), projectID)
-
-		if err != nil {
-			return ghAuth, err
-		}
-
-		linkedGH := false
-
-		// iterate through oauth integrations to find do
-		for _, oauthInt := range oauthInts {
-			if oauthInt.Client == ints.OAuthGithub {
-				linkedGH = true
-				ghAuth = oauthInt
-				break
-			}
-		}
-
-		if linkedGH {
-			break
-		}
-
-		time.Sleep(2 * time.Second)
-	}
-
-	return ghAuth, nil
-}

+ 296 - 0
cli/cmd/create.go

@@ -0,0 +1,296 @@
+package cmd
+
+import (
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/deploy"
+	"github.com/porter-dev/porter/cli/cmd/gitutils"
+	"github.com/spf13/cobra"
+	"sigs.k8s.io/yaml"
+)
+
+// createCmd represents the "porter create" base command when called
+// without any subcommands
+var createCmd = &cobra.Command{
+	Use:   "create [kind]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Creates a new application with name given by the --app flag.",
+	Long: fmt.Sprintf(`
+%s 
+
+Creates a new application with name given by the --app flag and a "kind", which can be one of 
+web, worker, or job. For example:
+
+  %s
+
+To modify the default configuration of the application, you can pass a values.yaml file in via the 
+--values flag. 
+
+  %s
+
+To read more about the configuration options, go here: 
+
+https://docs.getporter.dev/docs/deploying-from-the-cli#common-configuration-options
+
+This command will automatically build from a local path, and will create a new Docker image in your 
+default Docker registry. The path can be configured via the --path flag. For example:
+  
+  %s
+
+To connect the application to Github, so that the application rebuilds and redeploys on each push 
+to a Github branch, you can specify "--source github". If your local branch is set to track changes 
+from an upstream remote branch, Porter will try to use the connected remote and remote branch as the 
+Github repository to link to. Otherwise, Porter will use the remote given by origin. For example:
+
+  %s
+
+To deploy an application from a Docker registry, use "--source registry" and pass the image in via the
+--image flag. The image flag must be of the form repository:tag. For example:
+
+  %s 
+`,
+		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter create\":"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter create web --app example-app"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter create web --app example-app --values values.yaml"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter create web --app example-app --path ./path/to/app"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter create web --app example-app --source github"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter create web --app example-app --source registry --image gcr.io/snowflake-12345/example-app:latest"),
+	),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, createFull)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var name string
+var values string
+var source string
+var image string
+var registryURL string
+
+func init() {
+	rootCmd.AddCommand(createCmd)
+
+	createCmd.PersistentFlags().StringVar(
+		&name,
+		"app",
+		"",
+		"name of the new application/job/worker.",
+	)
+
+	createCmd.MarkPersistentFlagRequired("app")
+
+	createCmd.PersistentFlags().StringVarP(
+		&localPath,
+		"path",
+		"p",
+		".",
+		"if local build, the path to the build directory",
+	)
+
+	createCmd.PersistentFlags().StringVar(
+		&namespace,
+		"namespace",
+		"default",
+		"namespace of the application",
+	)
+
+	createCmd.PersistentFlags().StringVarP(
+		&values,
+		"values",
+		"v",
+		"",
+		"filepath to a values.yaml file",
+	)
+
+	createCmd.PersistentFlags().StringVar(
+		&dockerfile,
+		"dockerfile",
+		"",
+		"the path to the dockerfile",
+	)
+
+	createCmd.PersistentFlags().StringVar(
+		&method,
+		"method",
+		"",
+		"the build method to use (\"docker\" or \"pack\")",
+	)
+
+	createCmd.PersistentFlags().StringVar(
+		&source,
+		"source",
+		"local",
+		"the type of source (\"local\", \"github\", or \"registry\")",
+	)
+
+	createCmd.PersistentFlags().StringVar(
+		&image,
+		"image",
+		"",
+		"if the source is \"registry\", the image to use, in repository:tag format",
+	)
+
+	createCmd.PersistentFlags().StringVar(
+		&registryURL,
+		"registry-url",
+		"",
+		"the registry URL to use (must exist in \"porter registries list\")",
+	)
+}
+
+var supportedKinds = map[string]string{"web": "", "job": "", "worker": ""}
+
+func createFull(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
+	// check the kind
+	if _, exists := supportedKinds[args[0]]; !exists {
+		return fmt.Errorf("%s is not a supported type: specify web, job, or worker", args[0])
+	}
+
+	// read the values if necessary
+	valuesObj, err := readValuesFile()
+
+	if err != nil {
+		return err
+	}
+
+	color.New(color.FgGreen).Printf("Creating %s release: %s\n", args[0], name)
+
+	fullPath, err := filepath.Abs(localPath)
+
+	if err != nil {
+		return err
+	}
+
+	var buildMethod deploy.DeployBuildType
+
+	if method != "" {
+		buildMethod = deploy.DeployBuildType(method)
+	} else if dockerfile != "" {
+		buildMethod = deploy.DeployBuildTypeDocker
+	}
+
+	createAgent := &deploy.CreateAgent{
+		Client: client,
+		CreateOpts: &deploy.CreateOpts{
+			SharedOpts: &deploy.SharedOpts{
+				ProjectID:       config.Project,
+				ClusterID:       config.Cluster,
+				Namespace:       namespace,
+				LocalPath:       fullPath,
+				LocalDockerfile: dockerfile,
+				Method:          buildMethod,
+			},
+			Kind:        args[0],
+			ReleaseName: name,
+			RegistryURL: registryURL,
+		},
+	}
+
+	if source == "local" {
+		subdomain, err := createAgent.CreateFromDocker(valuesObj)
+
+		return handleSubdomainCreate(subdomain, err)
+	} else if source == "github" {
+		return createFromGithub(createAgent, valuesObj)
+	}
+
+	subdomain, err := createAgent.CreateFromRegistry(image, valuesObj)
+
+	return handleSubdomainCreate(subdomain, err)
+}
+
+func handleSubdomainCreate(subdomain string, err error) error {
+	if err != nil {
+		return err
+	}
+
+	if subdomain != "" {
+		color.New(color.FgGreen).Printf("Your web application is ready at: %s\n", subdomain)
+	} else {
+		color.New(color.FgGreen).Printf("Application created successfully\n")
+	}
+
+	return nil
+}
+
+func createFromGithub(createAgent *deploy.CreateAgent, overrideValues map[string]interface{}) error {
+	fullPath, err := filepath.Abs(localPath)
+
+	if err != nil {
+		return err
+	}
+
+	_, err = gitutils.GitDirectory(fullPath)
+
+	if err != nil {
+		return err
+	}
+
+	remote, gitBranch, err := gitutils.GetRemoteBranch(fullPath)
+
+	if err != nil {
+		return err
+	} else if gitBranch == "" {
+		return fmt.Errorf("git branch not automatically detectable")
+	}
+
+	ok, remoteRepo := gitutils.ParseGithubRemote(remote)
+
+	if !ok {
+		return fmt.Errorf("remote is not a Github repository")
+	}
+
+	subdomain, err := createAgent.CreateFromGithub(&deploy.GithubOpts{
+		Branch: gitBranch,
+		Repo:   remoteRepo,
+	}, overrideValues)
+
+	return handleSubdomainCreate(subdomain, err)
+}
+
+func readValuesFile() (map[string]interface{}, error) {
+	res := make(map[string]interface{})
+
+	if values == "" {
+		return res, nil
+	}
+
+	valuesFilePath, err := filepath.Abs(values)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if info, err := os.Stat(valuesFilePath); os.IsNotExist(err) || info.IsDir() {
+		return nil, fmt.Errorf("values file does not exist or is a directory")
+	}
+
+	reader, err := os.Open(valuesFilePath)
+
+	if err != nil {
+		return nil, err
+	}
+
+	bytes, err := ioutil.ReadAll(reader)
+
+	if err != nil {
+		return nil, err
+	}
+
+	err = yaml.Unmarshal(bytes, &res)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return res, nil
+}

+ 0 - 30
cli/cmd/create/create.go

@@ -1,30 +0,0 @@
-package create
-
-import (
-	api "github.com/porter-dev/porter/api/client"
-	"github.com/porter-dev/porter/cli/cmd/docker"
-)
-
-// CreateAgent handles the creation of a new application on Porter
-type CreateAgent struct {
-	client *api.Client
-	agent  *docker.Agent
-	opts   *CreateOpts
-}
-
-// CreateOpts are the options for creating a new CreateAgent
-type CreateOpts struct {
-	ProjectID uint
-	ClusterID uint
-	Namespace string
-}
-
-func (c *CreateAgent) CreateFromDocker() error {
-	// read values from local file
-
-	// overwrite with docker image repository and tag
-
-	// call subdomain creation if necessary
-
-	return nil
-}

+ 125 - 110
cli/cmd/deploy.go

@@ -10,56 +10,53 @@ import (
 	"github.com/spf13/cobra"
 )
 
-// deployCmd represents the "porter deploy" base command when called
+// updateCmd represents the "porter update" base command when called
 // without any subcommands
-var deployCmd = &cobra.Command{
-	Use:   "deploy",
-	Short: "Builds and deploys a specified application given by the --app flag.",
+var updateCmd = &cobra.Command{
+	Use:   "update",
+	Short: "Builds and updates a specified application given by the --app flag.",
 	Long: fmt.Sprintf(`
 %s 
 
-Builds and deploys a specified application given by the --app flag. For example:
+Builds and updates a specified application given by the --app flag. For example:
 
   %s
 
-If the application has a remote Git repository source configured, this command uses the latest commit 
-from the remote repo and branch to deploy an application. It will use the latest commit as the image 
-tag. 
-
-To build from a local directory, you must specify the --local flag. The path can be configured via the 
+This command will automatically build from a local path. The path can be configured via the 
 --path flag. You can also overwrite the tag using the --tag flag. For example, to build from the 
 local directory ~/path-to-dir with the tag "testing":
 
   %s
 
-If your application is set up to use a Dockerfile by default, you can use a buildpack via the flag 
-"--method pack". Conversely, if your application is set up to use a buildpack by default, you can 
-use a Dockerfile by passing the flag "--method docker". You can specify the relative path to a Dockerfile 
-in your remote Git repository. For example, if a Dockerfile is found at ./docker/prod.Dockerfile, you can 
-specify it as follows:
+If the application has a remote Git repository source configured, you can specify that the remote
+Git repository should be used to build the new image by specifying "--source github". Porter will use 
+the latest commit from the remote repo and branch to update an application, and will use the latest 
+commit as the image tag.
 
   %s
 
-If an application does not have a remote Git repository source, this command will attempt to use a 
-cloud-native buildpack builder and build from the current directory. If this is the desired behavior,
-you do not need to configure additional flags:
+To add new configuration or update existing configuration, you can pass a values.yaml file in via the 
+--values flag. For example;
 
   %s
 
-If you would like to build from a Dockerfile instead, use the flag --dockerfile and "--method docker"
-as documented above. For example:
+If your application is set up to use a Dockerfile by default, you can use a buildpack via the flag 
+"--method pack". Conversely, if your application is set up to use a buildpack by default, you can 
+use a Dockerfile by passing the flag "--method docker". You can specify the relative path to a Dockerfile 
+in your remote Git repository. For example, if a Dockerfile is found at ./docker/prod.Dockerfile, you can 
+specify it as follows:
 
   %s
 `,
-		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter deploy\":"),
-		color.New(color.FgGreen, color.Bold).Sprintf("porter deploy --app example-app"),
-		color.New(color.FgGreen, color.Bold).Sprintf("porter deploy --app remote-git-app --local --path ~/path-to-dir --tag testing"),
-		color.New(color.FgGreen, color.Bold).Sprintf("porter deploy --app remote-git-app --method docker --dockerfile ./docker/prod.Dockerfile"),
-		color.New(color.FgGreen, color.Bold).Sprintf("porter deploy --app local-app"),
-		color.New(color.FgGreen, color.Bold).Sprintf("porter deploy --app local-app --method docker --dockerfile ~/porter-test/prod.Dockerfile"),
+		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter update\":"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update --app example-app"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update --app example-app --path ~/path-to-dir --tag testing"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update --app remote-git-app --source github"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update --app example-app --values my-values.yaml"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update --app example-app --method docker --dockerfile ./docker/prod.Dockerfile"),
 	),
 	Run: func(cmd *cobra.Command, args []string) {
-		err := checkLoginAndRun(args, deployFull)
+		err := checkLoginAndRun(args, updateFull)
 
 		if err != nil {
 			os.Exit(1)
@@ -67,7 +64,7 @@ as documented above. For example:
 	},
 }
 
-var deployGetEnvCmd = &cobra.Command{
+var updateGetEnvCmd = &cobra.Command{
 	Use:   "get-env",
 	Short: "Gets environment variables for a deployment for a specified application given by the --app flag.",
 	Long: fmt.Sprintf(`
@@ -83,12 +80,12 @@ destination path for a .env file. For example:
 
   %s
 `,
-		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter deploy get-env\":"),
-		color.New(color.FgGreen, color.Bold).Sprintf("porter deploy get-env --app example-app | xargs"),
-		color.New(color.FgGreen, color.Bold).Sprintf("porter deploy get-env --app example-app --file .env"),
+		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter update get-env\":"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update get-env --app example-app | xargs"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update get-env --app example-app --file .env"),
 	),
 	Run: func(cmd *cobra.Command, args []string) {
-		err := checkLoginAndRun(args, deployGetEnv)
+		err := checkLoginAndRun(args, updateGetEnv)
 
 		if err != nil {
 			os.Exit(1)
@@ -96,7 +93,7 @@ destination path for a .env file. For example:
 	},
 }
 
-var deployBuildCmd = &cobra.Command{
+var updateBuildCmd = &cobra.Command{
 	Use:   "build",
 	Short: "Builds a new version of the application specified by the --app flag.",
 	Long: fmt.Sprintf(`
@@ -125,13 +122,13 @@ for the application:
 
   %s
 `,
-		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter deploy build\":"),
-		color.New(color.FgGreen, color.Bold).Sprintf("porter deploy build --app example-app"),
-		color.New(color.FgGreen, color.Bold).Sprintf("porter deploy build --app example-app --method docker"),
-		color.New(color.FgGreen, color.Bold).Sprintf("porter deploy build --app example-app --method docker --dockerfile ./prod.Dockerfile"),
+		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter update build\":"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update build --app example-app"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update build --app example-app --method docker"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update build --app example-app --method docker --dockerfile ./prod.Dockerfile"),
 	),
 	Run: func(cmd *cobra.Command, args []string) {
-		err := checkLoginAndRun(args, deployBuild)
+		err := checkLoginAndRun(args, updateBuild)
 
 		if err != nil {
 			os.Exit(1)
@@ -139,7 +136,7 @@ for the application:
 	},
 }
 
-var deployPushCmd = &cobra.Command{
+var updatePushCmd = &cobra.Command{
 	Use:   "push",
 	Short: "Pushes a new image for an application specified by the --app flag.",
 	Long: fmt.Sprintf(`
@@ -156,11 +153,11 @@ This command will not use your pre-saved authentication set up via "docker login
 are using an image registry that was created outside of Porter, make sure that you have 
 linked it via "porter connect".
 `,
-		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter deploy push\":"),
-		color.New(color.FgGreen, color.Bold).Sprintf("porter deploy push --app nginx --tag new-tag"),
+		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter update push\":"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update push --app nginx --tag new-tag"),
 	),
 	Run: func(cmd *cobra.Command, args []string) {
-		err := checkLoginAndRun(args, deployPush)
+		err := checkLoginAndRun(args, updatePush)
 
 		if err != nil {
 			os.Exit(1)
@@ -168,28 +165,30 @@ linked it via "porter connect".
 	},
 }
 
-var deployCallWebhookCmd = &cobra.Command{
-	Use:   "call-webhook",
-	Short: "Calls the webhook for an application specified by the --app flag.",
+var updateConfigCmd = &cobra.Command{
+	Use:   "config",
+	Short: "Updates the configuration for an application specified by the --app flag.",
 	Long: fmt.Sprintf(`
 %s 
 
-Calls the webhook for an application specified by the --app flag. This webhook will 
-trigger a new deployment for the application, with the new image set. For example:
+Updates the configuration for an application specified by the --app flag, using the configuration
+given by the --values flag. This will trigger a new deployment for the application with 
+new configuration set. Note that this will merge your existing configuration with configuration
+specified in the --values file. For example:
 
   %s
 
-This command will by default call the webhook with image tag "latest," but you can 
-specify a different tag with the --tag flag:
+You can update the configuration with only a new tag with the --tag flag, which will only update
+the image that the application uses if no --values file is specified:
 
   %s
 `,
-		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter deploy call-webhook\":"),
-		color.New(color.FgGreen, color.Bold).Sprintf("porter deploy call-webhook --app example-app"),
-		color.New(color.FgGreen, color.Bold).Sprintf("porter deploy call-webhook --app example-app --tag custom-tag"),
+		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter update config\":"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update config --app example-app --values my-values.yaml"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter update config --app example-app --tag custom-tag"),
 	),
 	Run: func(cmd *cobra.Command, args []string) {
-		err := checkLoginAndRun(args, deployCallWebhook)
+		err := checkLoginAndRun(args, updateUpgrade)
 
 		if err != nil {
 			os.Exit(1)
@@ -199,47 +198,46 @@ specify a different tag with the --tag flag:
 
 var app string
 var getEnvFileDest string
-var local bool
 var localPath string
 var tag string
 var dockerfile string
 var method string
 
 func init() {
-	rootCmd.AddCommand(deployCmd)
+	rootCmd.AddCommand(updateCmd)
 
-	deployCmd.PersistentFlags().StringVar(
+	updateCmd.PersistentFlags().StringVar(
 		&app,
 		"app",
 		"",
 		"Application in the Porter dashboard",
 	)
 
-	deployCmd.MarkPersistentFlagRequired("app")
+	updateCmd.MarkPersistentFlagRequired("app")
 
-	deployCmd.PersistentFlags().StringVar(
+	updateCmd.PersistentFlags().StringVar(
 		&namespace,
 		"namespace",
 		"default",
 		"Namespace of the application",
 	)
 
-	deployCmd.PersistentFlags().BoolVar(
-		&local,
+	updateCmd.PersistentFlags().StringVar(
+		&source,
+		"source",
 		"local",
-		false,
-		"Whether local context should be used for build",
+		"the type of source (\"local\" or \"github\")",
 	)
 
-	deployCmd.PersistentFlags().StringVarP(
+	updateCmd.PersistentFlags().StringVarP(
 		&localPath,
 		"path",
 		"p",
 		".",
-		"If local build, the path to the build directory",
+		"If local build, the path to the build directory. If remote build, the relative path from the repository root to the build directory.",
 	)
 
-	deployCmd.PersistentFlags().StringVarP(
+	updateCmd.PersistentFlags().StringVarP(
 		&tag,
 		"tag",
 		"t",
@@ -247,56 +245,64 @@ func init() {
 		"the specified tag to use, if not \"latest\"",
 	)
 
-	deployCmd.PersistentFlags().StringVar(
+	updateCmd.PersistentFlags().StringVarP(
+		&values,
+		"values",
+		"v",
+		"",
+		"Filepath to a values.yaml file",
+	)
+
+	updateCmd.PersistentFlags().StringVar(
 		&dockerfile,
 		"dockerfile",
 		"",
 		"the path to the dockerfile",
 	)
 
-	deployCmd.PersistentFlags().StringVar(
+	updateCmd.PersistentFlags().StringVar(
 		&method,
 		"method",
 		"",
 		"the build method to use (\"docker\" or \"pack\")",
 	)
 
-	deployCmd.AddCommand(deployGetEnvCmd)
+	updateCmd.AddCommand(updateGetEnvCmd)
 
-	deployGetEnvCmd.PersistentFlags().StringVar(
+	updateGetEnvCmd.PersistentFlags().StringVar(
 		&getEnvFileDest,
 		"file",
 		"",
 		"file destination for .env files",
 	)
 
-	deployCmd.AddCommand(deployBuildCmd)
-	deployCmd.AddCommand(deployPushCmd)
-	deployCmd.AddCommand(deployCallWebhookCmd)
+	updateCmd.AddCommand(updateBuildCmd)
+	updateCmd.AddCommand(updatePushCmd)
+	updateCmd.AddCommand(updateConfigCmd)
 }
 
-func deployFull(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
+func updateFull(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
 	color.New(color.FgGreen).Println("Deploying app:", app)
 
-	deployAgent, err := deployGetAgent(client)
+	updateAgent, err := updateGetAgent(client)
 
 	if err != nil {
 		return err
 	}
 
-	err = deployBuildWithAgent(deployAgent)
+	err = updateBuildWithAgent(updateAgent)
 
 	if err != nil {
 		return err
 	}
 
-	err = deployPushWithAgent(deployAgent)
+	err = updatePushWithAgent(updateAgent)
 
 	if err != nil {
 		return err
 	}
 
-	err = deployCallWebhookWithAgent(deployAgent)
+	err = updateUpgradeWithAgent(updateAgent)
 
 	if err != nil {
 		return err
@@ -305,119 +311,128 @@ func deployFull(resp *api.AuthCheckResponse, client *api.Client, args []string)
 	return nil
 }
 
-func deployGetEnv(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
-	deployAgent, err := deployGetAgent(client)
+func updateGetEnv(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
+	updateAgent, err := updateGetAgent(client)
 
 	if err != nil {
 		return err
 	}
 
-	buildEnv, err := deployAgent.GetBuildEnv()
+	buildEnv, err := updateAgent.GetBuildEnv()
 
 	if err != nil {
 		return err
 	}
 
 	// set the environment variables in the process
-	err = deployAgent.SetBuildEnv(buildEnv)
+	err = updateAgent.SetBuildEnv(buildEnv)
 
 	if err != nil {
 		return err
 	}
 
 	// write the environment variables to either a file or stdout (stdout by default)
-	return deployAgent.WriteBuildEnv(getEnvFileDest)
+	return updateAgent.WriteBuildEnv(getEnvFileDest)
 }
 
-func deployBuild(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
-	deployAgent, err := deployGetAgent(client)
+func updateBuild(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
+	updateAgent, err := updateGetAgent(client)
 
 	if err != nil {
 		return err
 	}
 
-	return deployBuildWithAgent(deployAgent)
+	return updateBuildWithAgent(updateAgent)
 }
 
-func deployPush(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
-	deployAgent, err := deployGetAgent(client)
+func updatePush(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
+	updateAgent, err := updateGetAgent(client)
 
 	if err != nil {
 		return err
 	}
 
-	return deployPushWithAgent(deployAgent)
+	return updatePushWithAgent(updateAgent)
 }
 
-func deployCallWebhook(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
-	deployAgent, err := deployGetAgent(client)
+func updateUpgrade(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
+	updateAgent, err := updateGetAgent(client)
 
 	if err != nil {
 		return err
 	}
 
-	return deployCallWebhookWithAgent(deployAgent)
+	return updateUpgradeWithAgent(updateAgent)
 }
 
 // HELPER METHODS
-func deployGetAgent(client *api.Client) (*deploy.DeployAgent, error) {
+func updateGetAgent(client *api.Client) (*deploy.DeployAgent, error) {
 	var buildMethod deploy.DeployBuildType
 
 	if method != "" {
 		buildMethod = deploy.DeployBuildType(method)
 	}
 
-	// initialize the deploy agent
+	// initialize the update agent
 	return deploy.NewDeployAgent(client, app, &deploy.DeployOpts{
-		ProjectID:       config.Project,
-		ClusterID:       config.Cluster,
-		Namespace:       namespace,
-		Local:           local,
-		LocalPath:       localPath,
-		LocalDockerfile: dockerfile,
-		OverrideTag:     tag,
-		Method:          buildMethod,
+		SharedOpts: &deploy.SharedOpts{
+			ProjectID:       config.Project,
+			ClusterID:       config.Cluster,
+			Namespace:       namespace,
+			LocalPath:       localPath,
+			LocalDockerfile: dockerfile,
+			OverrideTag:     tag,
+			Method:          buildMethod,
+		},
+		Local: source != "github",
 	})
 }
 
-func deployBuildWithAgent(deployAgent *deploy.DeployAgent) error {
+func updateBuildWithAgent(updateAgent *deploy.DeployAgent) error {
 	// build the deployment
 	color.New(color.FgGreen).Println("Building docker image for", app)
 
-	buildEnv, err := deployAgent.GetBuildEnv()
+	buildEnv, err := updateAgent.GetBuildEnv()
 
 	if err != nil {
 		return err
 	}
 
 	// set the environment variables in the process
-	err = deployAgent.SetBuildEnv(buildEnv)
+	err = updateAgent.SetBuildEnv(buildEnv)
 
 	if err != nil {
 		return err
 	}
 
-	return deployAgent.Build()
+	return updateAgent.Build()
 }
 
-func deployPushWithAgent(deployAgent *deploy.DeployAgent) error {
+func updatePushWithAgent(updateAgent *deploy.DeployAgent) error {
 	// push the deployment
 	color.New(color.FgGreen).Println("Pushing new image for", app)
 
-	return deployAgent.Push()
+	return updateAgent.Push()
 }
 
-func deployCallWebhookWithAgent(deployAgent *deploy.DeployAgent) error {
+func updateUpgradeWithAgent(updateAgent *deploy.DeployAgent) error {
 	// push the deployment
 	color.New(color.FgGreen).Println("Calling webhook for", app)
 
-	err := deployAgent.CallWebhook()
+	// read the values if necessary
+	valuesObj, err := readValuesFile()
+
+	if err != nil {
+		return err
+	}
+
+	err = updateAgent.UpdateImageAndValues(valuesObj)
 
 	if err != nil {
 		return err
 	}
 
-	color.New(color.FgGreen).Println("Successfully re-deployed", app)
+	color.New(color.FgGreen).Println("Successfully updated", app)
 
 	return nil
 }

+ 140 - 0
cli/cmd/deploy/build.go

@@ -0,0 +1,140 @@
+package deploy
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/docker"
+	"github.com/porter-dev/porter/cli/cmd/pack"
+)
+
+// BuildAgent builds a new Docker container image for a new version of an application
+type BuildAgent struct {
+	*SharedOpts
+
+	client      *api.Client
+	imageRepo   string
+	env         map[string]string
+	imageExists bool
+}
+
+// BuildDocker uses the local Docker daemon to build the image
+func (b *BuildAgent) BuildDocker(
+	dockerAgent *docker.Agent,
+	basePath,
+	buildCtx,
+	dockerfilePath,
+	tag string,
+) error {
+	buildCtx, dockerfilePath, isDockerfileInCtx, err := ResolveDockerPaths(
+		basePath,
+		buildCtx,
+		dockerfilePath,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	opts := &docker.BuildOpts{
+		ImageRepo:         b.imageRepo,
+		Tag:               tag,
+		BuildContext:      buildCtx,
+		Env:               b.env,
+		DockerfilePath:    dockerfilePath,
+		IsDockerfileInCtx: isDockerfileInCtx,
+	}
+
+	return dockerAgent.BuildLocal(
+		opts,
+	)
+}
+
+// BuildPack uses the cloud-native buildpack client to build a container image
+func (b *BuildAgent) BuildPack(dockerAgent *docker.Agent, dst, tag string) error {
+	// retag the image with "pack-cache" tag so that it doesn't re-pull from the registry
+	if b.imageExists {
+		err := dockerAgent.TagImage(
+			fmt.Sprintf("%s:%s", b.imageRepo, tag),
+			fmt.Sprintf("%s:%s", b.imageRepo, "pack-cache"),
+		)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	// create pack agent and build opts
+	packAgent := &pack.Agent{}
+
+	opts := &docker.BuildOpts{
+		ImageRepo: b.imageRepo,
+		// We tag the image with a stable param "pack-cache" so that pack can use the
+		// local image without attempting to re-pull from registry. We handle getting
+		// registry credentials and pushing/pulling the image.
+		Tag:          "pack-cache",
+		BuildContext: dst,
+		Env:          b.env,
+	}
+
+	// call builder
+	err := packAgent.Build(opts)
+
+	if err != nil {
+		return err
+	}
+
+	return dockerAgent.TagImage(
+		fmt.Sprintf("%s:%s", b.imageRepo, "pack-cache"),
+		fmt.Sprintf("%s:%s", b.imageRepo, tag),
+	)
+}
+
+// ResolveDockerPaths returns a path to the dockerfile that is either relative or absolute, and a path
+// to the build context that is absolute.
+//
+// The return value will be relative if the dockerfile exists within the build context, absolute
+// otherwise. The second return value is true if the dockerfile exists within the build context,
+// false otherwise.
+func ResolveDockerPaths(
+	basePath string,
+	buildContextPath string,
+	dockerfilePath string,
+) (
+	resBuildCtxPath string,
+	resDockerfilePath string,
+	isDockerfileRelative bool,
+	err error,
+) {
+	resBuildCtxPath, err = filepath.Abs(buildContextPath)
+	resDockerfilePath = dockerfilePath
+
+	// determine if the given dockerfile path is relative
+	if !filepath.IsAbs(dockerfilePath) {
+		// if path is relative, join basepath with path
+		resDockerfilePath = filepath.Join(basePath, dockerfilePath)
+	}
+
+	// compare the path to the dockerfile with the build context
+	pathComp, err := filepath.Rel(resBuildCtxPath, resDockerfilePath)
+
+	if err != nil {
+		return "", "", false, err
+	}
+
+	if !strings.HasPrefix(pathComp, ".."+string(os.PathSeparator)) {
+		// return the relative path to the dockerfile
+		return resBuildCtxPath, pathComp, true, nil
+	}
+
+	resDockerfilePath, err = filepath.Abs(resDockerfilePath)
+
+	if err != nil {
+		return "", "", false, err
+	}
+
+	return resBuildCtxPath, resDockerfilePath, false, nil
+}

+ 504 - 0
cli/cmd/deploy/create.go

@@ -0,0 +1,504 @@
+package deploy
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/docker"
+	"github.com/porter-dev/porter/internal/templater/utils"
+)
+
+// CreateAgent handles the creation of a new application on Porter
+type CreateAgent struct {
+	Client     *api.Client
+	CreateOpts *CreateOpts
+}
+
+// CreateOpts are required options for creating a new application on Porter: the
+// "kind" (web, worker, job) and the name of the application.
+type CreateOpts struct {
+	*SharedOpts
+
+	Kind        string
+	ReleaseName string
+	RegistryURL string
+}
+
+// GithubOpts are the options for linking a Github source to the app
+type GithubOpts struct {
+	Branch string
+	Repo   string
+}
+
+// CreateFromGithub uses the branch/repo to link the Github source for an application.
+// This function attempts to find a matching repository in the list of linked repositories
+// on Porter. If one is found, it will use that repository as the app source.
+func (c *CreateAgent) CreateFromGithub(
+	ghOpts *GithubOpts,
+	overrideValues map[string]interface{},
+) (string, error) {
+	opts := c.CreateOpts
+
+	// get all linked github repos and find matching repo
+	gitRepos, err := c.Client.ListGitRepos(
+		context.Background(),
+		c.CreateOpts.ProjectID,
+	)
+
+	if err != nil {
+		return "", err
+	}
+
+	var gitRepoMatch uint
+
+	for _, gitRepo := range gitRepos {
+		// for each git repo, search for a matching username/owner
+		githubRepos, err := c.Client.ListGithubRepos(
+			context.Background(),
+			c.CreateOpts.ProjectID,
+			gitRepo,
+		)
+
+		if err != nil {
+			return "", err
+		}
+
+		for _, githubRepo := range githubRepos {
+			if githubRepo.FullName == ghOpts.Repo {
+				gitRepoMatch = gitRepo
+				break
+			}
+		}
+
+		if gitRepoMatch != 0 {
+			break
+		}
+	}
+
+	if gitRepoMatch == 0 {
+		return "", fmt.Errorf("could not find a linked github repo for %s. Make sure you have linked your Github account on the Porter dashboard.", ghOpts.Repo)
+	}
+
+	latestVersion, mergedValues, err := c.getMergedValues(overrideValues)
+
+	if err != nil {
+		return "", err
+	}
+
+	if opts.Kind == "web" || opts.Kind == "worker" {
+		mergedValues["image"] = map[string]interface{}{
+			"repository": "public.ecr.aws/o1j4x7p4/hello-porter",
+			"tag":        "latest",
+		}
+	} else if opts.Kind == "job" {
+		mergedValues["image"] = map[string]interface{}{
+			"repository": "public.ecr.aws/o1j4x7p4/hello-porter-job",
+			"tag":        "latest",
+		}
+	}
+
+	regID, imageURL, err := c.GetImageRepoURL(opts.ReleaseName, opts.Namespace)
+
+	if err != nil {
+		return "", err
+	}
+
+	env, err := GetEnvFromConfig(mergedValues)
+
+	if err != nil {
+		env = map[string]string{}
+	}
+
+	subdomain, err := c.CreateSubdomainIfRequired(mergedValues)
+
+	if err != nil {
+		return "", err
+	}
+
+	err = c.Client.DeployTemplate(
+		context.Background(),
+		opts.ProjectID,
+		opts.ClusterID,
+		opts.Kind,
+		latestVersion,
+		&api.DeployTemplateRequest{
+			TemplateName: opts.Kind,
+			ImageURL:     imageURL,
+			FormValues:   mergedValues,
+			Namespace:    opts.Namespace,
+			Name:         opts.ReleaseName,
+			GitAction: &api.DeployTemplateGitAction{
+				GitRepo:        ghOpts.Repo,
+				GitBranch:      ghOpts.Branch,
+				ImageRepoURI:   imageURL,
+				DockerfilePath: opts.LocalDockerfile,
+				FolderPath:     ".",
+				GitRepoID:      gitRepoMatch,
+				BuildEnv:       env,
+				RegistryID:     regID,
+			},
+		},
+	)
+
+	if err != nil {
+		return "", err
+	}
+
+	return subdomain, nil
+}
+
+// CreateFromRegistry deploys a new application from an existing Docker repository + tag.
+func (c *CreateAgent) CreateFromRegistry(
+	image string,
+	overrideValues map[string]interface{},
+) (string, error) {
+	if image == "" {
+		return "", fmt.Errorf("image cannot be empty")
+	}
+
+	// split image into image-path:tag format
+	imageSpl := strings.Split(image, ":")
+
+	if len(imageSpl) != 2 {
+		return "", fmt.Errorf("invalid image format: must be image-path:tag format")
+	}
+
+	opts := c.CreateOpts
+
+	latestVersion, mergedValues, err := c.getMergedValues(overrideValues)
+
+	if err != nil {
+		return "", err
+	}
+
+	mergedValues["image"] = map[string]interface{}{
+		"repository": imageSpl[0],
+		"tag":        imageSpl[1],
+	}
+
+	subdomain, err := c.CreateSubdomainIfRequired(mergedValues)
+
+	if err != nil {
+		return "", err
+	}
+
+	err = c.Client.DeployTemplate(
+		context.Background(),
+		opts.ProjectID,
+		opts.ClusterID,
+		opts.Kind,
+		latestVersion,
+		&api.DeployTemplateRequest{
+			TemplateName: opts.Kind,
+			ImageURL:     imageSpl[0],
+			FormValues:   mergedValues,
+			Namespace:    opts.Namespace,
+			Name:         opts.ReleaseName,
+		},
+	)
+
+	if err != nil {
+		return "", err
+	}
+
+	return subdomain, nil
+}
+
+// CreateFromDocker uses a local build context and a local Docker daemon to build a new
+// container image, and then deploys it onto Porter.
+func (c *CreateAgent) CreateFromDocker(
+	overrideValues map[string]interface{},
+) (string, error) {
+	opts := c.CreateOpts
+
+	// detect the build config
+	if opts.Method != "" {
+		if opts.Method == DeployBuildTypeDocker {
+			if opts.LocalDockerfile == "" {
+				hasDockerfile := c.HasDefaultDockerfile(opts.LocalPath)
+
+				if !hasDockerfile {
+					return "", fmt.Errorf("Dockerfile not found")
+				}
+
+				opts.LocalDockerfile = "Dockerfile"
+			}
+		}
+	} else {
+		// try to detect dockerfile, otherwise fall back to `pack`
+		hasDockerfile := c.HasDefaultDockerfile(opts.LocalPath)
+
+		if !hasDockerfile {
+			opts.Method = DeployBuildTypePack
+		} else {
+			opts.Method = DeployBuildTypeDocker
+			opts.LocalDockerfile = "Dockerfile"
+		}
+	}
+
+	// overwrite with docker image repository and tag
+	regID, imageURL, err := c.GetImageRepoURL(opts.ReleaseName, opts.Namespace)
+
+	if err != nil {
+		return "", err
+	}
+
+	latestVersion, mergedValues, err := c.getMergedValues(overrideValues)
+
+	if err != nil {
+		return "", err
+	}
+
+	mergedValues["image"] = map[string]interface{}{
+		"repository": imageURL,
+		"tag":        "latest",
+	}
+
+	// create docker agen
+	agent, err := docker.NewAgentWithAuthGetter(c.Client, opts.ProjectID)
+
+	if err != nil {
+		return "", err
+	}
+
+	env, err := GetEnvFromConfig(mergedValues)
+
+	if err != nil {
+		env = map[string]string{}
+	}
+
+	buildAgent := &BuildAgent{
+		SharedOpts:  opts.SharedOpts,
+		client:      c.Client,
+		imageRepo:   imageURL,
+		env:         env,
+		imageExists: false,
+	}
+
+	if opts.Method == DeployBuildTypeDocker {
+		err = buildAgent.BuildDocker(agent, opts.LocalPath, ".", opts.LocalDockerfile, "latest")
+	} else {
+		err = buildAgent.BuildPack(agent, opts.LocalPath, "latest")
+	}
+
+	if err != nil {
+		return "", err
+	}
+
+	// create repository
+	err = c.Client.CreateRepository(
+		context.Background(),
+		opts.ProjectID,
+		regID,
+		&api.CreateRepositoryRequest{
+			ImageRepoURI: imageURL,
+		},
+	)
+
+	if err != nil {
+		return "", err
+	}
+
+	err = agent.PushImage(fmt.Sprintf("%s:%s", imageURL, "latest"))
+
+	if err != nil {
+		return "", err
+	}
+
+	subdomain, err := c.CreateSubdomainIfRequired(mergedValues)
+
+	if err != nil {
+		return "", err
+	}
+
+	err = c.Client.DeployTemplate(
+		context.Background(),
+		opts.ProjectID,
+		opts.ClusterID,
+		opts.Kind,
+		latestVersion,
+		&api.DeployTemplateRequest{
+			TemplateName: opts.Kind,
+			ImageURL:     imageURL,
+			FormValues:   mergedValues,
+			Namespace:    opts.Namespace,
+			Name:         opts.ReleaseName,
+		},
+	)
+
+	if err != nil {
+		return "", err
+	}
+
+	return subdomain, nil
+}
+
+// HasDefaultDockerfile detects if there is a dockerfile at the path `./Dockerfile`
+func (c *CreateAgent) HasDefaultDockerfile(buildPath string) bool {
+	dockerFilePath := filepath.Join(buildPath, "./Dockerfile")
+
+	info, err := os.Stat(dockerFilePath)
+
+	return err == nil && !os.IsNotExist(err) && !info.IsDir()
+}
+
+// GetImageRepoURL creates the image repository url by finding the first valid image
+// registry linked to Porter, and then generates a new name of the form:
+// `{registry}/{name}-{namespace}`
+func (c *CreateAgent) GetImageRepoURL(name, namespace string) (uint, string, error) {
+	// get all image registries linked to the project
+	// get the list of namespaces
+	registries, err := c.Client.ListRegistries(
+		context.Background(),
+		c.CreateOpts.ProjectID,
+	)
+
+	if err != nil {
+		return 0, "", err
+	} else if len(registries) == 0 {
+		return 0, "", fmt.Errorf("must have created or linked an image registry")
+	}
+
+	// get the first non-empty registry
+	var imageURI string
+	var regID uint
+
+	for _, reg := range registries {
+		if c.CreateOpts.RegistryURL != "" {
+			if c.CreateOpts.RegistryURL == reg.URL {
+				regID = reg.ID
+				imageURI = fmt.Sprintf("%s/%s-%s", reg.URL, name, namespace)
+				break
+			}
+		} else if reg.URL != "" {
+			regID = reg.ID
+			imageURI = fmt.Sprintf("%s/%s-%s", reg.URL, name, namespace)
+			break
+		}
+	}
+
+	return regID, imageURI, nil
+}
+
+// GetLatestTemplateVersion retrieves the latest template version for a specific
+// Porter template from the chart repository.
+func (c *CreateAgent) GetLatestTemplateVersion(templateName string) (string, error) {
+	templates, err := c.Client.ListTemplates(
+		context.Background(),
+	)
+
+	if err != nil {
+		return "", err
+	}
+
+	var version string
+	// find the matching template name
+	for _, template := range templates {
+		if templateName == template.Name {
+			version = template.Versions[0]
+			break
+		}
+	}
+
+	if version == "" {
+		return "", fmt.Errorf("matching template version not found")
+	}
+
+	return version, nil
+}
+
+// GetLatestTemplateDefaultValues gets the default config (`values.yaml`) set for a specific
+// template.
+func (c *CreateAgent) GetLatestTemplateDefaultValues(templateName, templateVersion string) (map[string]interface{}, error) {
+	chart, err := c.Client.GetTemplate(
+		context.Background(),
+		templateName,
+		templateVersion,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return chart.Values, nil
+}
+
+func (c *CreateAgent) getMergedValues(overrideValues map[string]interface{}) (string, map[string]interface{}, error) {
+	// deploy the template
+	latestVersion, err := c.GetLatestTemplateVersion(c.CreateOpts.Kind)
+
+	if err != nil {
+		return "", nil, err
+	}
+
+	// get the values of the template
+	values, err := c.GetLatestTemplateDefaultValues(c.CreateOpts.Kind, latestVersion)
+
+	if err != nil {
+		return "", nil, err
+	}
+
+	// merge existing values with overriding values
+	mergedValues := utils.CoalesceValues(values, overrideValues)
+
+	return latestVersion, mergedValues, err
+}
+
+func (c *CreateAgent) CreateSubdomainIfRequired(mergedValues map[string]interface{}) (string, error) {
+	subdomain := ""
+
+	// check for automatic subdomain creation if web kind
+	if c.CreateOpts.Kind == "web" {
+		// look for ingress.enabled and no custom domains set
+		ingressMap, err := getNestedMap(mergedValues, "ingress")
+
+		if err == nil {
+			enabledVal, enabledExists := ingressMap["enabled"]
+
+			customDomVal, customDomExists := ingressMap["custom_domain"]
+
+			if enabledExists && customDomExists {
+				enabled, eOK := enabledVal.(bool)
+				customDomain, cOK := customDomVal.(bool)
+
+				// in the case of ingress enabled but no custom domain, create subdomain
+				if eOK && cOK && enabled && !customDomain {
+					dnsRecord, err := c.Client.CreateDNSRecord(
+						context.Background(),
+						c.CreateOpts.ProjectID,
+						c.CreateOpts.ClusterID,
+						&api.CreateDNSRecordRequest{
+							ReleaseName: c.CreateOpts.ReleaseName,
+						},
+					)
+
+					if err != nil {
+						return "", fmt.Errorf("Error creating subdomain: %s", err.Error())
+					}
+
+					subdomain = dnsRecord.ExternalURL
+
+					if ingressVal, ok := mergedValues["ingress"]; !ok {
+						mergedValues["ingress"] = map[string]interface{}{
+							"porter_hosts": []string{
+								subdomain,
+							},
+						}
+					} else {
+						ingressValMap := ingressVal.(map[string]interface{})
+
+						ingressValMap["porter_hosts"] = []string{
+							subdomain,
+						}
+					}
+				}
+			}
+		}
+	}
+
+	return subdomain, nil
+}

+ 91 - 87
cli/cmd/deploy/deploy.go

@@ -2,6 +2,7 @@ package deploy
 
 import (
 	"context"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"io/ioutil"
@@ -12,7 +13,7 @@ import (
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/cli/cmd/docker"
 	"github.com/porter-dev/porter/cli/cmd/github"
-	"github.com/porter-dev/porter/cli/cmd/pack"
+	"github.com/porter-dev/porter/internal/templater/utils"
 	"k8s.io/client-go/util/homedir"
 )
 
@@ -21,10 +22,10 @@ type DeployBuildType string
 
 const (
 	// uses local Docker daemon to build and push images
-	deployBuildTypeDocker DeployBuildType = "docker"
+	DeployBuildTypeDocker DeployBuildType = "docker"
 
 	// uses cloud-native build pack to build and push images
-	deployBuildTypePack DeployBuildType = "pack"
+	DeployBuildTypePack DeployBuildType = "pack"
 )
 
 // DeployAgent handles the deployment and redeployment of an application on Porter
@@ -45,14 +46,9 @@ type DeployAgent struct {
 
 // DeployOpts are the options for creating a new DeployAgent
 type DeployOpts struct {
-	ProjectID       uint
-	ClusterID       uint
-	Namespace       string
-	Local           bool
-	LocalPath       string
-	LocalDockerfile string
-	OverrideTag     string
-	Method          DeployBuildType
+	*SharedOpts
+
+	Local bool
 }
 
 // NewDeployAgent creates a new DeployAgent given a Porter API client, application
@@ -94,18 +90,18 @@ func NewDeployAgent(client *api.Client, app string, opts *DeployOpts) (*DeployAg
 			// if the git action config exists, and dockerfile path is not empty, build type
 			// is docker
 			if release.GitActionConfig.DockerfilePath != "" {
-				deployAgent.opts.Method = deployBuildTypeDocker
+				deployAgent.opts.Method = DeployBuildTypeDocker
+			} else {
+				// otherwise build type is pack
+				deployAgent.opts.Method = DeployBuildTypePack
 			}
-
-			// otherwise build type is pack
-			deployAgent.opts.Method = deployBuildTypePack
 		} else {
-			// if the git action config does not exist, we use pack by default
-			deployAgent.opts.Method = deployBuildTypePack
+			// if the git action config does not exist, we use docker by default
+			deployAgent.opts.Method = DeployBuildTypeDocker
 		}
 	}
 
-	if deployAgent.opts.Method == deployBuildTypeDocker {
+	if deployAgent.opts.Method == DeployBuildTypeDocker {
 		if release.GitActionConfig != nil {
 			deployAgent.dockerfilePath = release.GitActionConfig.DockerfilePath
 		}
@@ -143,10 +139,13 @@ func NewDeployAgent(client *api.Client, app string, opts *DeployOpts) (*DeployAg
 	return deployAgent, nil
 }
 
+// GetBuildEnv retrieves the build env from the release config and returns it
 func (d *DeployAgent) GetBuildEnv() (map[string]string, error) {
-	return d.getEnvFromRelease()
+	return GetEnvFromConfig(d.release.Config)
 }
 
+// SetBuildEnv sets the build env vars in the process so that other commands can
+// use them
 func (d *DeployAgent) SetBuildEnv(envVars map[string]string) error {
 	d.env = envVars
 
@@ -166,6 +165,7 @@ func (d *DeployAgent) SetBuildEnv(envVars map[string]string) error {
 	return nil
 }
 
+// WriteBuildEnv writes the build env to either a file or stdout
 func (d *DeployAgent) WriteBuildEnv(fileDest string) error {
 	// join lines together
 	lines := make([]string, 0)
@@ -189,9 +189,12 @@ func (d *DeployAgent) WriteBuildEnv(fileDest string) error {
 	return nil
 }
 
+// Build uses the deploy agent options to build a new container image from either
+// buildpack or docker.
 func (d *DeployAgent) Build() error {
 	// if build is not local, fetch remote source
-	var dst string
+	var basePath string
+	buildCtx := d.opts.LocalPath
 	var err error
 
 	if !d.opts.Local {
@@ -206,26 +209,40 @@ func (d *DeployAgent) Build() error {
 		}
 
 		// download the repository from remote source into a temp directory
-		dst, err = d.downloadRepoToDir(zipResp.URLString)
+		basePath, err = d.downloadRepoToDir(zipResp.URLString)
+
+		if err != nil {
+			return err
+		}
 
 		if d.tag == "" {
 			shortRef := fmt.Sprintf("%.7s", zipResp.LatestCommitSHA)
 			d.tag = shortRef
 		}
+	} else {
+		basePath, err = filepath.Abs(".")
 
 		if err != nil {
 			return err
 		}
-	} else {
-		dst = filepath.Dir(d.opts.LocalPath)
 	}
 
 	if d.tag == "" {
-		d.tag = "latest"
+		currImageSection := d.release.Config["image"].(map[string]interface{})
+
+		d.tag = currImageSection["tag"].(string)
 	}
 
 	err = d.pullCurrentReleaseImage()
 
+	buildAgent := &BuildAgent{
+		SharedOpts:  d.opts.SharedOpts,
+		client:      d.client,
+		imageRepo:   d.imageRepo,
+		env:         d.env,
+		imageExists: d.imageExists,
+	}
+
 	// if image is not found, don't return an error
 	if err != nil && err != docker.PullImageErrNotFound {
 		return err
@@ -234,93 +251,76 @@ func (d *DeployAgent) Build() error {
 		d.imageExists = false
 	}
 
-	if d.opts.Method == deployBuildTypeDocker {
-		return d.BuildDocker(dst, d.tag)
+	if d.opts.Method == DeployBuildTypeDocker {
+		return buildAgent.BuildDocker(
+			d.agent,
+			basePath,
+			buildCtx,
+			d.dockerfilePath,
+			d.tag,
+		)
 	}
 
-	return d.BuildPack(dst, d.tag)
+	return buildAgent.BuildPack(d.agent, buildCtx, d.tag)
 }
 
-func (d *DeployAgent) BuildDocker(dst, tag string) error {
-	opts := &docker.BuildOpts{
-		ImageRepo:    d.imageRepo,
-		Tag:          tag,
-		BuildContext: dst,
-		Env:          d.env,
-	}
-
-	return d.agent.BuildLocal(
-		opts,
-		d.dockerfilePath,
-	)
+// Push pushes a local image to the remote repository linked in the release
+func (d *DeployAgent) Push() error {
+	return d.agent.PushImage(fmt.Sprintf("%s:%s", d.imageRepo, d.tag))
 }
 
-func (d *DeployAgent) BuildPack(dst, tag string) error {
-	// retag the image with "pack-cache" tag so that it doesn't re-pull from the registry
-	if d.imageExists {
-		err := d.agent.TagImage(
-			fmt.Sprintf("%s:%s", d.imageRepo, tag),
-			fmt.Sprintf("%s:%s", d.imageRepo, "pack-cache"),
-		)
+// UpdateImageAndValues updates the current image for a release, along with new
+// configuration passed in via overrrideValues. If overrideValues is nil, it just
+// reuses the configuration set for the application. If overrideValues is not nil,
+// it will merge the overriding values with the existing configuration.
+func (d *DeployAgent) UpdateImageAndValues(overrideValues map[string]interface{}) error {
+	mergedValues := utils.CoalesceValues(d.release.Config, overrideValues)
+
+	// overwrite the tag based on a new image
+	currImageSection := mergedValues["image"].(map[string]interface{})
+
+	// if the current image section is hello-porter, the image must be overriden
+	if currImageSection["repository"] == "public.ecr.aws/o1j4x7p4/hello-porter" ||
+		currImageSection["repository"] == "public.ecr.aws/o1j4x7p4/hello-porter-job" {
+		newImage, err := d.getReleaseImage()
 
 		if err != nil {
-			return err
+			return fmt.Errorf("could not overwrite hello-porter image: %s", err.Error())
 		}
-	}
 
-	// create pack agent and build opts
-	packAgent := &pack.Agent{}
+		currImageSection["repository"] = newImage
+
+		// set to latest just to be safe -- this will be overriden if "d.tag" is set in
+		// the agent
+		currImageSection["tag"] = "latest"
+	}
 
-	opts := &docker.BuildOpts{
-		ImageRepo: d.imageRepo,
-		// We tag the image with a stable param "pack-cache" so that pack can use the
-		// local image without attempting to re-pull from registry. We handle getting
-		// registry credentials and pushing/pulling the image.
-		Tag:          "pack-cache",
-		BuildContext: dst,
-		Env:          d.env,
+	if d.tag != "" && currImageSection["tag"] != d.tag {
+		currImageSection["tag"] = d.tag
 	}
 
-	// call builder
-	err := packAgent.Build(opts)
+	bytes, err := json.Marshal(mergedValues)
 
 	if err != nil {
 		return err
 	}
 
-	return d.agent.TagImage(
-		fmt.Sprintf("%s:%s", d.imageRepo, "pack-cache"),
-		fmt.Sprintf("%s:%s", d.imageRepo, tag),
-	)
-}
-
-func (d *DeployAgent) Push() error {
-	return d.agent.PushImage(fmt.Sprintf("%s:%s", d.imageRepo, d.tag))
-}
-
-func (d *DeployAgent) CallWebhook() error {
-	releaseExt, err := d.client.GetReleaseWebhook(
+	return d.client.UpgradeRelease(
 		context.Background(),
 		d.opts.ProjectID,
 		d.opts.ClusterID,
 		d.release.Name,
-		d.release.Namespace,
-	)
-
-	if err != nil {
-		return err
-	}
-
-	return d.client.DeployWithWebhook(
-		context.Background(),
-		releaseExt.WebhookToken,
-		d.tag,
+		&api.UpgradeReleaseRequest{
+			Values:    string(bytes),
+			Namespace: d.release.Namespace,
+		},
 	)
 }
 
-// HELPER METHODS
-func (d *DeployAgent) getEnvFromRelease() (map[string]string, error) {
-	envConfig, err := getNestedMap(d.release.Config, "container", "env", "normal")
+// GetEnvFromConfig gets the env vars for a standard Porter template config. These env
+// vars are found at `container.env.normal`.
+func GetEnvFromConfig(config map[string]interface{}) (map[string]string, error) {
+	envConfig, err := getNestedMap(config, "container", "env", "normal")
 
 	// if the field is not found, set envConfig to an empty map; this release has no env set
 	if e := (&NestedMapFieldNotFoundError{}); errors.As(err, &e) {
@@ -349,7 +349,11 @@ func (d *DeployAgent) getEnvFromRelease() (map[string]string, error) {
 }
 
 func (d *DeployAgent) getReleaseImage() (string, error) {
-	// pull the currently deployed image to use cache, if possible
+	if d.release.ImageRepoURI != "" {
+		return d.release.ImageRepoURI, nil
+	}
+
+	// get the image from the conig
 	imageConfig, err := getNestedMap(d.release.Config, "image")
 
 	if err != nil {

+ 12 - 0
cli/cmd/deploy/shared.go

@@ -0,0 +1,12 @@
+package deploy
+
+// SharedOpts are common options for build, create, and deploy agents
+type SharedOpts struct {
+	ProjectID       uint
+	ClusterID       uint
+	Namespace       string
+	LocalPath       string
+	LocalDockerfile string
+	OverrideTag     string
+	Method          DeployBuildType
+}

+ 4 - 0
cli/cmd/docker.go

@@ -138,6 +138,10 @@ func dockerConfig(user *api.AuthCheckResponse, client *api.Client, args []string
 		configFile.CredentialHelpers = make(map[string]string)
 	}
 
+	if configFile.AuthConfigs == nil {
+		configFile.AuthConfigs = make(map[string]types.AuthConfig)
+	}
+
 	for _, regURL := range regToAdd {
 		// if this is a dockerhub registry, see if an auth config has already been generated
 		// for index.docker.io

+ 4 - 2
cli/cmd/docker/agent.go

@@ -200,12 +200,14 @@ func (a *Agent) PushImage(image string) error {
 		opts,
 	)
 
+	if out != nil {
+		defer out.Close()
+	}
+
 	if err != nil {
 		return err
 	}
 
-	defer out.Close()
-
 	termFd, isTerm := term.GetFdInfo(os.Stderr)
 
 	return jsonmessage.DisplayJSONMessagesStream(out, os.Stderr, termFd, isTerm, nil)

+ 79 - 5
cli/cmd/docker/builder.go

@@ -1,31 +1,59 @@
 package docker
 
 import (
+	"archive/tar"
+	"bytes"
 	"context"
 	"fmt"
+	"io"
+	"io/ioutil"
 	"os"
+	"time"
 
 	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/pkg/archive"
 	"github.com/moby/moby/pkg/jsonmessage"
+	"github.com/moby/moby/pkg/stringid"
 	"github.com/moby/term"
+	"github.com/pkg/errors"
 )
 
 type BuildOpts struct {
-	ImageRepo    string
-	Tag          string
-	BuildContext string
-	Env          map[string]string
+	ImageRepo         string
+	Tag               string
+	BuildContext      string
+	DockerfilePath    string
+	IsDockerfileInCtx bool
+
+	Env map[string]string
 }
 
 // BuildLocal
-func (a *Agent) BuildLocal(opts *BuildOpts, dockerfilePath string) error {
+func (a *Agent) BuildLocal(opts *BuildOpts) error {
+	dockerfilePath := opts.DockerfilePath
 	tar, err := archive.TarWithOptions(opts.BuildContext, &archive.TarOptions{})
 
 	if err != nil {
 		return err
 	}
 
+	if !opts.IsDockerfileInCtx {
+		dockerfileCtx, err := os.Open(dockerfilePath)
+
+		if err != nil {
+			return errors.Errorf("unable to open Dockerfile: %v", err)
+		}
+
+		defer dockerfileCtx.Close()
+
+		// add the dockerfile to the build context
+		tar, dockerfilePath, err = AddDockerfileToBuildContext(dockerfileCtx, tar)
+
+		if err != nil {
+			return err
+		}
+	}
+
 	buildArgs := make(map[string]*string)
 
 	for key, val := range opts.Env {
@@ -52,3 +80,49 @@ func (a *Agent) BuildLocal(opts *BuildOpts, dockerfilePath string) error {
 
 	return jsonmessage.DisplayJSONMessagesStream(out.Body, os.Stderr, termFd, isTerm, nil)
 }
+
+// AddDockerfileToBuildContext from a ReadCloser, returns a new archive and
+// the relative path to the dockerfile in the context.
+func AddDockerfileToBuildContext(dockerfileCtx io.ReadCloser, buildCtx io.ReadCloser) (io.ReadCloser, string, error) {
+	file, err := ioutil.ReadAll(dockerfileCtx)
+	dockerfileCtx.Close()
+	if err != nil {
+		return nil, "", err
+	}
+	now := time.Now()
+	hdrTmpl := &tar.Header{
+		Mode:       0600,
+		Uid:        0,
+		Gid:        0,
+		ModTime:    now,
+		Typeflag:   tar.TypeReg,
+		AccessTime: now,
+		ChangeTime: now,
+	}
+	randomName := ".dockerfile." + stringid.GenerateRandomID()[:20]
+
+	buildCtx = archive.ReplaceFileTarWrapper(buildCtx, map[string]archive.TarModifierFunc{
+		// Add the dockerfile with a random filename
+		randomName: func(_ string, h *tar.Header, content io.Reader) (*tar.Header, []byte, error) {
+			return hdrTmpl, file, nil
+		},
+		// Update .dockerignore to include the random filename
+		".dockerignore": func(_ string, h *tar.Header, content io.Reader) (*tar.Header, []byte, error) {
+			if h == nil {
+				h = hdrTmpl
+			}
+
+			b := &bytes.Buffer{}
+			if content != nil {
+				if _, err := b.ReadFrom(content); err != nil {
+					return nil, nil, err
+				}
+			} else {
+				b.WriteString(".dockerignore")
+			}
+			b.WriteString("\n" + randomName + "\n")
+			return h, b.Bytes(), nil
+		},
+	})
+	return buildCtx, randomName, nil
+}

+ 6 - 2
cli/cmd/errors.go

@@ -2,12 +2,16 @@ package cmd
 
 import (
 	"context"
+	"errors"
 	"strings"
 
 	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
 )
 
+var ErrNotLoggedIn error = errors.New("You are not logged in.")
+var ErrCannotConnect error = errors.New("Unable to connect to the Porter server.")
+
 func checkLoginAndRun(args []string, runner func(user *api.AuthCheckResponse, client *api.Client, args []string) error) error {
 	client := GetAPIClient(config)
 
@@ -18,12 +22,12 @@ func checkLoginAndRun(args []string, runner func(user *api.AuthCheckResponse, cl
 
 		if strings.Contains(err.Error(), "403") {
 			red.Print("You are not logged in. Log in using \"porter auth login\"\n")
-			return nil
+			return ErrNotLoggedIn
 		} else if strings.Contains(err.Error(), "connection refused") {
 			red.Printf("Unable to connect to the Porter server at %s\n", config.Host)
 			red.Print("To set a different host, run \"porter config set-host [HOST]\"\n")
 			red.Print("To start a local server, run \"porter server start\"\n")
-			return nil
+			return ErrCannotConnect
 		}
 
 		red.Printf("Error: %v\n", err.Error())

+ 102 - 0
cli/cmd/gitutils/git.go

@@ -0,0 +1,102 @@
+package gitutils
+
+import (
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/cli/cli/git"
+)
+
+func GitDirectory(fullpath string) (string, error) {
+	currDir, err := os.Getwd()
+
+	if err != nil {
+		return "", fmt.Errorf("could not read current directory: %s", err.Error())
+	}
+
+	err = os.Chdir(fullpath)
+
+	if err != nil {
+		return "", nil
+	}
+
+	res, gitErr := git.ToplevelDir()
+
+	err = os.Chdir(currDir)
+
+	if err != nil {
+		return "", err
+	}
+
+	return res, gitErr
+}
+
+func GetRemoteBranch(fullpath string) (*git.Remote, string, error) {
+	var remote *git.Remote
+
+	currDir, err := os.Getwd()
+
+	if err != nil {
+		return nil, "", fmt.Errorf("could not read current directory: %s", err.Error())
+	}
+
+	err = os.Chdir(fullpath)
+
+	if err != nil {
+		return nil, "", nil
+	}
+
+	// read the current branch
+	branch, gitErr := git.CurrentBranch()
+
+	if gitErr == nil {
+		branchConf := git.ReadBranchConfig(branch)
+		remoteName := "origin"
+
+		if branchConf.RemoteName != "" {
+			remoteName = branchConf.RemoteName
+		}
+
+		remotes, err := git.Remotes()
+
+		if err != nil {
+			return nil, "", err
+		}
+
+		for _, _remote := range remotes {
+			if _remote.Name == remoteName {
+				remote = _remote
+				break
+			}
+		}
+
+		if remote == nil {
+			return nil, "", fmt.Errorf("remote repository not found")
+		}
+	}
+
+	err = os.Chdir(currDir)
+
+	if err != nil {
+		return nil, "", err
+	}
+
+	return remote, branch, gitErr
+}
+
+func ParseGithubRemote(remote *git.Remote) (bool, string) {
+	if remote == nil || remote.FetchURL == nil {
+		return false, ""
+	}
+
+	if remote.FetchURL.Host != "github.com" {
+		return false, ""
+	}
+
+	if !strings.Contains(remote.FetchURL.Path, ".git") {
+		return false, ""
+	}
+
+	return true, strings.Trim(strings.TrimSuffix(remote.FetchURL.Path, ".git"), "/")
+}

+ 89 - 0
cli/cmd/job.go

@@ -0,0 +1,89 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"os"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/spf13/cobra"
+)
+
+var batchImageUpdateCmd = &cobra.Command{
+	Use:   "job update-images",
+	Short: "Updates the image tag of all jobs in a namespace which use a specific image.",
+	Long: fmt.Sprintf(`
+%s 
+
+Updates the image tag of all jobs in a namespace which use a specific image. Note that for all
+jobs with version <= v0.4.0, this will trigger a new run of a manual job. However, for versions
+>= v0.5.0, this will not create a new run of the job. 
+
+Example commands:
+
+  %s
+
+This command is namespace-scoped and uses the default namespace. To specify a different namespace, 
+use the --namespace flag:
+
+  %s
+`,
+		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter job update-images\":"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter job update-images --image-repo-uri my-image.registry.io --tag newtag"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter job update-images --namespace custom-namespace --image-repo-uri my-image.registry.io --tag newtag"),
+	),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, batchImageUpdate)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var imageRepoURI string
+
+func init() {
+	rootCmd.AddCommand(batchImageUpdateCmd)
+
+	batchImageUpdateCmd.PersistentFlags().StringVar(
+		&tag,
+		"tag",
+		"",
+		"The new image tag to use.",
+	)
+
+	batchImageUpdateCmd.PersistentFlags().StringVar(
+		&namespace,
+		"namespace",
+		"",
+		"The namespace of the jobs.",
+	)
+
+	batchImageUpdateCmd.PersistentFlags().StringVarP(
+		&imageRepoURI,
+		"image-repo-uri",
+		"i",
+		"",
+		"Image repo uri",
+	)
+
+	batchImageUpdateCmd.MarkPersistentFlagRequired("image-repo-uri")
+	batchImageUpdateCmd.MarkPersistentFlagRequired("tag")
+}
+
+func batchImageUpdate(resp *api.AuthCheckResponse, client *api.Client, args []string) error {
+	color.New(color.FgGreen).Println("Updating all jobs which use the image:", imageRepoURI)
+
+	return client.UpdateBatchImage(
+		context.TODO(),
+		config.Project,
+		config.Cluster,
+		namespace,
+		&api.UpdateBatchImageRequest{
+			ImageRepoURI: imageRepoURI,
+			Tag:          tag,
+		},
+	)
+}

+ 15 - 3
cli/cmd/login/server.go

@@ -19,9 +19,15 @@ func redirect(
 		w.Header().Set("Content-Type", "text/html; charset=utf-8")
 		fmt.Fprint(w, successScreen)
 
-		queryParams, _ := url.ParseQuery(r.URL.RawQuery)
+		queryParams, err := url.ParseQuery(r.URL.RawQuery)
 
-		codechan <- queryParams["code"][0]
+		if err != nil {
+			return
+		}
+
+		if codeParam, exists := queryParams["code"]; exists && len(codeParam) > 0 {
+			codechan <- queryParams["code"][0]
+		}
 	}
 }
 
@@ -49,7 +55,13 @@ func Login(
 	}()
 
 	// open browser for host login
-	redirectHost := fmt.Sprintf("http://localhost:%d", port)
+	var redirectHost string
+	if utils.CheckIfWsl() {
+		redirectHost = fmt.Sprintf("http://%s:%d", utils.GetWslHostName(), port)
+	} else {
+		redirectHost = fmt.Sprintf("http://localhost:%d", port)
+	}
+
 	loginURL := fmt.Sprintf("%s/api/cli/login?redirect=%s", host, url.QueryEscape(redirectHost))
 
 	err = utils.OpenBrowser(loginURL)

+ 113 - 0
cli/cmd/logs.go

@@ -0,0 +1,113 @@
+package cmd
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+	"github.com/spf13/cobra"
+)
+
+// logsCmd represents the "porter logs" base command when called
+// without any subcommands
+var logsCmd = &cobra.Command{
+	Use:   "logs [release]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Logs the output from a given application.",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, logs)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var follow bool
+
+func init() {
+	rootCmd.AddCommand(logsCmd)
+
+	logsCmd.PersistentFlags().StringVar(
+		&namespace,
+		"namespace",
+		"default",
+		"namespace of release to connect to",
+	)
+
+	logsCmd.PersistentFlags().BoolVarP(
+		&follow,
+		"follow",
+		"f",
+		false,
+		"specify if the logs should be streamed",
+	)
+}
+
+func logs(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
+	podsSimple, err := getPods(client, namespace, args[0])
+
+	if err != nil {
+		return fmt.Errorf("Could not retrieve list of pods: %s", err.Error())
+	}
+
+	// if length of pods is 0, throw error
+	var selectedPod podSimple
+
+	if len(podsSimple) == 0 {
+		return fmt.Errorf("At least one pod must exist in this deployment.")
+	} else if len(podsSimple) == 1 {
+		selectedPod = podsSimple[0]
+	} else {
+		podNames := make([]string, 0)
+
+		for _, podSimple := range podsSimple {
+			podNames = append(podNames, podSimple.Name)
+		}
+
+		selectedPodName, err := utils.PromptSelect("Select the pod:", podNames)
+
+		if err != nil {
+			return err
+		}
+
+		// find selected pod
+		for _, podSimple := range podsSimple {
+			if selectedPodName == podSimple.Name {
+				selectedPod = podSimple
+			}
+		}
+	}
+
+	var selectedContainerName string
+
+	// if the selected pod has multiple container, spawn selector
+	if len(selectedPod.ContainerNames) == 0 {
+		return fmt.Errorf("At least one pod must exist in this deployment.")
+	} else if len(selectedPod.ContainerNames) == 1 {
+		selectedContainerName = selectedPod.ContainerNames[0]
+	} else {
+		selectedContainer, err := utils.PromptSelect("Select the container:", selectedPod.ContainerNames)
+
+		if err != nil {
+			return err
+		}
+
+		selectedContainerName = selectedContainer
+	}
+
+	config := &PorterRunSharedConfig{
+		Client: client,
+	}
+
+	err = config.setSharedConfig()
+
+	if err != nil {
+		return fmt.Errorf("Could not retrieve kube credentials: %s", err.Error())
+	}
+
+	_, err = pipePodLogsToStdout(config, namespace, selectedPod.Name, selectedContainerName, follow)
+
+	return err
+}

+ 14 - 0
cli/cmd/project.go

@@ -140,3 +140,17 @@ func deleteProject(_ *api.AuthCheckResponse, client *api.Client, args []string)
 
 	return nil
 }
+
+func setProjectCluster(client *api.Client, projectID uint) error {
+	clusters, err := client.ListProjectClusters(context.Background(), projectID)
+
+	if err != nil {
+		return err
+	}
+
+	if len(clusters) > 0 {
+		config.SetCluster(clusters[0].ID)
+	}
+
+	return nil
+}

+ 274 - 21
cli/cmd/run.go

@@ -3,22 +3,29 @@ package cmd
 import (
 	"context"
 	"fmt"
+	"io"
 	"os"
 	"strings"
+	"time"
 
 	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/spf13/cobra"
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/kubectl/pkg/util/term"
+
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/rest"
 	"k8s.io/client-go/tools/clientcmd"
 	"k8s.io/client-go/tools/remotecommand"
-	"k8s.io/kubectl/pkg/util/term"
 )
 
 var namespace string
+var verbose bool
 
 // runCmd represents the "porter run" base command when called
 // without any subcommands
@@ -35,6 +42,8 @@ var runCmd = &cobra.Command{
 	},
 }
 
+var existingPod bool
+
 func init() {
 	rootCmd.AddCommand(runCmd)
 
@@ -44,6 +53,22 @@ func init() {
 		"default",
 		"namespace of release to connect to",
 	)
+
+	runCmd.PersistentFlags().BoolVarP(
+		&existingPod,
+		"existing_pod",
+		"e",
+		false,
+		"whether to connect to an existing pod",
+	)
+
+	runCmd.PersistentFlags().BoolVarP(
+		&verbose,
+		"verbose",
+		"v",
+		false,
+		"whether to print verbose output",
+	)
 }
 
 func run(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
@@ -60,7 +85,7 @@ func run(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
 
 	if len(podsSimple) == 0 {
 		return fmt.Errorf("At least one pod must exist in this deployment.")
-	} else if len(podsSimple) == 1 {
+	} else if len(podsSimple) == 1 || !existingPod {
 		selectedPod = podsSimple[0]
 	} else {
 		podNames := make([]string, 0)
@@ -100,23 +125,38 @@ func run(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
 		selectedContainerName = selectedContainer
 	}
 
-	restConf, err := getRESTConfig(client)
+	config := &PorterRunSharedConfig{
+		Client: client,
+	}
+
+	err = config.setSharedConfig()
 
 	if err != nil {
 		return fmt.Errorf("Could not retrieve kube credentials: %s", err.Error())
 	}
 
-	return executeRun(restConf, namespace, selectedPod.Name, selectedContainerName, args[1:])
+	if existingPod {
+		return executeRun(config, namespace, selectedPod.Name, selectedContainerName, args[1:])
+	}
+
+	return executeRunEphemeral(config, namespace, selectedPod.Name, selectedContainerName, args[1:])
+}
+
+type PorterRunSharedConfig struct {
+	Client     *api.Client
+	RestConf   *rest.Config
+	Clientset  *kubernetes.Clientset
+	RestClient *rest.RESTClient
 }
 
-func getRESTConfig(client *api.Client) (*rest.Config, error) {
+func (p *PorterRunSharedConfig) setSharedConfig() error {
 	pID := config.Project
 	cID := config.Cluster
 
-	kubeResp, err := client.GetKubeconfig(context.TODO(), pID, cID)
+	kubeResp, err := p.Client.GetKubeconfig(context.TODO(), pID, cID)
 
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	kubeBytes := kubeResp.Kubeconfig
@@ -124,13 +164,13 @@ func getRESTConfig(client *api.Client) (*rest.Config, error) {
 	cmdConf, err := clientcmd.NewClientConfigFromBytes(kubeBytes)
 
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	restConf, err := cmdConf.ClientConfig()
 
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	restConf.GroupVersion = &schema.GroupVersion{
@@ -140,7 +180,25 @@ func getRESTConfig(client *api.Client) (*rest.Config, error) {
 
 	restConf.NegotiatedSerializer = runtime.NewSimpleNegotiatedSerializer(runtime.SerializerInfo{})
 
-	return restConf, nil
+	p.RestConf = restConf
+
+	clientset, err := kubernetes.NewForConfig(restConf)
+
+	if err != nil {
+		return err
+	}
+
+	p.Clientset = clientset
+
+	restClient, err := rest.RESTClientFor(restConf)
+
+	if err != nil {
+		return err
+	}
+
+	p.RestClient = restClient
+
+	return nil
 }
 
 type podSimple struct {
@@ -176,27 +234,20 @@ func getPods(client *api.Client, namespace, releaseName string) ([]podSimple, er
 	return res, nil
 }
 
-func executeRun(config *rest.Config, namespace, name, container string, args []string) error {
-	restClient, err := rest.RESTClientFor(config)
-
-	if err != nil {
-		return err
-	}
-
-	req := restClient.Post().
+func executeRun(config *PorterRunSharedConfig, namespace, name, container string, args []string) error {
+	req := config.RestClient.Post().
 		Resource("pods").
 		Name(name).
 		Namespace(namespace).
 		SubResource("exec")
 
-	// req.Param("container", "web")
 	for _, arg := range args {
 		req.Param("command", arg)
 	}
 	req.Param("stdin", "true")
 	req.Param("stdout", "true")
 	req.Param("tty", "true")
-	req.Param("container", "sidecar")
+	req.Param("container", container)
 
 	t := term.TTY{
 		In:  os.Stdin,
@@ -205,7 +256,7 @@ func executeRun(config *rest.Config, namespace, name, container string, args []s
 	}
 
 	fn := func() error {
-		exec, err := remotecommand.NewSPDYExecutor(config, "POST", req.URL())
+		exec, err := remotecommand.NewSPDYExecutor(config.RestConf, "POST", req.URL())
 
 		if err != nil {
 			return err
@@ -223,5 +274,207 @@ func executeRun(config *rest.Config, namespace, name, container string, args []s
 		return err
 	}
 
+	return nil
+}
+
+func executeRunEphemeral(config *PorterRunSharedConfig, namespace, name, container string, args []string) error {
+	existing, err := getExistingPod(config, name, namespace)
+
+	if err != nil {
+		return err
+	}
+
+	newPod, err := createPodFromExisting(config, existing, args)
+
+	if err != nil {
+		return err
+	}
+
+	podName := newPod.ObjectMeta.Name
+
+	t := term.TTY{
+		In:  os.Stdin,
+		Out: os.Stdout,
+		Raw: true,
+	}
+
+	fn := func() error {
+		req := config.RestClient.Post().
+			Resource("pods").
+			Name(podName).
+			Namespace("default").
+			SubResource("attach")
+
+		req.Param("stdin", "true")
+		req.Param("stdout", "true")
+		req.Param("tty", "true")
+		req.Param("container", container)
+
+		exec, err := remotecommand.NewSPDYExecutor(config.RestConf, "POST", req.URL())
+
+		if err != nil {
+			return err
+		}
+
+		return exec.Stream(remotecommand.StreamOptions{
+			Stdin:  os.Stdin,
+			Stdout: os.Stdout,
+			Stderr: os.Stderr,
+			Tty:    true,
+		})
+	}
+
+	color.New(color.FgYellow).Println("Attempting connection to the container, this may take up to 10 seconds. If you don't see a command prompt, try pressing enter.")
+
+	for i := 0; i < 5; i++ {
+		err = t.Safe(fn)
+
+		if err == nil {
+			break
+		}
+
+		time.Sleep(2 * time.Second)
+
+	}
+
+	// ugly way to catch no TTY errors, such as when running command "echo \"hello\""
+	if err != nil {
+		color.New(color.FgYellow).Println("Could not open a shell to this container. Container logs:\n")
+
+		var writtenBytes int64
+
+		writtenBytes, err = pipePodLogsToStdout(config, namespace, podName, container, false)
+
+		if verbose || writtenBytes == 0 {
+			color.New(color.FgYellow).Println("Could not get logs. Pod events:\n")
+
+			err = pipeEventsToStdout(config, namespace, podName, container, false)
+		}
+	} else if verbose {
+		color.New(color.FgYellow).Println("Pod events:\n")
+
+		pipeEventsToStdout(config, namespace, podName, container, false)
+	}
+
+	// delete the ephemeral pod
+	deletePod(config, podName, namespace)
+
 	return err
 }
+
+func pipePodLogsToStdout(config *PorterRunSharedConfig, namespace, name, container string, follow bool) (int64, error) {
+	podLogOpts := v1.PodLogOptions{
+		Container: container,
+		Follow:    follow,
+	}
+
+	req := config.Clientset.CoreV1().Pods(namespace).GetLogs(name, &podLogOpts)
+
+	podLogs, err := req.Stream(
+		context.Background(),
+	)
+
+	if err != nil {
+		return 0, err
+	}
+
+	defer podLogs.Close()
+
+	return io.Copy(os.Stdout, podLogs)
+}
+
+func pipeEventsToStdout(config *PorterRunSharedConfig, namespace, name, container string, follow bool) error {
+	// update the config in case the operation has taken longer than token expiry time
+	config.setSharedConfig()
+
+	// creates the clientset
+	resp, err := config.Clientset.CoreV1().Events(namespace).List(
+		context.TODO(),
+		metav1.ListOptions{
+			FieldSelector: fmt.Sprintf("involvedObject.name=%s,involvedObject.namespace=%s", name, namespace),
+		},
+	)
+
+	if err != nil {
+		return err
+	}
+
+	for _, event := range resp.Items {
+		color.New(color.FgRed).Println(event.Message)
+	}
+
+	return nil
+}
+
+func getExistingPod(config *PorterRunSharedConfig, name, namespace string) (*v1.Pod, error) {
+	return config.Clientset.CoreV1().Pods(namespace).Get(
+		context.Background(),
+		name,
+		metav1.GetOptions{},
+	)
+}
+
+func deletePod(config *PorterRunSharedConfig, name, namespace string) error {
+	// update the config in case the operation has taken longer than token expiry time
+	config.setSharedConfig()
+
+	err := config.Clientset.CoreV1().Pods(namespace).Delete(
+		context.Background(),
+		name,
+		metav1.DeleteOptions{},
+	)
+
+	if err != nil {
+		color.New(color.FgRed).Println("Could not delete ephemeral pod: %s", err.Error())
+		return err
+	}
+
+	color.New(color.FgGreen).Println("Sucessfully deleted ephemeral pod")
+
+	return nil
+}
+
+func createPodFromExisting(config *PorterRunSharedConfig, existing *v1.Pod, args []string) (*v1.Pod, error) {
+	newPod := existing.DeepCopy()
+
+	// only copy the pod spec, overwrite metadata
+	newPod.ObjectMeta = metav1.ObjectMeta{
+		Name:      strings.ToLower(fmt.Sprintf("%s-copy-%s", existing.ObjectMeta.Name, utils.String(4))),
+		Namespace: existing.ObjectMeta.Namespace,
+	}
+
+	newPod.Status = v1.PodStatus{}
+
+	// only use "primary" container
+	newPod.Spec.Containers = newPod.Spec.Containers[0:1]
+
+	// set restart policy to never
+	newPod.Spec.RestartPolicy = v1.RestartPolicyNever
+
+	// change the command in the pod to the passed in pod command
+	cmdRoot := args[0]
+	cmdArgs := make([]string, 0)
+
+	if len(args) > 1 {
+		cmdArgs = args[1:]
+	}
+
+	newPod.Spec.Containers[0].Command = []string{cmdRoot}
+	newPod.Spec.Containers[0].Args = cmdArgs
+	newPod.Spec.Containers[0].TTY = true
+	newPod.Spec.Containers[0].Stdin = true
+	newPod.Spec.Containers[0].StdinOnce = true
+	newPod.Spec.NodeName = ""
+
+	// remove health checks and probes
+	newPod.Spec.Containers[0].LivenessProbe = nil
+	newPod.Spec.Containers[0].ReadinessProbe = nil
+	newPod.Spec.Containers[0].StartupProbe = nil
+
+	// create the pod and return it
+	return config.Clientset.CoreV1().Pods(existing.ObjectMeta.Namespace).Create(
+		context.Background(),
+		newPod,
+		metav1.CreateOptions{},
+	)
+}

+ 1 - 0
cli/cmd/server.go

@@ -202,6 +202,7 @@ func startLocal(
 		"SQL_LITE=true",
 		"SQL_LITE_PATH=" + sqlLitePath,
 		"STATIC_FILE_PATH=" + staticFilePath,
+		fmt.Sprintf("SERVER_PORT=%d", port),
 		"REDIS_ENABLED=false",
 	}...)
 

+ 10 - 1
cli/cmd/utils/browser.go

@@ -1,6 +1,7 @@
 package utils
 
 import (
+	"fmt"
 	"os/exec"
 	"runtime"
 )
@@ -10,6 +11,8 @@ func OpenBrowser(url string) error {
 	var cmd string
 	var args []string
 
+	fmt.Printf("Attempting to open your browser. If this does not work, please navigate to: %s\n", url)
+
 	switch runtime.GOOS {
 	case "windows":
 		cmd = "cmd"
@@ -17,8 +20,14 @@ func OpenBrowser(url string) error {
 	case "darwin":
 		cmd = "open"
 	default: // "linux", "freebsd", "openbsd", "netbsd"
-		cmd = "xdg-open"
+		if CheckIfWsl() {
+			cmd = "cmd.exe"
+			args = []string{"/c", "start"}
+		} else {
+			cmd = "xdg-open"
+		}
 	}
+
 	args = append(args, url)
 	return exec.Command(cmd, args...).Start()
 }

+ 29 - 0
cli/cmd/utils/wsl.go

@@ -0,0 +1,29 @@
+package utils
+
+import (
+	"os/exec"
+	"regexp"
+	"strings"
+)
+
+// Checks based on uname if the linux environment is under wsl or not
+func CheckIfWsl() bool {
+	out, err := exec.Command("uname", "-a").Output()
+	if err != nil {
+		return false
+	}
+	// On some cases, uname on wsl outputs microsoft capitalized
+	matched, _ := regexp.Match(`microsoft|Microsoft`, out)
+	return matched
+}
+
+// Gets the subsystem host ip
+// If the CLI is running under WSL the localhost url will not work so
+// this function should return the real ip that we should redirect to
+func GetWslHostName() string {
+	out, err := exec.Command("wsl.exe", "hostname", "-I").Output()
+	if err != nil {
+		return "localhost"
+	}
+	return strings.TrimSpace(string(out))
+}

+ 1 - 1
cli/cmd/version.go

@@ -7,7 +7,7 @@ import (
 )
 
 // Version will be linked by an ldflag during build
-var Version string = "v0.2.0"
+var Version string = "v0.8.0"
 
 var versionCmd = &cobra.Command{
 	Use:     "version",

+ 1 - 30
cmd/app/main.go

@@ -7,7 +7,6 @@ import (
 	"net/http"
 	"os"
 
-	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 
 	"github.com/porter-dev/porter/server/api"
@@ -18,7 +17,6 @@ import (
 	"github.com/porter-dev/porter/server/router"
 
 	prov "github.com/porter-dev/porter/internal/kubernetes/provisioner"
-	ints "github.com/porter-dev/porter/internal/models/integrations"
 )
 
 // Version will be linked by an ldflag during build
@@ -45,34 +43,7 @@ func main() {
 		return
 	}
 
-	err = db.AutoMigrate(
-		&models.Project{},
-		&models.Role{},
-		&models.User{},
-		&models.Session{},
-		&models.GitRepo{},
-		&models.Registry{},
-		&models.HelmRepo{},
-		&models.Cluster{},
-		&models.ClusterCandidate{},
-		&models.ClusterResolver{},
-		&models.Infra{},
-		&models.GitActionConfig{},
-		&models.Invite{},
-		&models.AuthCode{},
-		&models.DNSRecord{},
-		&models.PWResetToken{},
-		&ints.KubeIntegration{},
-		&ints.BasicIntegration{},
-		&ints.OIDCIntegration{},
-		&ints.OAuthIntegration{},
-		&ints.GCPIntegration{},
-		&ints.AWSIntegration{},
-		&ints.TokenCache{},
-		&ints.ClusterTokenCache{},
-		&ints.RegTokenCache{},
-		&ints.HelmRepoTokenCache{},
-	)
+	err = gorm.AutoMigrate(db)
 
 	if err != nil {
 		logger.Fatal().Err(err).Msg("")

+ 8 - 6
cmd/migrate/keyrotate/helpers_test.go

@@ -256,12 +256,14 @@ func initOAuthIntegration(tester *tester, t *testing.T) {
 	}
 
 	oauth := &ints.OAuthIntegration{
-		Client:       ints.OAuthGithub,
-		ProjectID:    tester.initProjects[0].ID,
-		UserID:       tester.initUsers[0].ID,
-		ClientID:     []byte("exampleclientid"),
-		AccessToken:  []byte("idtoken"),
-		RefreshToken: []byte("refreshtoken"),
+		SharedOAuthModel: ints.SharedOAuthModel{
+			ClientID:     []byte("exampleclientid"),
+			AccessToken:  []byte("idtoken"),
+			RefreshToken: []byte("refreshtoken"),
+		},
+		Client:    ints.OAuthGithub,
+		ProjectID: tester.initProjects[0].ID,
+		UserID:    tester.initUsers[0].ID,
 	}
 
 	oauth, err := tester.repo.OAuthIntegration().CreateOAuthIntegration(oauth)

+ 4 - 33
cmd/migrate/main.go

@@ -9,9 +9,7 @@ import (
 	adapter "github.com/porter-dev/porter/internal/adapter"
 	"github.com/porter-dev/porter/internal/config"
 	lr "github.com/porter-dev/porter/internal/logger"
-	"github.com/porter-dev/porter/internal/models"
-
-	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/repository/gorm"
 
 	"github.com/joeshaw/envdecode"
 )
@@ -29,38 +27,11 @@ func main() {
 		return
 	}
 
-	err = db.AutoMigrate(
-		&models.Project{},
-		&models.Role{},
-		&models.User{},
-		&models.Release{},
-		&models.Session{},
-		&models.GitRepo{},
-		&models.Registry{},
-		&models.HelmRepo{},
-		&models.Cluster{},
-		&models.ClusterCandidate{},
-		&models.ClusterResolver{},
-		&models.Infra{},
-		&models.GitActionConfig{},
-		&models.Invite{},
-		&models.AuthCode{},
-		&models.DNSRecord{},
-		&models.PWResetToken{},
-		&ints.KubeIntegration{},
-		&ints.BasicIntegration{},
-		&ints.OIDCIntegration{},
-		&ints.OAuthIntegration{},
-		&ints.GCPIntegration{},
-		&ints.AWSIntegration{},
-		&ints.TokenCache{},
-		&ints.ClusterTokenCache{},
-		&ints.RegTokenCache{},
-		&ints.HelmRepoTokenCache{},
-	)
+	err = gorm.AutoMigrate(db)
 
 	if err != nil {
-		panic(err)
+		logger.Fatal().Err(err).Msg("")
+		return
 	}
 
 	if shouldRotate, oldKeyStr, newKeyStr := shouldKeyRotate(); shouldRotate {

+ 4 - 0
dashboard/babel.config.json

@@ -0,0 +1,4 @@
+{
+  "plugins": ["lodash"],
+  "presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"]
+}

File diff suppressed because it is too large
+ 2340 - 25
dashboard/package-lock.json


+ 27 - 10
dashboard/package.json

@@ -4,14 +4,6 @@
   "private": true,
   "dependencies": {
     "@material-ui/core": "^4.11.3",
-    "@types/d3-array": "^2.9.0",
-    "@types/d3-time-format": "^3.0.0",
-    "@types/js-yaml": "^4.0.1",
-    "@types/lodash": "^4.14.165",
-    "@types/markdown-to-jsx": "^6.11.3",
-    "@types/material-ui": "^0.21.8",
-    "@types/qs": "^6.9.5",
-    "@types/random-words": "^1.1.0",
     "@visx/axis": "^1.6.1",
     "@visx/curve": "^1.0.0",
     "@visx/event": "^1.3.0",
@@ -26,6 +18,8 @@
     "anser": "^2.0.1",
     "axios": "^0.20.0",
     "brace": "^0.11.1",
+    "clipboard": "^2.0.8",
+    "core-js": "^3.16.1",
     "d3-array": "^2.11.0",
     "d3-time-format": "^3.0.0",
     "dotenv": "^8.2.0",
@@ -40,22 +34,37 @@
     "react": "^16.13.1",
     "react-ace": "^9.1.3",
     "react-dom": "^16.13.1",
+    "react-error-boundary": "^3.1.3",
     "react-modal": "^3.11.2",
     "react-router-dom": "^5.2.0",
+    "react-table": "^7.7.0",
+    "regenerator-runtime": "^0.13.9",
     "semver": "^7.3.5",
     "styled-components": "^5.2.0"
   },
   "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1",
-    "start": "webpack-dev-server --host 0.0.0.0 --hot --inline --port 8080",
-    "build": "webpack"
+    "start": "webpack-dev-server",
+    "build": "NODE_ENV=\"production\" webpack",
+    "build-and-analyze": "ENABLE_ANALYZER=true NODE_ENV=\"production\" webpack"
   },
   "devDependencies": {
+    "@babel/core": "^7.15.0",
+    "@babel/preset-env": "^7.15.0",
+    "@babel/preset-react": "^7.14.5",
+    "@babel/preset-typescript": "^7.15.0",
+    "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
     "@testing-library/jest-dom": "^4.2.4",
     "@testing-library/react": "^9.3.2",
     "@testing-library/user-event": "^7.1.2",
+    "@types/d3-array": "^2.9.0",
+    "@types/d3-time-format": "^3.0.0",
     "@types/jest": "^24.0.0",
     "@types/js-base64": "^3.0.0",
+    "@types/js-yaml": "^4.0.1",
+    "@types/lodash": "^4.14.165",
+    "@types/markdown-to-jsx": "^6.11.3",
+    "@types/material-ui": "^0.21.8",
     "@types/node": "^12.12.62",
     "@types/qs": "^6.9.5",
     "@types/random-words": "^1.1.0",
@@ -64,16 +73,24 @@
     "@types/react-modal": "^3.10.6",
     "@types/react-router": "^5.1.8",
     "@types/react-router-dom": "^5.1.5",
+    "@types/react-table": "^7.7.1",
     "@types/semver": "^7.3.5",
     "@types/styled-components": "^5.1.3",
+    "@types/terser-webpack-plugin": "^4.2.2",
+    "@types/webpack-dev-server": "^3.11.5",
+    "babel-loader": "^8.2.2",
+    "babel-plugin-lodash": "^3.3.4",
     "file-loader": "^6.1.0",
     "html-webpack-plugin": "^4.5.0",
     "prettier": "2.2.1",
     "qs": "^6.9.4",
+    "react-refresh": "^0.10.0",
     "source-map-loader": "^1.1.0",
+    "terser-webpack-plugin": "^4.2.3",
     "ts-loader": "^8.0.4",
     "typescript": "^4.1.2",
     "webpack": "^4.44.2",
+    "webpack-bundle-analyzer": "^4.4.2",
     "webpack-cli": "^3.3.12",
     "webpack-dev-server": "^3.11.0"
   }

+ 121 - 0
dashboard/react-table.d.ts

@@ -0,0 +1,121 @@
+import {
+  UseColumnOrderInstanceProps,
+  UseColumnOrderState,
+  UseExpandedHooks,
+  UseExpandedInstanceProps,
+  UseExpandedOptions,
+  UseExpandedRowProps,
+  UseExpandedState,
+  UseFiltersColumnOptions,
+  UseFiltersColumnProps,
+  UseFiltersInstanceProps,
+  UseFiltersOptions,
+  UseFiltersState,
+  UseGlobalFiltersColumnOptions,
+  UseGlobalFiltersInstanceProps,
+  UseGlobalFiltersOptions,
+  UseGlobalFiltersState,
+  UseGroupByCellProps,
+  UseGroupByColumnOptions,
+  UseGroupByColumnProps,
+  UseGroupByHooks,
+  UseGroupByInstanceProps,
+  UseGroupByOptions,
+  UseGroupByRowProps,
+  UseGroupByState,
+  UsePaginationInstanceProps,
+  UsePaginationOptions,
+  UsePaginationState,
+  UseResizeColumnsColumnOptions,
+  UseResizeColumnsColumnProps,
+  UseResizeColumnsOptions,
+  UseResizeColumnsState,
+  UseRowSelectHooks,
+  UseRowSelectInstanceProps,
+  UseRowSelectOptions,
+  UseRowSelectRowProps,
+  UseRowSelectState,
+  UseRowStateCellProps,
+  UseRowStateInstanceProps,
+  UseRowStateOptions,
+  UseRowStateRowProps,
+  UseRowStateState,
+  UseSortByColumnOptions,
+  UseSortByColumnProps,
+  UseSortByHooks,
+  UseSortByInstanceProps,
+  UseSortByOptions,
+  UseSortByState,
+} from "react-table";
+
+declare module "react-table" {
+  // take this file as-is, or comment out the sections that don't apply to your plugin configuration
+
+  export interface TableOptions<
+    D extends object = {}
+  > extends UseExpandedOptions<D>,
+      UseFiltersOptions<D>,
+      UseGlobalFiltersOptions<D>,
+      UseGroupByOptions<D>,
+      UsePaginationOptions<D>,
+      UseResizeColumnsOptions<D>,
+      UseRowSelectOptions<D>,
+      UseRowStateOptions<D>,
+      UseSortByOptions<D>,
+      // note that having Record here allows you to add anything to the options, this matches the spirit of the
+      // underlying js library, but might be cleaner if it's replaced by a more specific type that matches your
+      // feature set, this is a safe default.
+      Record<string, any> {}
+
+  export interface Hooks<D extends object = {}>
+    extends UseExpandedHooks<D>,
+      UseGroupByHooks<D>,
+      UseRowSelectHooks<D>,
+      UseSortByHooks<D> {}
+
+  export interface TableInstance<D extends object = {}>
+    extends UseColumnOrderInstanceProps<D>,
+      UseExpandedInstanceProps<D>,
+      UseFiltersInstanceProps<D>,
+      UseGlobalFiltersInstanceProps<D>,
+      UseGroupByInstanceProps<D>,
+      UsePaginationInstanceProps<D>,
+      UseRowSelectInstanceProps<D>,
+      UseRowStateInstanceProps<D>,
+      UseSortByInstanceProps<D> {}
+
+  export interface TableState<D extends object = {}>
+    extends UseColumnOrderState<D>,
+      UseExpandedState<D>,
+      UseFiltersState<D>,
+      UseGlobalFiltersState<D>,
+      UseGroupByState<D>,
+      UsePaginationState<D>,
+      UseResizeColumnsState<D>,
+      UseRowSelectState<D>,
+      UseRowStateState<D>,
+      UseSortByState<D> {}
+
+  export interface ColumnInterface<D extends object = {}>
+    extends UseFiltersColumnOptions<D>,
+      UseGlobalFiltersColumnOptions<D>,
+      UseGroupByColumnOptions<D>,
+      UseResizeColumnsColumnOptions<D>,
+      UseSortByColumnOptions<D> {}
+
+  export interface ColumnInstance<D extends object = {}>
+    extends UseFiltersColumnProps<D>,
+      UseGroupByColumnProps<D>,
+      UseResizeColumnsColumnProps<D>,
+      UseSortByColumnProps<D> {}
+
+  export interface Cell<D extends object = {}, V = any>
+    extends UseGroupByCellProps<D>,
+      UseRowStateCellProps<D> {}
+
+  export interface Row<D extends object = {}>
+    extends UseExpandedRowProps<D>,
+      UseGroupByRowProps<D>,
+      UseRowSelectRowProps<D>,
+      UseRowStateRowProps<D> {}
+}

+ 44 - 10
dashboard/src/App.tsx

@@ -1,18 +1,52 @@
 import React, { Component } from "react";
+import { BrowserRouter } from "react-router-dom";
+import PorterErrorBoundary from "shared/PorterErrorBoundary";
+import styled, { createGlobalStyle } from "styled-components";
 
-import { ContextProvider } from "./shared/Context";
-import Main from "./main/Main";
+import MainWrapper from "./main/MainWrapper";
 
-type PropsType = {};
-
-type StateType = {};
-
-export default class App extends Component<PropsType, StateType> {
+export default class App extends Component {
   render() {
     return (
-      <ContextProvider>
-        <Main />
-      </ContextProvider>
+      <StyledMain>
+        <GlobalStyle />
+        <PorterErrorBoundary errorBoundaryLocation="globalErrorBoundary">
+          <BrowserRouter>
+            <MainWrapper />
+          </BrowserRouter>
+        </PorterErrorBoundary>
+      </StyledMain>
     );
   }
 }
+
+const GlobalStyle = createGlobalStyle`
+  * {
+    box-sizing: border-box;
+    font-family: 'Work Sans', sans-serif;
+  }
+  
+  body {
+    background: #202227;
+    overscroll-behavior-x: none;
+  }
+
+  a {
+    color: #949eff;
+    text-decoration: none;
+  }
+
+  img {
+    max-width: 100%;
+  }
+`;
+
+const StyledMain = styled.div`
+  height: 100vh;
+  width: 100vw;
+  position: fixed;
+  top: 0;
+  left: 0;
+  background: #202227;
+  color: white;
+`;

BIN
dashboard/src/assets/back_arrow.png


BIN
dashboard/src/assets/node.png


BIN
dashboard/src/assets/trash.png


+ 57 - 0
dashboard/src/components/Button.tsx

@@ -0,0 +1,57 @@
+import React from "react";
+import styled from "styled-components";
+
+interface Props {
+  disabled?: boolean;
+  children: React.ReactNode;
+  onClick: () => void;
+}
+
+const Button: React.FC<Props> = ({ children, disabled, onClick }) => {
+  return (
+    <ButtonWrapper disabled={disabled} onClick={onClick}>
+      {children}
+    </ButtonWrapper>
+  );
+};
+
+export default Button;
+
+const ButtonWrapper = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  font-weight: 500;
+  padding: 10px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;

+ 110 - 0
dashboard/src/components/CopyToClipboard.tsx

@@ -0,0 +1,110 @@
+// import ClipboardJS from "clipboard";
+import ClipboardJS from "clipboard";
+import React, { Component, RefObject } from "react";
+import Tooltip from "@material-ui/core/Tooltip";
+import styled from "styled-components";
+
+type PropsType = {
+  text: string;
+  onSuccess?: (e: ClipboardJS.Event) => void;
+  onError?: (e: ClipboardJS.Event) => void;
+  wrapperProps?: any;
+  as?: any;
+};
+
+type StateType = {
+  clipboard: ClipboardJS | undefined;
+  success: boolean;
+};
+
+/**
+ * Dynamic component to enable copy to clipboard.
+ *  By default, it will be displayed as a span, when the user clicks over the span
+ *  it will copy the text provided
+ *
+ * Examples of usage:
+ * <CopyToClipboard
+ *   as={MyCustomComponent}
+ *   text={`some usefull text ${var}`}
+ *   onSuccess={(e) => console.log("Success event:", e)}
+ *   onError={(e) => console.log("Error event:", e)}
+ * >
+ *   Some content
+ * </CopyToClipboard>
+ */
+export default class CopyToClipboard extends Component<PropsType, StateType> {
+  triggerRef: RefObject<HTMLSpanElement>;
+
+  state: StateType = {
+    clipboard: undefined,
+    success: false,
+  };
+
+  constructor(props: PropsType) {
+    super(props);
+    this.triggerRef = React.createRef();
+  }
+
+  componentDidMount() {
+    const trigger = this.triggerRef.current;
+    if (!trigger) {
+      console.error("Couldn't mount clipboardjs on wrapper component");
+      return;
+    }
+    const clipboard = new ClipboardJS(trigger, {
+      text: () => {
+        return this.props.text;
+      },
+    });
+
+    clipboard.on("success", (e) => {
+      this.setState({ success: true });
+      this.props.onSuccess && this.props.onSuccess(e);
+      setTimeout(() => {
+        this.setState({ success: false });
+      }, 2000);
+    });
+
+    this.props.onError && clipboard.on("error", this.props.onError);
+
+    this.setState({ clipboard });
+  }
+
+  componentWillUnmount() {
+    if (this.state.clipboard && this.state.clipboard.destroy) {
+      this.state.clipboard.destroy();
+    }
+  }
+
+  render() {
+    return (
+      <Tooltip
+        title={
+          <div
+            style={{
+              fontFamily: "Work Sans, sans-serif",
+              fontSize: "12px",
+              fontWeight: "normal",
+              padding: "5px 6px",
+            }}
+          >
+            Copied to clipboard
+          </div>
+        }
+        open={this.state.success}
+        placement="bottom"
+        arrow
+      >
+        <DynamicSpanComponent
+          as={this.props.as || "span"}
+          ref={this.triggerRef}
+          {...(this.props.wrapperProps || {})}
+        >
+          {this.props.children}
+        </DynamicSpanComponent>
+      </Tooltip>
+    );
+  }
+}
+
+const DynamicSpanComponent = styled.span``;

+ 1 - 1
dashboard/src/components/ExpandableResource.tsx

@@ -73,7 +73,7 @@ const Status = styled.div`
 `;
 
 const StatusSection = styled.div`
-  border-radius: 5px;
+  border-radius: 8px;
   background: #ffffff11;
   font-size: 13px;
   padding: 20px 20px 25px;

+ 0 - 0
dashboard/src/components/Helper.tsx


+ 12 - 4
dashboard/src/components/Loading.tsx

@@ -4,6 +4,8 @@ import loading from "assets/loading.gif";
 
 type PropsType = {
   offset?: string;
+  width?: string;
+  height?: string;
 };
 
 type StateType = {};
@@ -13,7 +15,11 @@ export default class Loading extends Component<PropsType, StateType> {
 
   render() {
     return (
-      <StyledLoading offset={this.props.offset}>
+      <StyledLoading
+        offset={this.props.offset}
+        width={this.props.width || "100%"}
+        height={this.props.height || "100%"}
+      >
         <Spinner src={loading} />
       </StyledLoading>
     );
@@ -24,11 +30,13 @@ const Spinner = styled.img`
   width: 20px;
 `;
 
+type StyleLoadingProps = PropsType;
+
 const StyledLoading = styled.div`
-  width: 100%;
-  height: 100%;
+  width: ${(props: StyleLoadingProps) => props.width};
+  height: ${(props: StyleLoadingProps) => props.height};
   display: flex;
   align-items: center;
   justify-content: center;
-  margin-top: ${(props: { offset?: string }) => props.offset};
+  margin-top: ${(props: StyleLoadingProps) => props.offset};
 `;

+ 189 - 0
dashboard/src/components/PageNotFound.tsx

@@ -0,0 +1,189 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import { RouteComponentProps, withRouter } from "react-router";
+
+import { pushFiltered } from "shared/routing";
+
+type PropsType = RouteComponentProps & {};
+
+type StateType = {};
+
+class PageNotFound extends Component<PropsType, StateType> {
+  state = {};
+
+  render() {
+    let { pathname } = this.props.location;
+    let params = this.props.match.params as any;
+    let { baseRoute } = params;
+    if (baseRoute === "applications") {
+      return (
+        <StyledPageNotFound>
+          <Mega>
+            404
+            <Inside>Application Not Found</Inside>
+          </Mega>
+          <Flex>
+            <BackButton
+              width="140px"
+              onClick={() =>
+                pushFiltered(this.props, "/applications", ["project_id"])
+              }
+            >
+              <i className="material-icons">arrow_back</i>
+              Applications
+            </BackButton>
+            {pathname && (
+              <>
+                <Splitter>|</Splitter>
+                <Helper>Could not find "{pathname}"</Helper>
+              </>
+            )}
+          </Flex>
+        </StyledPageNotFound>
+      );
+    } else if (baseRoute === "jobs") {
+      return (
+        <StyledPageNotFound>
+          <Mega>
+            404
+            <Inside>Job Not Found</Inside>
+          </Mega>
+          <Flex>
+            <BackButton
+              width="90px"
+              onClick={() => pushFiltered(this.props, "/jobs", ["project_id"])}
+            >
+              <i className="material-icons">arrow_back</i>
+              Jobs
+            </BackButton>
+            {pathname && (
+              <>
+                <Splitter>|</Splitter>
+                <Helper>Could not find "{pathname}"</Helper>
+              </>
+            )}
+          </Flex>
+        </StyledPageNotFound>
+      );
+    }
+    return (
+      <StyledPageNotFound>
+        <Mega>
+          404
+          <Inside>Page Not Found</Inside>
+        </Mega>
+        <Flex>
+          <BackButton
+            width="145px"
+            onClick={() =>
+              pushFiltered(this.props, "/dashboard", ["project_id"])
+            }
+          >
+            <i className="material-icons">home</i>
+            Return Home
+          </BackButton>
+          {pathname && (
+            <>
+              <Splitter>|</Splitter>
+              <Helper>Could not find "{pathname}"</Helper>
+            </>
+          )}
+        </Flex>
+      </StyledPageNotFound>
+    );
+  }
+}
+
+export default withRouter(PageNotFound);
+
+const Splitter = styled.div`
+  margin: 0 20px;
+  font-size: 27px;
+  font-weight: 200;
+  color: #ffffff15;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Helper = styled.div`
+  font-size: 15px;
+  max-width: 550px;
+  margin-right: -50px;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: pointer;
+  font-size: 13px;
+  height: 35px;
+  padding: 5px 16px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+    margin-left: -2px;
+  }
+`;
+
+const StyledPageNotFound = styled.div`
+  font-family: "Work Sans", sans-serif;
+  color: #6f6f6f;
+  font-size: 16px;
+  user-select: none;
+  margin-top: -80px;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Mega = styled.div`
+  font-size: 200px;
+  color: #ffffff06;
+  position: relative;
+  font-weight: bold;
+  text-align: center;
+
+  > i {
+    font-size: 23px;
+    margin-right: 12px;
+  }
+`;
+
+const Inside = styled.div`
+  position: absolute;
+  color: #6f6f6f;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: 400;
+  font-size: 20px;
+
+  > i {
+    font-size: 23px;
+    margin-right: 12px;
+  }
+`;

+ 4 - 1
dashboard/src/components/RadioSelector.tsx

@@ -17,7 +17,10 @@ export default class RadioSelector extends Component<PropsType, StateType> {
           (option: { label: string; value: string }, i: number) => {
             let selected = option.value === this.props.selected;
             return (
-              <RadioRow onClick={() => this.props.setSelected(option.value)}>
+              <RadioRow
+                key={option.value}
+                onClick={() => this.props.setSelected(option.value)}
+              >
                 <Indicator selected={selected}>
                   {selected && <Circle />}
                 </Indicator>

+ 1 - 5
dashboard/src/components/ResourceTab.tsx

@@ -146,11 +146,7 @@ const StyledResourceTab = styled.div`
   border-bottom-left-radius: ${(props: {
     isLast: boolean;
     roundAllCorners: boolean;
-  }) => (props.isLast ? "5px" : "")};
-  border-bottom-right-radius: ${(props: {
-    isLast: boolean;
-    roundAllCorners: boolean;
-  }) => (props.roundAllCorners && props.isLast ? "5px" : "")};
+  }) => (props.isLast ? "10px" : "")};
 `;
 
 const Tooltip = styled.div`

+ 112 - 49
dashboard/src/components/SaveButton.tsx

@@ -2,70 +2,85 @@ import React, { Component } from "react";
 import styled from "styled-components";
 import loading from "assets/loading.gif";
 
-type PropsType = {
-  text: string;
+type Props = {
+  text?: string;
   onClick: () => void;
   disabled?: boolean;
   status?: string | null;
   color?: string;
+  rounded?: boolean;
   helper?: string | null;
+  saveText?: string | null;
 
   // Makes flush with corner if not within a modal
   makeFlush?: boolean;
+  clearPosition?: boolean;
+  statusPosition?: "right" | "left";
 };
 
-type StateType = {};
-
-export default class SaveButton extends Component<PropsType, StateType> {
-  renderStatus = () => {
-    if (this.props.status) {
-      if (this.props.status === "successful") {
+const SaveButton: React.FC<Props> = (props) => {
+  const renderStatus = () => {
+    if (props.status) {
+      if (props.status === "successful") {
         return (
-          <StatusWrapper successful={true}>
-            <i className="material-icons">done</i> Successfully updated
+          <StatusWrapper position={props.statusPosition} successful={true}>
+            <i className="material-icons">done</i>
+            <StatusTextWrapper>Successfully updated</StatusTextWrapper>
           </StatusWrapper>
         );
-      } else if (this.props.status === "loading") {
+      } else if (props.status === "loading") {
         return (
-          <StatusWrapper successful={false}>
-            <LoadingGif src={loading} /> Updating . . .
+          <StatusWrapper position={props.statusPosition} successful={false}>
+            <LoadingGif src={loading} />
+            <StatusTextWrapper>
+              {props.saveText || "Updating . . ."}
+            </StatusTextWrapper>
           </StatusWrapper>
         );
-      } else if (this.props.status === "error") {
+      } else if (props.status === "error") {
         return (
-          <StatusWrapper successful={false}>
-            <i className="material-icons">error_outline</i> Could not update
+          <StatusWrapper position={props.statusPosition} successful={false}>
+            <i className="material-icons">error_outline</i>
+            <StatusTextWrapper>Could not update</StatusTextWrapper>
           </StatusWrapper>
         );
       } else {
         return (
-          <StatusWrapper successful={false}>
-            <i className="material-icons">error_outline</i> {this.props.status}
+          <StatusWrapper position={props.statusPosition} successful={false}>
+            <i className="material-icons">error_outline</i>
+            <StatusTextWrapper>{props.status}</StatusTextWrapper>
           </StatusWrapper>
         );
       }
-    } else if (this.props.helper) {
+    } else if (props.helper) {
       return (
-        <StatusWrapper successful={true}>{this.props.helper}</StatusWrapper>
+        <StatusWrapper position={props.statusPosition} successful={true}>
+          {props.helper}
+        </StatusWrapper>
       );
     }
   };
 
-  render() {
-    return (
-      <ButtonWrapper makeFlush={this.props.makeFlush}>
-        {this.renderStatus()}
-        <Button
-          disabled={this.props.disabled}
-          onClick={this.props.onClick}
-          color={this.props.color || "#616FEEcc"}
-        >
-          {this.props.text}
-        </Button>
-      </ButtonWrapper>
-    );
-  }
-}
+  return (
+    <ButtonWrapper
+      makeFlush={props.makeFlush}
+      clearPosition={props.clearPosition}
+    >
+      {props.statusPosition !== "right" && <div>{renderStatus()}</div>}
+      <Button
+        rounded={props.rounded}
+        disabled={props.disabled}
+        onClick={props.onClick}
+        color={props.color || "#616FEEcc"}
+      >
+        {props.children || props.text}
+      </Button>
+      {props.statusPosition === "right" && <div>{renderStatus()}</div>}
+    </ButtonWrapper>
+  );
+};
+
+export default SaveButton;
 
 const LoadingGif = styled.img`
   width: 15px;
@@ -74,28 +89,43 @@ const LoadingGif = styled.img`
   margin-bottom: 0px;
 `;
 
-const StatusWrapper = styled.div`
+const StatusTextWrapper = styled.p`
+  display: -webkit-box;
+  line-clamp: 2;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  line-height: 19px;
+  margin: 0;
+`;
+
+// TODO: prevent status re-render on form refresh to allow animation
+// animation: statusFloatIn 0.5s;
+const StatusWrapper = styled.div<{
+  successful: boolean;
+  position: "right" | "left";
+}>`
   display: flex;
   align-items: center;
   font-family: "Work Sans", sans-serif;
   font-size: 13px;
   color: #ffffff55;
-  margin-right: 25px;
-  padding: 0 10px;
-
+  ${(props) => {
+    if (props.position !== "right") {
+      return "margin-right: 25px;";
+    }
+    return "margin-left: 25px;";
+  }}
   max-width: 500px;
-  white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
 
   > i {
     font-size: 18px;
     margin-right: 10px;
-    color: ${(props: { successful: boolean }) =>
-      props.successful ? "#4797ff" : "#fcba03"};
+    float: left;
+    color: ${(props) => (props.successful ? "#4797ff" : "#fcba03")};
   }
 
-  animation: statusFloatIn 0.5s;
   animation-fill-mode: forwards;
 
   @keyframes statusFloatIn {
@@ -111,33 +141,52 @@ const StatusWrapper = styled.div`
 `;
 
 const ButtonWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  position: absolute;
-  ${(props: { makeFlush: boolean }) => {
+  ${(props: { makeFlush: boolean; clearPosition?: boolean }) => {
+    const baseStyles = `
+      display: flex;
+      align-items: center;
+    `;
+
+    if (props.clearPosition) {
+      return baseStyles;
+    }
+
     if (!props.makeFlush) {
       return `
+        ${baseStyles}
+        position: absolute;
+        justify-content: flex-end;
         bottom: 25px;
         right: 27px;
+        left: 27px;
       `;
     }
     return `
+      ${baseStyles}
+      position: absolute;
+      justify-content: flex-end;
       bottom: 5px;
       right: 0;
     `;
   }}
 `;
 
-const Button = styled.button`
+const Button = styled.button<{
+  disabled: boolean;
+  color: string;
+  rounded: boolean;
+}>`
   height: 35px;
   font-size: 13px;
   font-weight: 500;
   font-family: "Work Sans", sans-serif;
   color: white;
+  display: flex;
+  align-items: center;
   padding: 6px 20px 7px 20px;
   text-align: left;
   border: 0;
-  border-radius: 5px;
+  border-radius: ${(props) => (props.rounded ? "100px" : "5px")};
   background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
   box-shadow: ${(props) =>
     !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
@@ -149,4 +198,18 @@ const Button = styled.button`
   :hover {
     filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
   }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 14px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 10px;
+    margin-left: -5px;
+    justify-content: center;
+  }
 `;

+ 93 - 0
dashboard/src/components/SearchBar.tsx

@@ -0,0 +1,93 @@
+import React, { useState } from "react";
+import Button from "./Button";
+import styled from "styled-components";
+
+interface Props {
+  setSearchFilter: (x: string) => void;
+  disabled: boolean;
+  prompt?: string;
+}
+
+const SearchBar: React.FC<Props> = ({ setSearchFilter, disabled, prompt }) => {
+  const [searchInput, setSearchInput] = useState("");
+
+  return (
+    <SearchRowWrapper>
+      <SearchBarWrapper>
+        <i className="material-icons">search</i>
+        <SearchInput
+          value={searchInput}
+          onChange={(e: any) => {
+            setSearchInput(e.target.value);
+          }}
+          onKeyPress={({ key }) => {
+            if (key === "Enter") {
+              setSearchFilter(searchInput);
+            }
+          }}
+          placeholder={prompt}
+        />
+      </SearchBarWrapper>
+      <ButtonWrapper disabled={disabled}>
+        <Button
+          onClick={() => setSearchFilter(searchInput)}
+          disabled={disabled}
+        >
+          Search
+        </Button>
+      </ButtonWrapper>
+    </SearchRowWrapper>
+  );
+};
+
+export default SearchBar;
+
+const SearchRow = styled.div`
+  display: flex;
+  align-items: center;
+  height: 40px;
+  background: #ffffff11;
+  border-bottom: 1px solid #606166;
+  margin-bottom: 10px;
+`;
+
+const SearchRowWrapper = styled(SearchRow)`
+  border-bottom: 0;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+`;
+
+const ButtonWrapper = styled.div`
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+  height: 40px;
+  display: flex;
+  align-items: center;
+`;
+
+const SearchBarWrapper = styled.div`
+  display: flex;
+  flex: 1;
+
+  > i {
+    color: #aaaabb;
+    padding-top: 1px;
+    margin-left: 13px;
+    font-size: 18px;
+    margin-right: 8px;
+  }
+`;
+
+const SearchInput = styled.input`
+  outline: none;
+  border: none;
+  font-size: 13px;
+  background: none;
+  width: 100%;
+  color: white;
+  height: 20px;
+`;

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

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

+ 86 - 0
dashboard/src/components/StatusSection.tsx

@@ -0,0 +1,86 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import loading from "assets/loading.gif";
+
+type PropsType = {
+  status: string;
+};
+
+type StateType = {};
+
+// TODO: replace StatusIndicator
+export default class StatusSection extends Component<PropsType, StateType> {
+  renderIndicator = (status: string) => {
+    if (status == "loading") {
+      return (
+        <div>
+          <Spinner src={loading} />
+        </div>
+      );
+    }
+
+    return (
+      <div>
+        <StatusColor status={status} />
+      </div>
+    );
+  };
+
+  render() {
+    return (
+      <Status>
+        {this.renderIndicator(this.props.status)}
+        {this.props.status}
+      </Status>
+    );
+  }
+}
+
+const Spinner = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 15px;
+  margin-bottom: -3px;
+`;
+
+const StatusColor = styled.div`
+  margin-top: 1px;
+  max-width: 8px;
+  max-height: 8px;
+  min-width: 8px;
+  min-height: 8px;
+  width: 8px;
+  height: 8px;
+  background: ${(props: { status: string }) =>
+    props.status === "deployed" || props.status === "healthy"
+      ? "#4797ff"
+      : props.status === "failed"
+      ? "#ed5f85"
+      : props.status === "completed"
+      ? "#00d12a"
+      : "#f5cb42"};
+  border-radius: 4px;
+  margin-left: 3px;
+  margin-right: 16px;
+`;
+
+const Status = styled.div`
+  display: flex;
+  height: 20px;
+  font-size: 13px;
+  flex-direction: row;
+  text-transform: capitalize;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  color: #aaaabb;
+  animation: fadeIn 0.5s;
+
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;

+ 28 - 40
dashboard/src/components/TabRegion.tsx

@@ -4,13 +4,19 @@ import styled from "styled-components";
 import TabSelector from "./TabSelector";
 import Loading from "./Loading";
 
+export interface TabOption {
+  label: string;
+  value: string;
+}
+
 type PropsType = {
-  options: { label: string; value: string }[];
+  options: TabOption[];
   currentTab: string;
   setCurrentTab: (x: string) => void;
   defaultTab?: string;
   addendum?: any;
   color?: string | null;
+  suppressAnimation?: boolean;
 };
 
 type StateType = {};
@@ -33,49 +39,29 @@ export default class TabRegion extends Component<PropsType, StateType> {
     }
   }
 
-  renderContents = () => {
-    if (!this.props.currentTab) {
-      return <Loading />;
-    }
-
+  render() {
     return (
-      <Div>
-        <TabSelector
-          options={this.props.options}
-          color={this.props.color}
-          currentTab={this.props.currentTab}
-          setCurrentTab={(x: string) => this.props.setCurrentTab(x)}
-          addendum={this.props.addendum}
-        />
-        <Gap />
-        <TabContents>{this.props.children}</TabContents>
-      </Div>
+      <StyledTabRegion suppressAnimation={this.props.suppressAnimation}>
+        {!this.props.currentTab ? (
+          <Loading />
+        ) : (
+          <>
+            <TabSelector
+              options={this.props.options}
+              color={this.props.color}
+              currentTab={this.props.currentTab}
+              setCurrentTab={(x: string) => this.props.setCurrentTab(x)}
+              addendum={this.props.addendum}
+            />
+            <Gap />
+            <TabContents>{this.props.children}</TabContents>
+          </>
+        )}
+      </StyledTabRegion>
     );
-  };
-
-  render() {
-    return <StyledTabRegion>{this.renderContents()}</StyledTabRegion>;
   }
 }
 
-const Placeholder = styled.div`
-  width: 100%;
-  height: 200px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: #ffffff11;
-  border-radius: 5px;
-  color: #ffffff44;
-  font-size: 13px;
-`;
-
-const Div = styled.div`
-  width: 100%;
-  height: 100%;
-  animation: fadeIn 0.25s 0s;
-`;
-
 const TabContents = styled.div`
   height: calc(100% - 65px);
 `;
@@ -86,9 +72,11 @@ const Gap = styled.div`
   height: 30px;
 `;
 
-const StyledTabRegion = styled.div`
+const StyledTabRegion = styled.div<{ suppressAnimation: boolean }>`
   width: 100%;
   height: 100%;
+  animation: ${(props) => (props.suppressAnimation ? "" : "fadeIn 0.25s 0s")};
   position: relative;
   overflow-y: auto;
+  overflow: visible;
 `;

+ 221 - 0
dashboard/src/components/Table.tsx

@@ -0,0 +1,221 @@
+import React from "react";
+import styled from "styled-components";
+import { Column, Row, useGlobalFilter, useTable } from "react-table";
+import Loading from "components/Loading";
+
+const GlobalFilter: React.FunctionComponent<any> = ({ setGlobalFilter }) => {
+  const [value, setValue] = React.useState("");
+  const onChange = (value: string) => {
+    setValue(value);
+    setGlobalFilter(value || undefined);
+  };
+
+  return (
+    <SearchRow>
+      <i className="material-icons">search</i>
+      <SearchInput
+        value={value}
+        onChange={(e: any) => {
+          onChange(e.target.value);
+        }}
+        placeholder="Search"
+      />
+    </SearchRow>
+  );
+};
+
+export type TableProps = {
+  columns: Column<any>[];
+  data: any[];
+  onRowClick?: (row: Row) => void;
+  isLoading: boolean;
+  disableGlobalFilter?: boolean;
+  disableHover?: boolean;
+};
+
+const Table: React.FC<TableProps> = ({
+  columns: columnsData,
+  data,
+  onRowClick,
+  isLoading,
+  disableGlobalFilter = false,
+  disableHover,
+}) => {
+  const {
+    getTableProps,
+    getTableBodyProps,
+    rows,
+    setGlobalFilter,
+    prepareRow,
+    headerGroups,
+    visibleColumns,
+  } = useTable(
+    {
+      columns: columnsData,
+      data,
+    },
+    useGlobalFilter
+  );
+
+  const renderRows = () => {
+    if (isLoading) {
+      return (
+        <StyledTr disableHover={true} selected={false}>
+          <StyledTd colSpan={visibleColumns.length}>
+            <Loading />
+          </StyledTd>
+        </StyledTr>
+      );
+    }
+
+    if (!rows.length) {
+      return (
+        <StyledTr disableHover={true} selected={false}>
+          <StyledTd colSpan={visibleColumns.length}>No data available</StyledTd>
+        </StyledTr>
+      );
+    }
+    return (
+      <>
+        {rows.map((row) => {
+          prepareRow(row);
+
+          return (
+            <StyledTr
+              disableHover={disableHover}
+              {...row.getRowProps()}
+              enablePointer={!!onRowClick}
+              onClick={() => onRowClick && onRowClick(row)}
+              selected={false}
+            >
+              {row.cells.map((cell) => (
+                <StyledTd {...cell.getCellProps()}>
+                  {cell.render("Cell")}
+                </StyledTd>
+              ))}
+            </StyledTr>
+          );
+        })}
+      </>
+    );
+  };
+
+  return (
+    <TableWrapper>
+      {!disableGlobalFilter && (
+        <GlobalFilter setGlobalFilter={setGlobalFilter} />
+      )}
+      <StyledTable {...getTableProps()}>
+        <StyledTHead>
+          {headerGroups.map((headerGroup) => (
+            <StyledTr
+              {...headerGroup.getHeaderGroupProps()}
+              disableHover={true}
+            >
+              {headerGroup.headers.map((column) => (
+                <StyledTh {...column.getHeaderProps()}>
+                  {column.render("Header")}
+                </StyledTh>
+              ))}
+            </StyledTr>
+          ))}
+        </StyledTHead>
+        <tbody {...getTableBodyProps()}>{renderRows()}</tbody>
+      </StyledTable>
+    </TableWrapper>
+  );
+};
+
+export default Table;
+
+const TableWrapper = styled.div`
+  padding-bottom: 20px;
+`;
+
+type StyledTrProps = {
+  enablePointer?: boolean;
+  disableHover?: boolean;
+  selected?: boolean;
+};
+
+export const StyledTr = styled.tr`
+  line-height: 2.2em;
+  background: ${(props: StyledTrProps) => (props.selected ? "#ffffff11" : "")};
+  :hover {
+    background: ${(props: StyledTrProps) =>
+      props.disableHover ? "" : "#ffffff22"};
+  }
+  cursor: ${(props: StyledTrProps) =>
+    props.enablePointer ? "pointer" : "unset"};
+`;
+
+export const StyledTd = styled.td`
+  font-size: 13px;
+  color: #ffffff;
+  :first-child {
+    padding-left: 10px;
+  }
+  :last-child {
+    padding-right: 10px;
+  }
+  user-select: text;
+`;
+
+export const StyledTHead = styled.thead`
+  width: 100%;
+  border-top: 1px solid #aaaabb22;
+  border-bottom: 1px solid #aaaabb22;
+`;
+
+export const StyledTh = styled.th`
+  text-align: left;
+  font-size: 13px;
+  font-weight: 500;
+  color: #aaaabb;
+  :first-child {
+    padding-left: 10px;
+  }
+  :last-child {
+    padding-right: 10px;
+  }
+`;
+
+export const StyledTable = styled.table`
+  width: 100%;
+  min-width: 500px;
+  border-collapse: collapse;
+`;
+
+const SearchInput = styled.input`
+  outline: none;
+  border: none;
+  font-size: 13px;
+  background: none;
+  width: 100%;
+  color: white;
+  padding: 0;
+  height: 20px;
+`;
+
+const SearchRow = styled.div`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  color: #ffffff55;
+  border-radius: 4px;
+  user-select: none;
+  align-items: center;
+  padding: 10px 0px;
+  min-width: 300px;
+  max-width: min-content;
+  background: #ffffff11;
+  margin-bottom: 15px;
+  margin-top: 0px;
+  i {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+    font-size: 20px;
+  }
+`;

+ 96 - 0
dashboard/src/components/TitleSection.tsx

@@ -0,0 +1,96 @@
+import React from "react";
+import styled from "styled-components";
+
+interface Props {
+  children: React.ReactNode;
+  icon?: any;
+  iconWidth?: string;
+  capitalize?: boolean;
+  handleNavBack?: () => void;
+}
+
+const TitleSection: React.FC<Props> = ({
+  children,
+  icon,
+  iconWidth,
+  capitalize,
+  handleNavBack,
+}) => {
+  return (
+    <StyledTitleSection>
+      {handleNavBack && (
+        <BackButton>
+          <i className="material-icons" onClick={handleNavBack}>
+            keyboard_backspace
+          </i>
+        </BackButton>
+      )}
+      {icon && <Icon width={iconWidth} src={icon} />}
+      <StyledTitle capitalize={capitalize}>{children}</StyledTitle>
+    </StyledTitleSection>
+  );
+};
+
+export default TitleSection;
+
+const BackButton = styled.div`
+  > i {
+    cursor: pointer;
+    font-size 24px;
+    color: #969Fbbaa;
+    margin-right: 10px;
+    padding: 3px;
+    margin-left: 0px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+`;
+
+const StyledTitleSection = styled.div`
+  margin-bottom: 15px;
+  display: flex;
+  align-items: center;
+`;
+
+const Icon = styled.img<{ width: string }>`
+  width: ${(props) => props.width || "28px"};
+  margin-right: 16px;
+`;
+
+const StyledTitle = styled.div<{ capitalize: boolean }>`
+  font-size: 24px;
+  font-weight: 600;
+  user-select: text;
+  text-transform: ${(props) => (props.capitalize ? "capitalize" : "")};
+  display: flex;
+  align-items: center;
+
+  > i {
+    margin-left: 10px;
+    cursor: pointer;
+    font-size: 18px;
+    color: #858faaaa;
+    padding: 5px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+    margin-bottom: -3px;
+  }
+
+  > a {
+    > i {
+      display: flex;
+      align-items: center;
+      margin-bottom: -2px;
+      font-size: 18px;
+      margin-left: 15px;
+      color: #858faaaa;
+      :hover {
+        color: #aaaabb;
+      }
+    }
+  }
+`;

+ 59 - 0
dashboard/src/components/UnauthorizedPage.tsx

@@ -0,0 +1,59 @@
+import React from "react";
+import styled from "styled-components";
+
+const UnauthorizedPage: React.FunctionComponent = () => (
+  <StyledUnauthorizedPage>
+    <Mega>
+      401
+      <Inside>You're not authorized to access this page</Inside>
+    </Mega>
+  </StyledUnauthorizedPage>
+);
+
+export default UnauthorizedPage;
+
+const StyledUnauthorizedPage = styled.div`
+  font-family: "Work Sans", sans-serif;
+  color: #6f6f6f;
+  font-size: 16px;
+  user-select: none;
+  padding-bottom: 20px;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Mega = styled.div`
+  font-size: 200px;
+  color: #ffffff06;
+  position: relative;
+  font-weight: bold;
+  text-align: center;
+
+  > i {
+    font-size: 23px;
+    margin-right: 12px;
+  }
+`;
+
+const Inside = styled.div`
+  position: absolute;
+  color: #6f6f6f;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: 400;
+  font-size: 20px;
+
+  > i {
+    font-size: 23px;
+    margin-right: 12px;
+  }
+`;

+ 117 - 0
dashboard/src/components/UnexpectedErrorPage.tsx

@@ -0,0 +1,117 @@
+import React from "react";
+import styled from "styled-components";
+
+const UnexpectedErrorPage: React.FC = ({ error, resetErrorBoundary }: any) => (
+  <>
+    <StyledPageNotFound>
+      <Mega>
+        Unknwown
+        <Inside>Unknown Error</Inside>
+      </Mega>
+      <Flex>
+        <BackButton width="140px" onClick={() => resetErrorBoundary(error)}>
+          <i className="material-icons">arrow_back</i>
+          Reload page
+        </BackButton>
+        <Splitter>|</Splitter>
+        <Helper>
+          Sorry for the inconvinience! The Porter team has been notified
+        </Helper>
+      </Flex>
+    </StyledPageNotFound>
+  </>
+);
+
+export default UnexpectedErrorPage;
+
+const Splitter = styled.div`
+  margin: 0 20px;
+  font-size: 27px;
+  font-weight: 200;
+  color: #ffffff15;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Helper = styled.div`
+  font-size: 15px;
+  max-width: 550px;
+  margin-right: -50px;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: pointer;
+  font-size: 13px;
+  height: 35px;
+  padding: 5px 16px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+    margin-left: -2px;
+  }
+`;
+
+const StyledPageNotFound = styled.div`
+  font-family: "Work Sans", sans-serif;
+  color: #6f6f6f;
+  font-size: 16px;
+  user-select: none;
+  margin-top: -80px;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Mega = styled.div`
+  font-size: 200px;
+  color: #ffffff06;
+  position: relative;
+  font-weight: bold;
+  text-align: center;
+
+  > i {
+    font-size: 23px;
+    margin-right: 12px;
+  }
+`;
+
+const Inside = styled.div`
+  position: absolute;
+  color: #6f6f6f;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: 400;
+  font-size: 20px;
+
+  > i {
+    font-size: 23px;
+    margin-right: 12px;
+  }
+`;

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

@@ -52,7 +52,7 @@ class YamlEditor extends Component<PropsType, StateType> {
             editorProps={{ $blockScrolling: true }}
             height={this.props.height}
             width="100%"
-            style={{ borderRadius: "5px" }}
+            style={{ borderRadius: "10px" }}
             showPrintMargin={false}
             showGutter={true}
             highlightActiveLine={true}
@@ -67,9 +67,10 @@ class YamlEditor extends Component<PropsType, StateType> {
 export default YamlEditor;
 
 const Editor = styled.form`
-  border-radius: ${(props: { border: boolean }) => (props.border ? "5px" : "")};
+  border-radius: ${(props: { border: boolean }) =>
+    props.border ? "10px" : ""};
   border: ${(props: { border: boolean }) =>
-    props.border ? "1px solid #ffffff22" : ""};
+    props.border ? "1px solid #ffffff33" : ""};
 `;
 
 const Holder = styled.div`

+ 0 - 0
dashboard/src/components/values-form/CheckboxList.tsx → dashboard/src/components/form-components/CheckboxList.tsx


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


+ 43 - 0
dashboard/src/components/form-components/Heading.tsx

@@ -0,0 +1,43 @@
+import React from "react";
+import styled from "styled-components";
+
+export default function Heading(props: {
+  isAtTop?: boolean;
+  children: any;
+  docs?: string;
+}) {
+  return (
+    <StyledHeading isAtTop={props.isAtTop}>
+      {props.children}
+      {props.docs && (
+        <a href={props.docs} target="_blank">
+          <i className="material-icons">help_outline</i>
+        </a>
+      )}
+    </StyledHeading>
+  );
+}
+
+const StyledHeading = styled.div<{ isAtTop: boolean }>`
+  color: white;
+  font-weight: 500;
+  font-size: 16px;
+  margin-top: ${(props) => (props.isAtTop ? "0" : "30px")};
+  margin-bottom: 5px;
+  display: flex;
+  align-items: center;
+
+  > a {
+    > i {
+      display: flex;
+      align-items: center;
+      margin-bottom: -2px;
+      font-size: 16px;
+      margin-left: 12px;
+      color: #858faaaa;
+      :hover {
+        color: #aaaabb;
+      }
+    }
+  }
+`;

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


+ 56 - 7
dashboard/src/components/values-form/InputRow.tsx → dashboard/src/components/form-components/InputRow.tsx

@@ -1,8 +1,10 @@
 import React, { ChangeEvent, Component } from "react";
+import Tooltip from "@material-ui/core/Tooltip";
 import styled from "styled-components";
 
 type PropsType = {
   label?: string;
+  info?: string;
   type: string;
   value: string | number;
   setValue?: (x: string | number) => void;
@@ -11,6 +13,7 @@ type PropsType = {
   width?: string;
   disabled?: boolean;
   isRequired?: boolean;
+  className?: string;
 };
 
 type StateType = {
@@ -31,12 +34,34 @@ export default class InputRow extends Component<PropsType, StateType> {
   };
 
   render() {
-    let { label, value, type, unit, placeholder, width } = this.props;
+    let { label, value, type, unit, placeholder, width, info } = this.props;
     return (
-      <StyledInputRow>
-        {label && (
+      <StyledInputRow className={this.props.className}>
+        {(label || info) && (
           <Label>
-            {label} <Required>{this.props.isRequired ? " *" : null}</Required>
+            {label}
+            {info && (
+              <Tooltip
+                title={
+                  <div
+                    style={{
+                      fontFamily: "Work Sans, sans-serif",
+                      fontSize: "12px",
+                      fontWeight: "normal",
+                      padding: "5px 6px",
+                    }}
+                  >
+                    {info}
+                  </div>
+                }
+                placement="top"
+              >
+                <StyledInfoTooltip>
+                  <i className="material-icons">help_outline</i>
+                </StyledInfoTooltip>
+              </Tooltip>
+            )}
+            {this.props.isRequired && <Required>{" *"}</Required>}
           </Label>
         )}
         <InputWrapper>
@@ -63,13 +88,21 @@ const Required = styled.div`
 `;
 
 const Unit = styled.div`
-  margin-left: 8px;
+  padding: 0 10px;
+  background: #ffffff05;
+  height: 35px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-left: 1px solid #ffffff55;
 `;
 
 const InputWrapper = styled.div`
   display: flex;
   margin-bottom: -1px;
   align-items: center;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
 `;
 
 const Input = styled.input<{ disabled: boolean; width: string }>`
@@ -77,9 +110,7 @@ const Input = styled.input<{ disabled: boolean; width: string }>`
   border: none;
   font-size: 13px;
   background: #ffffff11;
-  border: 1px solid #ffffff55;
   cursor: ${(props) => (props.disabled ? "not-allowed" : "")};
-  border-radius: 3px;
   width: ${(props) => (props.width ? props.width : "270px")};
   color: ${(props) => (props.disabled ? "#ffffff44" : "white")};
   padding: 5px 10px;
@@ -99,3 +130,21 @@ const StyledInputRow = styled.div`
   margin-bottom: 15px;
   margin-top: 22px;
 `;
+
+const StyledInfoTooltip = styled.div`
+  display: inline-block;
+  position: relative;
+  margin-right: 2px;
+  > i {
+    display: flex;
+    align-items: center;
+    position: absolute;
+    top: -10px;
+    font-size: 10px;
+    color: #858faaaa;
+    cursor: pointer;
+    :hover {
+      color: #aaaabb;
+    }
+  }
+`;

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

@@ -6,7 +6,11 @@ import EnvEditorModal from "../../main/home/modals/EnvEditorModal";
 
 import sliders from "assets/sliders.svg";
 import upload from "assets/upload.svg";
-import { keysIn } from "lodash";
+
+export type KeyValue = {
+  key: string;
+  value: string;
+};
 
 type PropsType = {
   label?: string;
@@ -45,21 +49,32 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
 
   valuesToObject = () => {
     let obj = {} as any;
+    const rg = /(?:^|[^\\])(\\n)/g;
+    const fixNewlines = (s: string) => {
+      while (rg.test(s)) {
+        s = s.replace(rg, (str) => {
+          if (str.length == 2) return "\n";
+          if (str[0] != "\\") return str[0] + "\n";
+          return "\\n";
+        });
+      }
+      return s;
+    };
+    const isNumber = (s: string) => {
+      return !isNaN(!s ? NaN : Number(String(s).trim()));
+    };
     this.state.values.forEach((entry: any, i: number) => {
-      obj[entry.key] = entry.value;
+      if (isNumber(entry.value)) {
+        obj[entry.key] = entry.value;
+      } else {
+        obj[entry.key] = fixNewlines(entry.value);
+      }
     });
     return obj;
   };
 
-  objectToValues = (obj: any) => {
-    let values = [] as any[];
-    Object.keys(obj).forEach((key: string, i: number) => {
-      let entry = {} as any;
-      entry.key = key;
-      entry.value = obj[key];
-      values.push(entry);
-    });
-    return values;
+  objectToValues = (obj: Record<string, string>): KeyValue[] => {
+    return Object.entries(obj)?.map(([key, value]) => ({ key, value }));
   };
 
   renderDeleteButton = (i: number) => {
@@ -93,7 +108,7 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
   renderInputList = () => {
     return (
       <>
-        {this.state.values.map((entry: any, i: number) => {
+        {this.state.values?.map((entry: any, i: number) => {
           // Preprocess non-string env values set via raw Helm values
           let { value } = entry;
           if (typeof value === "object") {
@@ -148,16 +163,18 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
       return (
         <Modal
           onRequestClose={() => this.setState({ showEnvModal: false })}
-          width="665px"
-          height="342px"
+          width="765px"
+          height="542px"
         >
           <LoadEnvGroupModal
+            existingValues={this.props.values}
             namespace={this.props.externalValues?.namespace}
             clusterId={this.props.externalValues?.clusterId}
             closeModal={() => this.setState({ showEnvModal: false })}
-            setValues={(values: any) => {
-              this.props.setValues(values);
-              this.setState({ values: this.objectToValues(values) });
+            setValues={(values) => {
+              const newValues = { ...this.props.values, ...values };
+              this.props.setValues(newValues);
+              this.setState({ values: this.objectToValues(newValues) });
             }}
           />
         </Modal>

+ 0 - 0
dashboard/src/components/values-form/SelectRow.tsx → dashboard/src/components/form-components/SelectRow.tsx


+ 0 - 0
dashboard/src/components/values-form/TextArea.tsx → dashboard/src/components/form-components/TextArea.tsx


+ 0 - 1
dashboard/src/components/values-form/UploadArea.tsx → dashboard/src/components/form-components/UploadArea.tsx

@@ -35,7 +35,6 @@ export default class UploadArea extends Component<PropsType, StateType> {
 
   render() {
     let { label, placeholder } = this.props;
-    console.log(this.state.fileName);
     if (this.state.fileName) {
       placeholder = `Uploaded ${this.state.fileName}`;
     }

+ 3 - 7
dashboard/src/components/image-selector/ImageList.tsx

@@ -18,6 +18,7 @@ type PropsType = {
   setSelectedImageUrl: (x: string) => void;
   setSelectedTag: (x: string) => void;
   setClickedImage: (x: ImageType) => void;
+  disableImageSelect?: boolean;
 };
 
 type StateType = {
@@ -162,11 +163,6 @@ export default class ImageList extends Component<PropsType, StateType> {
     }
   }
 
-  /*
-  <Highlight onClick={() => this.props.setCurrentView('integrations')}>
-    Link your registry.
-  </Highlight>
-  */
   renderImageList = () => {
     let { images, loading, error } = this.state;
 
@@ -206,8 +202,8 @@ export default class ImageList extends Component<PropsType, StateType> {
   };
 
   renderBackButton = () => {
-    let { setSelectedImageUrl } = this.props;
-    if (this.props.clickedImage) {
+    let { setSelectedImageUrl, clickedImage, disableImageSelect } = this.props;
+    if (clickedImage && !disableImageSelect) {
       return (
         <BackButton
           width="175px"

+ 5 - 101
dashboard/src/components/image-selector/ImageSelector.tsx

@@ -17,6 +17,7 @@ type PropsType = {
   setSelectedImageUrl: (x: string) => void;
   setSelectedTag: (x: string) => void;
   noTagSelection?: boolean;
+  disableImageSelect?: boolean;
 };
 
 type StateType = {
@@ -36,87 +37,6 @@ export default class ImageSelector extends Component<PropsType, StateType> {
     clickedImage: null as ImageType | null,
   };
 
-  // componentDidMount() {
-  //   const { currentProject, setCurrentError } = this.context;
-  //   let images = [] as ImageType[];
-  //   let errors = [] as number[];
-  //   api
-  //     .getProjectRegistries("<token>", {}, { id: currentProject.id })
-  //     .then(async (res) => {
-  //       let registries = res.data;
-  //       if (registries.length === 0) {
-  //         this.setState({ loading: false });
-  //       }
-
-  //       // Loop over connected image registries
-  //       registries.forEach(async (registry: any, i: number) => {
-  //         await new Promise((nextController: (res?: any) => void) => {
-  //           api
-  //             .getImageRepos(
-  //               "<token>",
-  //               {},
-  //               {
-  //                 project_id: currentProject.id,
-  //                 registry_id: registry.id,
-  //               }
-  //             )
-  //             .then((res) => {
-  //               res.data.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
-  //               // Loop over found image repositories
-  //               let newImg = res.data.map((img: any) => {
-  //                 if (this.props.selectedImageUrl === img.uri) {
-  //                   this.setState({
-  //                     clickedImage: {
-  //                       kind: registry.service,
-  //                       source: img.uri,
-  //                       name: img.name,
-  //                       registryId: registry.id,
-  //                     },
-  //                   });
-  //                 }
-  //                 return {
-  //                   kind: registry.service,
-  //                   source: img.uri,
-  //                   name: img.name,
-  //                   registryId: registry.id,
-  //                 };
-  //               });
-  //               images.push(...newImg);
-  //               errors.push(0);
-  //             })
-  //             .catch(() => errors.push(1))
-  //             .finally(() => {
-  //               if (i == registries.length - 1) {
-  //                 let error =
-  //                   errors.reduce((a, b) => {
-  //                     return a + b;
-  //                   }) == registries.length
-  //                     ? true
-  //                     : false;
-
-  //                 this.setState({
-  //                   images,
-  //                   loading: false,
-  //                   error,
-  //                 });
-  //               }
-
-  //               nextController();
-  //             });
-  //         });
-  //       });
-  //     })
-  //     .catch((err) => {
-  //       console.log(err);
-  //       this.setState({ error: true });
-  //     });
-  // }
-
-  /*
-  <Highlight onClick={() => this.props.setCurrentView('integrations')}>
-    Link your registry.
-  </Highlight>
-  */
   renderImageList = () => {
     let { images, loading, error } = this.state;
 
@@ -155,24 +75,6 @@ export default class ImageSelector extends Component<PropsType, StateType> {
     });
   };
 
-  renderBackButton = () => {
-    let { setSelectedImageUrl } = this.props;
-    if (this.state.clickedImage) {
-      return (
-        <BackButton
-          width="175px"
-          onClick={() => {
-            setSelectedImageUrl("");
-            this.setState({ clickedImage: null });
-          }}
-        >
-          <i className="material-icons">keyboard_backspace</i>
-          Select Image Repo
-        </BackButton>
-      );
-    }
-  };
-
   renderSelected = () => {
     let { selectedImageUrl, setSelectedImageUrl } = this.props;
     let { clickedImage } = this.state;
@@ -192,6 +94,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
       <Label>
         <img src={icon} />
         <Input
+          disabled={this.props.disableImageSelect}
           autoFocus={true}
           onClick={(e: any) => e.stopPropagation()}
           value={selectedImageUrl}
@@ -233,6 +136,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
 
         {this.state.isExpanded ? (
           <ImageList
+            disableImageSelect={this.props.disableImageSelect}
             selectedImageUrl={this.props.selectedImageUrl}
             selectedTag={this.props.selectedTag}
             clickedImage={this.state.clickedImage}
@@ -284,13 +188,13 @@ const BackButton = styled.div`
   }
 `;
 
-const Input = styled.input`
+const Input = styled.input<{ disabled: boolean }>`
   outline: 0;
   background: none;
   border: 0;
   font-size: 13px;
   width: calc(100% - 60px);
-  color: white;
+  color: ${(props) => (props.disabled ? "#aaaabb" : "#ffffff")};
 `;
 
 const ImageItem = styled.div`

+ 54 - 3
dashboard/src/components/image-selector/TagList.tsx

@@ -32,7 +32,8 @@ export default class TagList extends Component<PropsType, StateType> {
     currentTag: this.props.selectedTag,
   };
 
-  componentDidMount() {
+  refreshTagList = () => {
+    this.setState({ loading: true });
     const { currentProject } = this.context;
 
     let splits = this.props.selectedImageUrl.split("/");
@@ -55,6 +56,14 @@ export default class TagList extends Component<PropsType, StateType> {
         }
       )
       .then((res) => {
+        // Sort if timestamp is available
+        if (res.data.length > 0 && res.data[0].pushed_at) {
+          res.data.sort((a: any, b: any) => {
+            let d1 = new Date(a.pushed_at);
+            let d2 = new Date(b.pushed_at);
+            return d2.getTime() - d1.getTime();
+          });
+        }
         let tags = res.data.map((tag: any, i: number) => {
           return tag.tag;
         });
@@ -64,6 +73,10 @@ export default class TagList extends Component<PropsType, StateType> {
         console.log(err);
         this.setState({ loading: false, error: true });
       });
+  };
+
+  componentDidMount() {
+    this.refreshTagList();
   }
 
   setTag = (tag: string) => {
@@ -105,7 +118,12 @@ export default class TagList extends Component<PropsType, StateType> {
     return (
       <>
         <TagNameAlt>
-          <img src={info} /> Select Image Tag
+          <Label>
+            <img src={info} /> Select Image Tag
+          </Label>
+          <Refresh onClick={this.refreshTagList}>
+            <i className="material-icons">autorenew</i> Refresh
+          </Refresh>
         </TagNameAlt>
         <StyledTagList>{this.renderTagList()}</StyledTagList>
       </>
@@ -115,6 +133,36 @@ export default class TagList extends Component<PropsType, StateType> {
 
 TagList.contextType = Context;
 
+const Label = styled.div`
+  display: flex;
+  align-items: center;
+  > img {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+  }
+`;
+
+const Refresh = styled.div`
+  margin-right: 10px;
+  cursor: pointer;
+  color: #949eff;
+  display: flex;
+  align-items: center;
+  font-weight: 500;
+  border-radius: 3px;
+  padding: 2px 3px;
+  padding-right: 7px;
+  > i {
+    font-size: 17px;
+    margin-right: 6px;
+  }
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
 const StyledTagList = styled.div`
   max-height: 175px;
   position: relative;
@@ -152,10 +200,13 @@ const TagName = styled.div`
 `;
 
 const TagNameAlt = styled(TagName)`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
   color: #ffffff55;
   cursor: default;
   :hover {
-    background: #ffffff11;
+    background: none;
     > i {
       background: none;
     }

+ 537 - 0
dashboard/src/components/porter-form/FormDebugger.tsx

@@ -0,0 +1,537 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import AceEditor from "react-ace";
+import PorterFormWrapper from "./PorterFormWrapper";
+import CheckboxRow from "components/form-components/CheckboxRow";
+import InputRow from "components/form-components/InputRow";
+import yaml from "js-yaml";
+
+import "shared/ace-porter-theme";
+import "ace-builds/src-noconflict/mode-text";
+
+import Heading from "../form-components/Heading";
+import Helper from "../form-components/Helper";
+
+type PropsType = {
+  goBack: () => void;
+};
+
+type StateType = {
+  rawYaml: string;
+  showBonusTabs: boolean;
+  showStateDebugger: boolean;
+  valuesToOverride: any;
+  checkbox_a: boolean;
+  input_a: string;
+  isReadOnly: boolean;
+};
+
+const tabOptions = [
+  { value: "a", label: "Bonus Tab A" },
+  { value: "b", label: "Bonus Tab B" },
+];
+
+export default class FormDebugger extends Component<PropsType, StateType> {
+  state = {
+    rawYaml: initYaml,
+    showBonusTabs: false,
+    showStateDebugger: true,
+    valuesToOverride: {
+      checkbox_a: {
+        value: true,
+      },
+    } as any,
+    checkbox_a: true,
+    input_a: "",
+    isReadOnly: false,
+  };
+
+  renderTabContents = (currentTab: string) => {
+    return (
+      <TabWrapper>
+        {this.state.rawYaml.toString().slice(0, 300) || "No raw YAML inputted."}
+      </TabWrapper>
+    );
+  };
+
+  aceEditorRef = React.createRef<AceEditor>();
+  render() {
+    let formData = {};
+    try {
+      formData = yaml.load(this.state.rawYaml);
+    } catch (err: any) {
+      console.log("YAML parsing error.");
+    }
+    return (
+      <StyledFormDebugger>
+        <Button onClick={this.props.goBack}>
+          <i className="material-icons">keyboard_backspace</i>
+          Back
+        </Button>
+        <Heading isAtTop={true}>✨ Form.yaml Editor</Heading>
+        <Helper>Write and test form.yaml free of consequence.</Helper>
+
+        <EditorWrapper>
+          <AceEditor
+            ref={this.aceEditorRef}
+            mode="yaml"
+            value={this.state.rawYaml}
+            theme="porter"
+            onChange={(e: string) => this.setState({ rawYaml: e })}
+            name="codeEditor"
+            editorProps={{ $blockScrolling: true }}
+            height="450px"
+            width="100%"
+            style={{
+              borderRadius: "5px",
+              border: "1px solid #ffffff22",
+              marginTop: "27px",
+              marginBottom: "27px",
+            }}
+            showPrintMargin={false}
+            showGutter={true}
+            highlightActiveLine={true}
+          />
+        </EditorWrapper>
+
+        <CheckboxRow
+          label="Show form state debugger"
+          checked={this.state.showStateDebugger}
+          toggle={() =>
+            this.setState({ showStateDebugger: !this.state.showStateDebugger })
+          }
+        />
+        <CheckboxRow
+          label="Read-only"
+          checked={this.state.isReadOnly}
+          toggle={() =>
+            this.setState({
+              isReadOnly: !this.state.isReadOnly,
+            })
+          }
+        />
+        <CheckboxRow
+          label="Include non-form dummy tabs"
+          checked={this.state.showBonusTabs}
+          toggle={() =>
+            this.setState({ showBonusTabs: !this.state.showBonusTabs })
+          }
+        />
+        <CheckboxRow
+          label="checkbox_a"
+          checked={this.state.checkbox_a}
+          toggle={() =>
+            this.setState({
+              checkbox_a: !this.state.checkbox_a,
+
+              // Override the form value for checkbox_a
+              valuesToOverride: {
+                ...this.state.valuesToOverride,
+                checkbox_a: {
+                  value: !this.state.checkbox_a,
+                },
+              },
+            })
+          }
+        />
+        <InputRow
+          type="string"
+          value={this.state.input_a}
+          setValue={(x: string) =>
+            this.setState({
+              input_a: x,
+
+              // Override the form value for input_a
+              valuesToOverride: {
+                ...this.state.valuesToOverride,
+                input_a: {
+                  value: x,
+                },
+              },
+            })
+          }
+          label={"input_a"}
+          placeholder="ex: override text"
+        />
+
+        <Heading>🎨 Rendered Form</Heading>
+        <Br />
+        <PorterFormWrapper
+          showStateDebugger={this.state.showStateDebugger}
+          formData={formData}
+          valuesToOverride={{
+            input_a: this.state.valuesToOverride?.input_a?.value,
+          }}
+          isReadOnly={this.state.isReadOnly}
+          onSubmit={(vars) => {
+            alert("check console output");
+            console.log(vars);
+          }}
+          rightTabOptions={this.state.showBonusTabs ? tabOptions : []}
+          renderTabContents={this.renderTabContents}
+          saveButtonText={"Test Submit"}
+        />
+      </StyledFormDebugger>
+    );
+  }
+}
+
+const Br = styled.div`
+  width: 100%;
+  height: 12px;
+`;
+
+const TabWrapper = styled.div`
+  background: #ffffff11;
+  height: 200px;
+  width: 100%;
+  border-radius: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  overflow: auto;
+  padding: 50px;
+`;
+
+const EditorWrapper = styled.div`
+  .ace_editor,
+  .ace_editor * {
+    font-family: "Monaco", "Menlo", "Ubuntu Mono", "Droid Sans Mono", "Consolas",
+      monospace !important;
+    font-size: 12px !important;
+    font-weight: 400 !important;
+    letter-spacing: 0 !important;
+  }
+`;
+
+const StyledFormDebugger = styled.div`
+  position: relative;
+`;
+
+const Button = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border-radius: 20px;
+  color: white;
+  height: 35px;
+  margin-left: -2px;
+  padding: 0px 8px;
+  width: 85px;
+  float: right;
+  padding-bottom: 1px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  cursor: pointer;
+  border: 2px solid #969fbbaa;
+  :hover {
+    background: #ffffff11;
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    color: #969fbbaa;
+    font-weight: 600;
+    font-size: 14px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const initYaml = `name: Web
+hasSource: true
+includeHiddenFields: true
+tabs:
+- name: main
+  label: Main
+  sections:
+  - name: section_one
+    contents: 
+    - type: heading
+      label: Container Settings
+    - type: variable
+      variable: showStartCommand
+      settings:
+        default: true
+  - name: command
+    show_if: showStartCommand
+    contents:
+    - type: subtitle
+      name: command_description
+      label: (Optional) Set a start command for this service.
+    - type: string-input
+      label: Start Command
+      placeholder: "ex: sh ./script.sh"
+      variable: container.command
+  - name: section_one_cont
+    contents:
+    - type: subtitle
+      label: Specify the port your application is running on.
+    - type: number-input
+      variable: container.port
+      label: Container Port
+      placeholder: "ex: 80"
+      settings:
+        default: 80
+    - type: heading
+      label: Deploy Webhook
+    - type: checkbox
+      variable: auto_deploy
+      label: Auto-deploy when webhook is called.
+      settings:
+        default: true
+  - name: network
+    contents:
+    - type: heading
+      label: Network Settings
+    - type: subtitle
+      label: For containers that you do not want to expose to external traffic (e.g. databases and add-ons), you may make them accessible only to other internal services running within the same cluster. 
+    - type: checkbox
+      variable: ingress.enabled
+      label: Expose to external traffic
+      settings:
+        default: true
+  - name: domain_toggle
+    show_if: ingress.enabled
+    contents:
+    - type: subtitle
+      label: Assign custom domain to your deployment. You must first create an A record in your domain provider that points to your cluster load balancer's IP address for this.
+    - type: checkbox
+      variable: ingress.custom_domain
+      label: Configure Custom Domain
+      settings:
+        default: false 
+  - name: domain_name
+    show_if: ingress.custom_domain
+    contents:
+    - type: array-input
+      variable: ingress.hosts
+      label: Domain Name
+  - name: do_wildcard
+    show_if: 
+      and:
+      - ingress.custom_domain
+      - currentCluster.service.is_do
+    contents:
+    - type: subtitle
+      label: If you're hosting on Digital Ocean and have enabled the wildcard domains from the 'HTTPS Issuer', you can use a wildcard certificate.
+    - type: checkbox
+      variable: ingress.wildcard
+      label: Use Wildcard Certificate
+- name: resources
+  label: Resources
+  sections:
+  - name: main_section
+    contents:
+    - type: heading
+      label: Resources
+    - type: subtitle
+      label: Configure resources assigned to this container.
+    - type: number-input
+      label: RAM
+      variable: resources.requests.memory
+      placeholder: "ex: 256"
+      settings:
+        unit: Mi
+        default: 256
+    - type: number-input
+      label: CPU
+      variable: resources.requests.cpu
+      placeholder: "ex: 100"
+      settings:
+        unit: m
+        default: 100
+    - type: number-input
+      label: Replicas
+      variable: replicaCount
+      placeholder: "ex: 1"
+      settings:
+        default: 1
+    - type: checkbox
+      variable: autoscaling.enabled
+      label: Enable autoscaling
+      settings:
+        default: false
+  - name: autoscaler
+    show_if: autoscaling.enabled
+    contents:
+    - type: number-input
+      label: Minimum Replicas
+      variable: autoscaling.minReplicas
+      placeholder: "ex: 1"
+      settings:
+        default: 1
+    - type: number-input
+      label: Maximum Replicas
+      variable: autoscaling.maxReplicas
+      placeholder: "ex: 10"
+      settings:
+        default: 10
+    - type: number-input
+      label: Target CPU Utilization
+      variable: autoscaling.targetCPUUtilizationPercentage
+      placeholder: "ex: 50"
+      settings:
+        omitUnitFromValue: true
+        unit: "%"
+        default: 50
+    - type: number-input
+      label: Target RAM Utilization
+      variable: autoscaling.targetMemoryUtilizationPercentage
+      placeholder: "ex: 50"
+      settings:
+        omitUnitFromValue: true
+        unit: "%"
+        default: 50
+- name: env
+  label: Environment
+  sections:
+  - name: env_vars
+    contents:
+    - type: heading
+      label: Environment Variables
+    - type: subtitle
+      label: Set environment variables for your secrets and environment-specific configuration.
+    - type: env-key-value-array
+      label: 
+      variable: container.env.normal
+- name: advanced
+  label: Advanced
+  sections:
+  - name: ingress_annotations
+    contents:
+    - type: heading
+      label: Ingress Custom Annotations
+    - type: subtitle
+      label: Assign custom annotations to Ingress. These annotations will overwrite the annotations Porter assigns by default.
+    - type: key-value-array
+      variable: ingress.annotations
+      settings:
+        default: {}
+  - name: health_check
+    contents:
+    - type: heading
+      label: Custom Health Checks
+    - type: subtitle
+      label: Define custom health check endpoints to ensure zero down-time deployments.
+    - type: checkbox
+      variable: health.enabled
+      label: Enable Custom Health Checks
+      settings:
+        default: false
+  - name: health_check_endpoint
+    show_if: health.enabled
+    contents:
+    - type: string-input
+      label: Health Check Endpoint
+      variable: health.path
+      placeholder: "ex: /healthz"
+      settings:
+        default: /healthz
+    - type: heading
+      label: Custom Health Check Rules
+    - type: subtitle
+      label: Configure how many times a health check will be performed before deeming the container as failed. 
+    - type: number-input
+      label: Failure Threshold
+      variable: health.failureThreshold
+      placeholder: "ex: 3"
+    - type: subtitle
+      label: Configure the interval at which health check is repeated in the case of failure.
+    - type: number-input
+      label: Repeat Interval
+      variable: health.periodSeconds
+      placeholder: "ex: 30"
+  - name: persistence_toggle
+    contents:
+    - type: heading
+      label: Persistent Disks
+    - type: subtitle
+      label: Attach persistent disks to your deployment to retain data across releases.
+    - type: checkbox
+      label: Enable Persistence
+      variable: pvc.enabled
+  - name: persistent_storage
+    show_if: pvc.enabled
+    contents:
+    - type: number-input
+      label: Persistent Storage
+      variable: pvc.storage
+      placeholder: "ex: 20"
+      settings:
+        unit: Gi
+        default: 20
+    - type: string-input
+      label: Mount Path
+      variable: pvc.mountPath
+      placeholder: "ex: /mypath"
+      settings:
+        default: /mypath
+  - name: termination_grace_period
+    contents:
+    - type: heading
+      label: Termination Grace Period
+    - type: subtitle
+      label: Specify how much time app processes have to gracefully shut down on SIGTERM.
+    - type: number-input
+      label: Termination Grace Period (seconds)
+      variable: terminationGracePeriodSeconds
+      placeholder: "ex: 30"
+      settings:
+        default: 30
+  - name: container_hooks
+    contents:
+    - type: heading
+      label: Container hooks
+    - type: subtitle
+      label: (Optional) Set post start or pre stop commands for this service.
+    - type: string-input
+      label: Post start command
+      placeholder: "ex: /bin/sh ./myscript.sh"
+      variable: container.lifecycle.postStart
+    - type: string-input
+      label: Pre stop command
+      placeholder: "ex: /bin/sh ./myscript.sh"
+      variable: container.lifecycle.preStop
+  - name: cloud_sql_toggle
+    show_if: currentCluster.service.is_gcp
+    contents:
+    - type: heading
+      label: Google Cloud SQL
+    - type: subtitle
+      label: Securely connect to Google Cloud SQL (GKE only).
+    - type: checkbox
+      variable: cloudsql.enabled
+      label: Enable Google Cloud SQL Proxy
+      settings:
+        default: false
+  - name: cloud_sql_contents
+    show_if: cloudsql.enabled
+    contents:
+    - type: string-input
+      label: Instance Connection Name
+      variable: cloudsql.connectionName
+      placeholder: "ex: project-123:us-east1:pachyderm"
+    - type: number-input
+      label: DB Port
+      variable: cloudsql.dbPort
+      placeholder: "ex: 5432"
+      settings:
+        default: 5432
+    - type: string-input
+      label: Service Account JSON
+      variable: cloudsql.serviceAccountJSON
+      placeholder: "ex: { <SERVICE_ACCOUNT_JSON> }"`;

+ 233 - 0
dashboard/src/components/porter-form/PorterForm.tsx

@@ -0,0 +1,233 @@
+import React, { useContext } from "react";
+import {
+  ArrayInputField,
+  CheckboxField,
+  FormField,
+  InputField,
+  KeyValueArrayField,
+  ResourceListField,
+  Section,
+  SelectField,
+  ServiceIPListField
+} from "./types";
+import TabRegion, { TabOption } from "../TabRegion";
+import Heading from "../form-components/Heading";
+import Helper from "../form-components/Helper";
+import Input from "./field-components/Input";
+import { PorterFormContext } from "./PorterFormContextProvider";
+import Checkbox from "./field-components/Checkbox";
+import KeyValueArray from "./field-components/KeyValueArray";
+import styled from "styled-components";
+import SaveButton from "../SaveButton";
+import ArrayInput from "./field-components/ArrayInput";
+import Select from "./field-components/Select";
+import ServiceIPList from "./field-components/ServiceIPList";
+import ResourceList from "./field-components/ResourceList";
+import VeleroForm from "./field-components/VeleroForm";
+
+interface Props {
+  leftTabOptions?: TabOption[];
+  rightTabOptions?: TabOption[];
+  renderTabContents?: (
+    currentTab: string,
+    submitValues?: any
+  ) => React.ReactElement;
+  saveButtonText?: string;
+  isReadOnly?: boolean;
+  isInModal?: boolean;
+  color?: string;
+  addendum?: any;
+  saveValuesStatus?: string;
+  showStateDebugger?: boolean;
+  currentTab: string;
+  setCurrentTab: (nt: string) => void;
+  isLaunch?: boolean;
+}
+
+const PorterForm: React.FC<Props> = (props) => {
+  const {
+    formData,
+    isReadOnly,
+    validationInfo,
+    onSubmit,
+    formState,
+  } = useContext(PorterFormContext);
+
+  const { currentTab, setCurrentTab } = props;
+
+  const renderSectionField = (field: FormField): JSX.Element => {
+    const bundledProps = {
+      ...field,
+      isReadOnly,
+    };
+    switch (field.type) {
+      case "heading":
+        return <Heading>{field.label}</Heading>;
+      case "subtitle":
+        return <Helper>{field.label}</Helper>;
+      case "input":
+        return <Input {...(bundledProps as InputField)} />;
+      case "checkbox":
+        return <Checkbox {...(bundledProps as CheckboxField)} />;
+      case "key-value-array":
+        return <KeyValueArray {...(bundledProps as KeyValueArrayField)} />;
+      case "array-input":
+        return <ArrayInput {...(bundledProps as ArrayInputField)} />;
+      case "select":
+        return <Select {...(bundledProps as SelectField)} />;
+      case "service-ip-list":
+        return <ServiceIPList {...(bundledProps as ServiceIPListField)} />;
+      case "resource-list":
+        return <ResourceList {...(bundledProps as ResourceListField)} />;
+      case "velero-create-backup":
+        return <VeleroForm />;
+    }
+    return <p>Not Implemented: {(field as any).type}</p>;
+  };
+
+  const renderSection = (section: Section): JSX.Element => {
+    return (
+      <>
+        {section.contents?.map((field, i) => {
+          return (
+            <React.Fragment key={field.id}>
+              {renderSectionField(field)}
+            </React.Fragment>
+          );
+        })}
+      </>
+    );
+  };
+
+  const getTabOptions = (): TabOption[] => {
+    let options = (props.leftTabOptions || [])
+      .concat(
+        formData?.tabs?.map((tab) => {
+          if (props.isLaunch && tab?.settings?.omitFromLaunch) {
+            return undefined;
+          }
+          return { label: tab.label, value: tab.name };
+        })
+      )
+      .concat(props.rightTabOptions || []);
+    return options.filter((x) => !!x);
+  };
+
+  const showSaveButton = (): boolean => {
+    if (props.isReadOnly) {
+      return false;
+    }
+
+    let returnVal = true;
+    props.leftTabOptions?.forEach((tab: any) => {
+      if (tab.value === currentTab) {
+        returnVal = false;
+      }
+    });
+    props.rightTabOptions?.forEach((tab: any) => {
+      if (tab.value === currentTab) {
+        returnVal = false;
+      }
+    });
+
+    return returnVal;
+  };
+
+  const renderTab = (): JSX.Element => {
+    if (!formData) {
+      return props.renderTabContents(currentTab);
+    }
+
+    const tab = formData.tabs?.filter((tab) => tab.name == currentTab)[0];
+
+    // Handle external tab
+    if (!tab) {
+      return props.renderTabContents ? (
+        props.renderTabContents(currentTab)
+      ) : (
+        <></>
+      );
+    }
+
+    return (
+      <StyledPorterForm showSave={showSaveButton()}>
+        {tab.sections?.map((section) => {
+          return (
+            <React.Fragment key={section.name}>
+              {renderSection(section)}
+            </React.Fragment>
+          );
+        })}
+      </StyledPorterForm>
+    );
+  };
+
+  const isDisabled = () => {
+    if (props.saveValuesStatus == "loading") {
+      return true;
+    }
+
+    return isReadOnly || !validationInfo.validated;
+  };
+
+  const renderSaveStatus = (): string => {
+    if (isDisabled() && props.saveValuesStatus !== "loading") {
+      return "Missing required fields";
+    }
+    return props.saveValuesStatus;
+  };
+
+  return (
+    <>
+      <TabRegion
+        addendum={props.addendum}
+        color={props.color}
+        options={getTabOptions()}
+        currentTab={currentTab}
+        setCurrentTab={setCurrentTab}
+        suppressAnimation={true}
+      >
+        {renderTab()}
+      </TabRegion>
+      <br />
+      {showSaveButton() && (
+        <SaveButton
+          text={props.saveButtonText || "Deploy"}
+          onClick={onSubmit}
+          makeFlush={!props.isInModal}
+          status={
+            validationInfo.validated ? renderSaveStatus() : validationInfo.error
+          }
+          disabled={isDisabled()}
+        />
+      )}
+      {props.showStateDebugger && (
+        <Pre>{JSON.stringify(formState, undefined, 2)}</Pre>
+      )}
+      <Spacer />
+    </>
+  );
+};
+
+export default PorterForm;
+
+const Pre = styled.pre`
+  font-size: 13px;
+  color: #aaaabb;
+`;
+
+const Spacer = styled.div`
+  height: 50px;
+`;
+
+const StyledPorterForm = styled.div<{ showSave?: boolean }>`
+  width: 100%;
+  height: ${(props) => (props.showSave ? "calc(100% - 50px)" : "100%")};
+  background: #ffffff11;
+  color: #ffffff;
+  padding: 0px 35px 25px;
+  position: relative;
+  border-radius: 8px;
+  font-size: 13px;
+  overflow: auto;
+`;

+ 462 - 0
dashboard/src/components/porter-form/PorterFormContextProvider.tsx

@@ -0,0 +1,462 @@
+import React, { createContext, useContext, useReducer } from "react";
+import {
+  GetFinalVariablesFunction,
+  PorterFormAction,
+  PorterFormData,
+  PorterFormState,
+  PorterFormValidationInfo,
+  PorterFormVariableList,
+} from "./types";
+import { ShowIf, ShowIfAnd, ShowIfNot, ShowIfOr } from "../../shared/types";
+import { getFinalVariablesForStringInput } from "./field-components/Input";
+import { getFinalVariablesForKeyValueArray } from "./field-components/KeyValueArray";
+import { Context } from "../../shared/Context";
+import { getFinalVariablesForArrayInput } from "./field-components/ArrayInput";
+import { getFinalVariablesForCheckbox } from "./field-components/Checkbox";
+import { getFinalVariablesForSelect } from "./field-components/Select";
+
+interface Props {
+  rawFormData: PorterFormData;
+  onSubmit: (vars: PorterFormVariableList) => void;
+  initialVariables?: PorterFormVariableList;
+  overrideVariables?: PorterFormVariableList;
+  includeHiddenFields?: boolean;
+  isReadOnly?: boolean;
+  doDebug?: boolean;
+}
+
+interface ContextProps {
+  formData: PorterFormData;
+  formState: PorterFormState;
+  onSubmit: () => void;
+  dispatchAction: (event: PorterFormAction) => void;
+  validationInfo: PorterFormValidationInfo;
+  getSubmitValues: () => PorterFormVariableList;
+  isReadOnly?: boolean;
+}
+
+export const PorterFormContext = createContext<ContextProps | undefined>(
+  undefined!
+);
+const { Provider } = PorterFormContext;
+
+export const PorterFormContextProvider: React.FC<Props> = (props) => {
+  const context = useContext(Context);
+
+  const handleAction = (
+    state: PorterFormState,
+    action: PorterFormAction
+  ): PorterFormState => {
+    switch (action?.type) {
+      case "init-field":
+        if (!(action.id in state.components)) {
+          return {
+            ...state,
+            variables: {
+              ...state.variables,
+              ...action.initVars,
+            },
+            components: {
+              ...state.components,
+              [action.id]: {
+                state: action.initValue,
+              },
+            },
+            validation: {
+              ...state.validation,
+              [action.id]: {
+                ...{
+                  validated: false,
+                },
+                ...action.initValidation,
+              },
+            },
+          };
+        }
+        break;
+      case "update-field":
+        return {
+          ...state,
+          variables: {
+            ...state.variables,
+            ...props.overrideVariables,
+          },
+          components: {
+            ...state.components,
+            [action.id]: {
+              ...state.components[action.id],
+              state: {
+                ...state.components[action.id].state,
+                ...action.updateFunc(state.components[action.id].state),
+              },
+            },
+          },
+        };
+      case "update-validation":
+        return {
+          ...state,
+          components: {
+            ...state.components,
+            [action.id]: {
+              ...state.components[action.id],
+            },
+          },
+          validation: {
+            ...state.validation,
+            [action.id]: {
+              ...action.updateFunc(state.validation[action.id]),
+            },
+          },
+        };
+      case "mutate-vars":
+        return {
+          ...state,
+          variables: {
+            ...state.variables,
+            ...action.mutateFunc(state.variables),
+            ...props.overrideVariables,
+          },
+        };
+    }
+    return state;
+  };
+
+  // get variables initiated by variable field
+  const getInitialVariables = (data: PorterFormData) => {
+    const ret: Record<string, any> = {};
+    data?.tabs?.map((tab) =>
+      tab.sections?.map((section) =>
+        section.contents?.map((field) => {
+          if (field?.type == "variable") {
+            ret[field.variable] = field.settings?.default;
+          }
+        })
+      )
+    );
+    return {
+      ...ret,
+      ...{
+        "currentCluster.service.is_gcp":
+          context.currentCluster?.service == "gke",
+        "currentCluster.service.is_aws":
+          context.currentCluster?.service == "eks",
+        "currentCluster.service.is_do":
+          context.currentCluster?.service == "doks",
+      },
+    };
+  };
+
+  const getInitialValidation = (data: PorterFormData) => {
+    const ret: Record<string, any> = {};
+    data?.tabs?.map((tab, i) =>
+      tab.sections?.map((section, j) =>
+        section.contents?.map((field, k) => {
+          if (
+            field?.type == "heading" ||
+            field?.type == "subtitle" ||
+            field?.type == "resource-list" ||
+            field?.type == "service-ip-list" ||
+            field?.type == "velero-create-backup"
+          )
+            return;
+          if (
+            field.required &&
+            (field.settings?.default || (field.value && field.value[0]))
+          ) {
+            ret[`${i}-${j}-${k}`] = {
+              validated: true,
+            };
+          }
+        })
+      )
+    );
+    return ret;
+  };
+
+  const [state, dispatch] = useReducer(handleAction, {
+    components: {},
+    validation: getInitialValidation(props.rawFormData),
+    variables: {
+      ...props.initialVariables,
+      ...getInitialVariables(props.rawFormData),
+      ...props.overrideVariables,
+    },
+  });
+
+  const evalShowIf = (
+    vals: ShowIf,
+    variables: PorterFormVariableList
+  ): boolean => {
+    if (!vals) {
+      return false;
+    }
+    if (typeof vals == "string") {
+      return !!variables[vals];
+    }
+    if ((vals as ShowIfOr).or) {
+      vals = vals as ShowIfOr;
+      for (let i = 0; i < vals.or?.length; i++) {
+        if (evalShowIf(vals.or[i], variables)) {
+          return true;
+        }
+      }
+      return false;
+    }
+    if ((vals as ShowIfAnd).and) {
+      vals = vals as ShowIfAnd;
+      for (let i = 0; i < vals.and?.length; i++) {
+        if (!evalShowIf(vals.and[i], variables)) {
+          return false;
+        }
+      }
+      return true;
+    }
+    if ((vals as ShowIfNot).not) {
+      vals = vals as ShowIfNot;
+      return !evalShowIf(vals.not, variables);
+    }
+
+    return false;
+  };
+
+  /*
+    Takes in old form data and changes it to use newer fields
+    For example, number-input becomes input with a setting that makes it
+    a number input
+   */
+  const restructureToNewFields = (data: PorterFormData) => {
+    return {
+      ...data,
+      tabs: data?.tabs?.map((tab) => {
+        return {
+          ...tab,
+          sections: tab.sections?.map((section) => {
+            return {
+              ...section,
+              contents: section.contents
+                ?.map((field: any) => {
+                  if (field?.type == "number-input") {
+                    return {
+                      ...field,
+                      type: "input",
+                      settings: {
+                        ...field.settings,
+                        type: "number",
+                      },
+                    };
+                  }
+                  if (field?.type == "string-input") {
+                    return {
+                      ...field,
+                      type: "input",
+                      settings: {
+                        ...field.settings,
+                        type: "string",
+                      },
+                    };
+                  }
+                  if (field?.type == "string-input-password") {
+                    return {
+                      ...field,
+                      type: "input",
+                      settings: {
+                        ...field.settings,
+                        type: "password",
+                      },
+                    };
+                  }
+                  if (field?.type == "provider-select") {
+                    return {
+                      ...field,
+                      type: "select",
+                      settings: {
+                        ...field.settings,
+                        type: "provider",
+                      },
+                    };
+                  }
+                  if (field?.type == "env-key-value-array") {
+                    return {
+                      ...field,
+                      type: "key-value-array",
+                      secretOption: true,
+                      envLoader: true,
+                      fileUpload: true,
+                      settings: {
+                        type: "env",
+                      },
+                    };
+                  }
+                  if (field?.type == "variable") return null;
+                  return field;
+                })
+                .filter((x) => x != null),
+            };
+          }),
+        };
+      }),
+    };
+  };
+
+  /*
+  We don't want to have the actual <PorterForm> component to do as little form
+  logic as possible, so this structures the form object based on show_if statements
+  and assigns a unique id to each field
+
+  This computed structure also later lets us figure out which fields should be required
+  */
+  const computeFormStructure = (
+    data: PorterFormData,
+    variables: PorterFormVariableList
+  ) => {
+    return {
+      ...data,
+      tabs: data?.tabs?.map((tab, i) => {
+        return {
+          ...tab,
+          sections: tab.sections
+            ?.map((section, j) => {
+              return {
+                ...section,
+                contents: section.contents?.map((field, k) => {
+                  return {
+                    ...field,
+                    id: `${i}-${j}-${k}`,
+                  };
+                }),
+              };
+            })
+            .filter((section) => {
+              return !section.show_if || evalShowIf(section.show_if, variables);
+            }),
+        };
+      }),
+    };
+  };
+
+  /*
+    compute a list of field ids who's input is required and a map from a variable value
+    to a list of fields that set it
+  */
+  const computeRequiredVariables = (
+    data: PorterFormData
+  ): [string[], Record<string, string[]>] => {
+    const requiredIds: string[] = [];
+    const mapping: Record<string, string[]> = {};
+    data?.tabs?.map((tab) =>
+      tab.sections?.map((section) =>
+        section.contents?.map((field) => {
+          if (
+            field?.type == "heading" ||
+            field?.type == "subtitle" ||
+            field?.type == "resource-list" ||
+            field?.type == "service-ip-list" ||
+            field?.type == "velero-create-backup"
+          )
+            return;
+          // fields that have defaults can't be required since we can always
+          // compute their value
+          if (field.required) {
+            requiredIds.push(field.id);
+          }
+          if (!mapping[field.variable]) {
+            mapping[field.variable] = [];
+          }
+          mapping[field.variable].push(field.id);
+        })
+      )
+    );
+    return [requiredIds, mapping];
+  };
+
+  /*
+    Validate the form based on a list of required ids
+   */
+  const doValidation = (requiredIds: string[]) =>
+    requiredIds?.map((id) => state.validation[id]?.validated).every((x) => x);
+
+  const formData = computeFormStructure(
+    restructureToNewFields(props.rawFormData),
+    state.variables
+  );
+  const [requiredIds, varMapping] = computeRequiredVariables(formData);
+  const isValidated = doValidation(requiredIds);
+
+  /*
+  Handle submit
+  This involves going through all the (currently active) fields in the form and
+  using functions for each input to finalize the variables
+  This can take care of things like appending units to strings
+ */
+  const getSubmitValues = () => {
+    // we start off with a base list of the current variables for fields
+    // that don't need any processing on top (for example: checkbox)
+    // the assign here is important because that way state.variable isn't mutated
+    const varList: PorterFormVariableList[] = [
+      Object.assign({}, state.variables),
+    ];
+    const finalFunctions: Record<string, GetFinalVariablesFunction> = {
+      input: getFinalVariablesForStringInput,
+      "array-input": getFinalVariablesForArrayInput,
+      checkbox: getFinalVariablesForCheckbox,
+      "key-value-array": getFinalVariablesForKeyValueArray,
+      select: getFinalVariablesForSelect,
+    };
+
+    const data = props.includeHiddenFields
+      ? restructureToNewFields(props.rawFormData)
+      : props.rawFormData.includeHiddenFields
+      ? restructureToNewFields(props.rawFormData)
+      : formData;
+
+    data?.tabs?.map((tab) =>
+      tab.sections?.map((section) =>
+        section.contents?.map((field) => {
+          if (finalFunctions[field?.type])
+            varList.push(
+              finalFunctions[field?.type](
+                state.variables,
+                field,
+                state.components[field.id]?.state,
+                context
+              )
+            );
+        })
+      )
+    );
+    if (props.doDebug) console.log(Object.assign.apply({}, varList));
+
+    return Object.assign.apply({}, varList);
+  };
+
+  const onSubmitWrapper = () => {
+    props.onSubmit(getSubmitValues());
+  };
+
+  if (props.doDebug) {
+    console.group("Validation Info:");
+    console.log(requiredIds);
+    console.log(varMapping);
+    console.log(isValidated);
+    console.groupEnd();
+  }
+
+  return (
+    <Provider
+      value={{
+        formData: formData,
+        formState: state,
+        dispatchAction: dispatch,
+        isReadOnly: props.isReadOnly,
+        validationInfo: {
+          validated: isValidated,
+          error: isValidated ? null : "Missing required fields",
+        },
+        onSubmit: onSubmitWrapper,
+        getSubmitValues,
+      }}
+    >
+      {props.children}
+    </Provider>
+  );
+};

+ 99 - 0
dashboard/src/components/porter-form/PorterFormWrapper.tsx

@@ -0,0 +1,99 @@
+import React, { useState } from "react";
+
+import PorterForm from "./PorterForm";
+import { PorterFormData } from "./types";
+import { PorterFormContextProvider } from "./PorterFormContextProvider";
+
+type PropsType = {
+  formData: any;
+  valuesToOverride?: any;
+  isReadOnly?: boolean;
+  onSubmit?: (values: any) => void;
+  renderTabContents?: (currentTab: string, submitValues?: any) => any;
+  leftTabOptions?: { value: string; label: string }[];
+  rightTabOptions?: { value: string; label: string }[];
+  saveButtonText?: string;
+  isInModal?: boolean;
+  color?: string;
+  addendum?: any;
+  saveValuesStatus?: string;
+  showStateDebugger?: boolean;
+  isLaunch?: boolean;
+  includeHiddenFields?: boolean;
+};
+
+const PorterFormWrapper: React.FunctionComponent<PropsType> = ({
+  formData,
+  valuesToOverride,
+  isReadOnly,
+  onSubmit,
+  renderTabContents,
+  leftTabOptions,
+  rightTabOptions,
+  saveButtonText,
+  isInModal,
+  color,
+  addendum,
+  saveValuesStatus,
+  showStateDebugger,
+  isLaunch,
+  includeHiddenFields,
+}) => {
+  const hashCode = (s: string) => {
+    return s?.split("").reduce(function (a, b) {
+      a = (a << 5) - a + b.charCodeAt(0);
+      return a & a;
+    }, 0);
+  };
+
+  const getInitialTab = (): string => {
+    if (leftTabOptions?.length > 0) {
+      return leftTabOptions[0].value;
+    } else if (formData?.tabs?.length > 0) {
+      let includedTabs = formData.tabs;
+      if (isLaunch) {
+        includedTabs = formData.tabs.filter(
+          (tab: any) => !tab?.settings?.omitFromLaunch
+        );
+      }
+      return includedTabs[0].name;
+    } else if (rightTabOptions?.length > 0) {
+      return rightTabOptions[0].value;
+    } else {
+      return "";
+    }
+  };
+
+  // Lifted into PorterFormWrapper to allow tab to be remembered on re-render (e.g., on revision select)
+  const [currentTab, setCurrentTab] = useState(getInitialTab());
+
+  return (
+    <React.Fragment key={hashCode(JSON.stringify(formData))}>
+      <PorterFormContextProvider
+        rawFormData={formData as PorterFormData}
+        overrideVariables={valuesToOverride}
+        isReadOnly={isReadOnly}
+        onSubmit={onSubmit}
+        includeHiddenFields={includeHiddenFields}
+      >
+        <PorterForm
+          showStateDebugger={showStateDebugger}
+          addendum={addendum}
+          isReadOnly={isReadOnly}
+          leftTabOptions={leftTabOptions}
+          rightTabOptions={rightTabOptions}
+          renderTabContents={renderTabContents}
+          saveButtonText={saveButtonText}
+          isInModal={isInModal}
+          color={color}
+          saveValuesStatus={saveValuesStatus}
+          currentTab={currentTab}
+          setCurrentTab={setCurrentTab}
+          isLaunch={isLaunch}
+        />
+      </PorterFormContextProvider>
+    </React.Fragment>
+  );
+};
+
+export default PorterFormWrapper;

+ 183 - 0
dashboard/src/components/porter-form/field-components/ArrayInput.tsx

@@ -0,0 +1,183 @@
+import React from "react";
+import styled from "styled-components";
+import { ArrayInputField, ArrayInputFieldState, GetFinalVariablesFunction } from "../types";
+import useFormField from "../hooks/useFormField";
+
+const ArrayInput: React.FC<ArrayInputField> = (props) => {
+  const { state, variables, setVars } = useFormField<ArrayInputFieldState>(
+    props.id,
+    {
+      initVars: {
+        [props.variable]: props.value && props.value[0] ? props.value[0] : [],
+      },
+    }
+  );
+
+  if (state == undefined) return <></>;
+
+  const renderDeleteButton = (values: string[], i: number) => {
+    if (!props.isReadOnly) {
+      return (
+        <DeleteButton
+          onClick={() => {
+            setVars((prev) => {
+              return {
+                [props.variable]: prev[props.variable]
+                  .slice(0, i)
+                  .concat(prev[props.variable].slice(i + 1)),
+              };
+            });
+          }}
+        >
+          <i className="material-icons">cancel</i>
+        </DeleteButton>
+      );
+    }
+  };
+
+  const renderInputList = (values: string[]) => {
+    return (
+      <>
+        {values?.map((value: string, i: number) => {
+          return (
+            <InputWrapper>
+              <Input
+                placeholder=""
+                width="270px"
+                value={value}
+                onChange={(e: any) => {
+                  e.persist();
+                  setVars((prev) => {
+                    return {
+                      [props.variable]: prev[props.variable]?.map(
+                        (t: string, j: number) => {
+                          return i == j ? e.target.value : t;
+                        }
+                      ),
+                    };
+                  });
+                }}
+                disabled={props.isReadOnly}
+              />
+              {renderDeleteButton(values, i)}
+            </InputWrapper>
+          );
+        })}
+      </>
+    );
+  };
+
+  return (
+    <StyledInputArray>
+      <Label>{props.label}</Label>
+      {variables[props.variable] === 0 ? (
+        <></>
+      ) : (
+        renderInputList(variables[props.variable])
+      )}
+      <AddRowButton
+        onClick={() => {
+          setVars((prev) => {
+            return {
+              [props.variable]: [...prev[props.variable], ""],
+            };
+          });
+        }}
+      >
+        <i className="material-icons">add</i> Add Row
+      </AddRowButton>
+    </StyledInputArray>
+  );
+};
+
+export default ArrayInput;
+
+export const getFinalVariablesForArrayInput: GetFinalVariablesFunction = (
+  vars,
+  props: ArrayInputField
+) => {
+  return vars[props.variable]
+    ? {}
+    : {
+        [props.variable]: props.value ? props.value[0] : [],
+      };
+};
+
+const AddRowButton = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 5px;
+  width: 270px;
+  font-size: 13px;
+  color: #aaaabb;
+  height: 30px;
+  border-radius: 3px;
+  cursor: pointer;
+  background: #ffffff11;
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+`;
+
+const DeleteButton = styled.div`
+  width: 15px;
+  height: 15px;
+  display: flex;
+  align-items: center;
+  margin-left: 8px;
+  margin-top: -3px;
+  justify-content: center;
+
+  > i {
+    font-size: 17px;
+    color: #ffffff44;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    :hover {
+      color: #ffffff88;
+    }
+  }
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Input = styled.input`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { disabled?: boolean; width: string }) =>
+    props.width ? props.width : "270px"};
+  color: ${(props: { disabled?: boolean; width: string }) =>
+    props.disabled ? "#ffffff44" : "white"};
+  padding: 5px 10px;
+  height: 35px;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+`;
+
+const StyledInputArray = styled.div`
+  margin-bottom: 15px;
+  margin-top: 22px;
+`;

+ 76 - 0
dashboard/src/components/porter-form/field-components/Checkbox.tsx

@@ -0,0 +1,76 @@
+import React from "react";
+import { CheckboxField, CheckboxFieldState, GetFinalVariablesFunction } from "../types";
+import CheckboxRow from "../../form-components/CheckboxRow";
+import useFormField from "../hooks/useFormField";
+
+interface Props extends CheckboxField {
+  id: string;
+}
+
+const Checkbox: React.FC<Props> = ({
+  id,
+  label,
+  required,
+  variable,
+  isReadOnly,
+  settings,
+  value,
+}) => {
+  const { state, variables, setVars } = useFormField<CheckboxFieldState>(id, {
+    initState: {},
+    initValidation: {
+      validated: !required,
+    },
+    initVars: {
+      [variable]: value ? value[0] : !!settings?.default,
+    },
+  });
+
+  if (state == undefined) {
+    return <></>;
+  }
+
+  return (
+    <CheckboxRow
+      isRequired={required}
+      checked={variables[variable]}
+      toggle={() => {
+        setVars((vars) => {
+          return {
+            ...vars,
+            [variable]: !vars[variable],
+          };
+        });
+      }}
+      label={label}
+      disabled={isReadOnly}
+    />
+  );
+};
+
+export default Checkbox;
+
+export const getFinalVariablesForCheckbox: GetFinalVariablesFunction = (
+  vars,
+  props: CheckboxField
+) => {
+  // Read from revision values if unrendered (and therefore not in form state)
+  if (vars[props.variable] === null || vars[props.variable] === undefined) {
+    if (props.value[0] === false) {
+      return { [props.variable]: false };
+    } else if (props.value[0] === true) {
+      return { [props.variable]: true };
+    }
+  }
+
+  // Read from form state if set by user
+  if (vars[props.variable] === false) {
+    return { [props.variable]: false };
+  } else if (vars[props.variable] === true) {
+    return { [props.variable]: true };
+  }
+
+  return {
+    [props.variable]: props.value ? props.value[0] : !!props.settings?.default,
+  };
+};

+ 108 - 0
dashboard/src/components/porter-form/field-components/Input.tsx

@@ -0,0 +1,108 @@
+import React from "react";
+import InputRow from "../../form-components/InputRow";
+import useFormField from "../hooks/useFormField";
+import {
+  GetFinalVariablesFunction,
+  InputField,
+  StringInputFieldState,
+} from "../types";
+
+const clipOffUnit = (unit: string, x: string) => {
+  if (typeof x === "string" && unit) {
+    return unit === x.slice(x.length - unit.length, x.length)
+      ? x.slice(0, x.length - unit.length)
+      : x;
+  }
+  return x;
+};
+
+const Input: React.FC<InputField> = ({
+  id,
+  variable,
+  label,
+  required,
+  placeholder,
+  info,
+  settings,
+  isReadOnly,
+  value,
+}) => {
+  const {
+    state,
+    variables,
+    setVars,
+    setValidation,
+  } = useFormField<StringInputFieldState>(id, {
+    initValidation: {
+      validated: value
+        ? value[0] !== undefined && value[0] !== "" && value[0] != null
+        : settings?.default != undefined,
+    },
+    initVars: {
+      [variable]: value
+        ? clipOffUnit(settings?.unit, value[0])
+        : settings?.default,
+    },
+  });
+
+  if (state == undefined) {
+    return <></>;
+  }
+
+  const curValue =
+    settings?.type == "number"
+      ? !isNaN(parseFloat(variables[variable]))
+        ? parseFloat(variables[variable])
+        : ""
+      : variables[variable] || "";
+
+  return (
+    <InputRow
+      width="100%"
+      type={settings?.type || "text"}
+      value={curValue}
+      unit={settings?.unit}
+      setValue={(x: string | number) => {
+        setVars((vars) => {
+          return {
+            ...vars,
+            [variable]: x,
+          };
+        });
+        setValidation((prev) => {
+          return {
+            ...prev,
+            validated:
+              settings?.type == "number"
+                ? !isNaN(x as number)
+                : !!(x as string).trim(),
+          };
+        });
+      }}
+      label={label}
+      isRequired={required}
+      placeholder={placeholder}
+      info={info}
+      disabled={isReadOnly}
+    />
+  );
+};
+
+export const getFinalVariablesForStringInput: GetFinalVariablesFunction = (
+  vars,
+  props: InputField
+) => {
+  const val =
+    vars[props.variable] ||
+    (props.value
+      ? clipOffUnit(props.settings?.unit, props.value[0])
+      : props.settings?.default);
+  return {
+    [props.variable]:
+      props.settings?.unit && !props.settings.omitUnitFromValue
+        ? val + props.settings.unit
+        : val,
+  };
+};
+
+export default Input;

+ 516 - 0
dashboard/src/components/porter-form/field-components/KeyValueArray.tsx

@@ -0,0 +1,516 @@
+import React from "react";
+import { GetFinalVariablesFunction, KeyValueArrayField, KeyValueArrayFieldState } from "../types";
+import sliders from "../../../assets/sliders.svg";
+import upload from "../../../assets/upload.svg";
+import styled from "styled-components";
+import useFormField from "../hooks/useFormField";
+import Modal from "../../../main/home/modals/Modal";
+import LoadEnvGroupModal from "../../../main/home/modals/LoadEnvGroupModal";
+import EnvEditorModal from "../../../main/home/modals/EnvEditorModal";
+
+interface Props extends KeyValueArrayField {
+  id: string;
+}
+
+const KeyValueArray: React.FC<Props> = (props) => {
+  const { state, setState, variables } = useFormField<KeyValueArrayFieldState>(
+    props.id,
+    {
+      initState: {
+        values:
+          props.value && props.value[0]
+            ? (Object.entries(props.value[0])?.map(([k, v]) => {
+                return { key: k, value: v };
+              }) as any[])
+            : [],
+        showEnvModal: false,
+        showEditorModal: false,
+      },
+    }
+  );
+
+  if (state == undefined) return <></>;
+
+  const parseEnv = (src: any, options: any) => {
+    const debug = Boolean(options && options.debug);
+    const obj = {} as Record<string, string>;
+    const NEWLINE = "\n";
+    const RE_INI_KEY_VAL = /^\s*([\w.-]+)\s*=\s*(.*)?\s*$/;
+    const RE_NEWLINES = /\\n/g;
+    const NEWLINES_MATCH = /\n|\r|\r\n/;
+
+    // convert Buffers before splitting into lines and processing
+    src
+      .toString()
+      .split(NEWLINES_MATCH)
+      .forEach(function (line: any, idx: any) {
+        // matching "KEY' and 'VAL' in 'KEY=VAL'
+        const keyValueArr = line.match(RE_INI_KEY_VAL);
+        // matched?
+        if (keyValueArr != null) {
+          const key = keyValueArr[1];
+          // default undefined or missing values to empty string
+          let val = keyValueArr[2] || "";
+          const end = val.length - 1;
+          const isDoubleQuoted = val[0] === '"' && val[end] === '"';
+          const isSingleQuoted = val[0] === "'" && val[end] === "'";
+
+          // if single or double quoted, remove quotes
+          if (isSingleQuoted || isDoubleQuoted) {
+            val = val.substring(1, end);
+
+            // if double quoted, expand newlines
+            if (isDoubleQuoted) {
+              val = val.replace(RE_NEWLINES, NEWLINE);
+            }
+          } else {
+            // remove surrounding whitespace
+            val = val.trim();
+          }
+
+          obj[key] = val;
+        } else if (debug) {
+          console.log(
+            `did not match key and value when parsing line ${idx + 1}: ${line}`
+          );
+        }
+      });
+
+    return obj;
+  };
+
+  const readFile = (env: string) => {
+    let envObj = parseEnv(env, null);
+    let push = true;
+
+    for (let key in envObj) {
+      for (var i = 0; i < state.values.length; i++) {
+        let existingKey = state.values[i]["key"];
+        if (key === existingKey) {
+          state.values[i]["value"] = envObj[key];
+          push = false;
+        }
+      }
+
+      if (push) {
+        setState((prev) => {
+          return {
+            values: [...prev.values, { key, value: envObj[key] }],
+          };
+        });
+      }
+    }
+  };
+
+  const renderEditorModal = () => {
+    if (state.showEditorModal) {
+      return (
+        <Modal
+          onRequestClose={() =>
+            setState(() => {
+              return { showEditorModal: false };
+            })
+          }
+          width="60%"
+          height="80%"
+        >
+          <EnvEditorModal
+            closeModal={() =>
+              setState(() => {
+                return { showEditorModal: false };
+              })
+            }
+            setEnvVariables={(envFile: string) => readFile(envFile)}
+          />
+        </Modal>
+      );
+    }
+  };
+
+  const getProcessedValues = (
+    objectArray: { key: string; value: string }[]
+  ): any => {
+    let obj = {} as any;
+    objectArray?.forEach(({ key, value }) => {
+      obj[key] = value;
+    });
+    return obj;
+  };
+
+  const renderEnvModal = () => {
+    if (state.showEnvModal) {
+      return (
+        <Modal
+          onRequestClose={() =>
+            setState(() => {
+              return { showEnvModal: false };
+            })
+          }
+          width="765px"
+          height="542px"
+        >
+          <LoadEnvGroupModal
+            existingValues={getProcessedValues(state.values)}
+            namespace={variables.namespace}
+            clusterId={variables.clusterId}
+            closeModal={() =>
+              setState(() => {
+                return {
+                  showEnvModal: false,
+                };
+              })
+            }
+            setValues={(values) => {
+              setState((prev) => {
+                return {
+                  // might be broken
+                  values: [
+                    ...prev.values,
+                    ...Object.entries(values)?.map(([k, v]) => {
+                      return {
+                        key: k,
+                        value: v,
+                      };
+                    }),
+                  ],
+                };
+              });
+            }}
+          />
+        </Modal>
+      );
+    }
+  };
+
+  const renderDeleteButton = (i: number) => {
+    if (!props.isReadOnly) {
+      return (
+        <DeleteButton
+          onClick={() => {
+            state.values.splice(i, 1);
+            setState((prev) => {
+              return {
+                values: prev.values
+                  .slice(0, i + 1)
+                  .concat(prev.values.slice(i + 1, prev.values.length)),
+              };
+            });
+          }}
+        >
+          <i className="material-icons">cancel</i>
+        </DeleteButton>
+      );
+    }
+  };
+
+  const renderHiddenOption = (hidden: boolean, i: number) => {
+    if (props.secretOption && hidden) {
+      return (
+        <HideButton>
+          <i className="material-icons">lock</i>
+        </HideButton>
+      );
+    }
+  };
+
+  const renderInputList = () => {
+    return (
+      <>
+        {state.values?.map((entry: any, i: number) => {
+          // Preprocess non-string env values set via raw Helm values
+          let { value } = entry;
+          if (typeof value === "object") {
+            value = JSON.stringify(value);
+          } else if (typeof value === "number" || typeof value === "boolean") {
+            value = value.toString();
+          }
+
+          return (
+            <InputWrapper key={i}>
+              <Input
+                placeholder="ex: key"
+                width="270px"
+                value={entry.key}
+                onChange={(e: any) => {
+                  e.persist();
+                  setState((prev) => {
+                    return {
+                      values: prev.values?.map((t, j) => {
+                        if (j == i) {
+                          return {
+                            ...t,
+                            key: e.target.value,
+                          };
+                        }
+                        return t;
+                      }),
+                    };
+                  });
+                }}
+                disabled={props.isReadOnly || value.includes("PORTERSECRET")}
+                spellCheck={false}
+              />
+              <Spacer />
+              <Input
+                placeholder="ex: value"
+                width="270px"
+                value={value}
+                onChange={(e: any) => {
+                  e.persist();
+                  setState((prev) => {
+                    return {
+                      values: prev.values?.map((t, j) => {
+                        if (j == i) {
+                          return {
+                            ...t,
+                            value: e.target.value,
+                          };
+                        }
+                        return t;
+                      }),
+                    };
+                  });
+                }}
+                disabled={props.isReadOnly || value.includes("PORTERSECRET")}
+                type={value.includes("PORTERSECRET") ? "password" : "text"}
+                spellCheck={false}
+              />
+              {renderDeleteButton(i)}
+              {renderHiddenOption(value.includes("PORTERSECRET"), i)}
+            </InputWrapper>
+          );
+        })}
+      </>
+    );
+  };
+
+  return (
+    <>
+      <StyledInputArray>
+        <Label>{props.label}</Label>
+        {state.values.length === 0 ? <></> : renderInputList()}
+        {props.isReadOnly ? (
+          <></>
+        ) : (
+          <InputWrapper>
+            <AddRowButton
+              onClick={() => {
+                setState((prev) => {
+                  return {
+                    values: [...prev.values, { key: "", value: "" }],
+                  };
+                });
+              }}
+            >
+              <i className="material-icons">add</i> Add Row
+            </AddRowButton>
+            <Spacer />
+            {variables.namespace && props.envLoader && (
+              <LoadButton
+                onClick={() =>
+                  setState((prev) => {
+                    return {
+                      showEnvModal: !prev.showEnvModal,
+                    };
+                  })
+                }
+              >
+                <img src={sliders} /> Load from Env Group
+              </LoadButton>
+            )}
+            {props.fileUpload && (
+              <UploadButton
+                onClick={() => {
+                  setState((prev) => {
+                    return {
+                      showEditorModal: true,
+                    };
+                  });
+                }}
+              >
+                <img src={upload} /> Copy from File
+              </UploadButton>
+            )}
+          </InputWrapper>
+        )}
+      </StyledInputArray>
+      {renderEnvModal()}
+      {renderEditorModal()}
+    </>
+  );
+};
+
+export const getFinalVariablesForKeyValueArray: GetFinalVariablesFunction = (
+  vars,
+  props: KeyValueArrayField,
+  state: KeyValueArrayFieldState
+) => {
+  if (!state) {
+    return {
+      [props.variable]: props.value ? props.value[0] : [],
+    };
+  }
+
+  let obj = {} as any;
+  const rg = /(?:^|[^\\])(\\n)/g;
+  const fixNewlines = (s: string) => {
+    while (rg.test(s)) {
+      s = s.replace(rg, (str) => {
+        if (str.length == 2) return "\n";
+        if (str[0] != "\\") return str[0] + "\n";
+        return "\\n";
+      });
+    }
+    return s;
+  };
+  const isNumber = (s: string) => {
+    return !isNaN(!s ? NaN : Number(String(s).trim()));
+  };
+  state.values.forEach((entry: any, i: number) => {
+    if (isNumber(entry.value)) {
+      obj[entry.key] = entry.value;
+    } else {
+      obj[entry.key] = fixNewlines(entry.value);
+    }
+  });
+  return {
+    [props.variable]: obj,
+  };
+};
+
+export default KeyValueArray;
+
+const Spacer = styled.div`
+  width: 10px;
+  height: 20px;
+`;
+
+const AddRowButton = styled.div`
+  display: flex;
+  align-items: center;
+  width: 270px;
+  font-size: 13px;
+  color: #aaaabb;
+  height: 32px;
+  border-radius: 3px;
+  cursor: pointer;
+  background: #ffffff11;
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+`;
+
+const LoadButton = styled(AddRowButton)`
+  background: none;
+  border: 1px solid #ffffff55;
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  > img {
+    width: 14px;
+    margin-left: 10px;
+    margin-right: 12px;
+  }
+`;
+
+const UploadButton = styled(AddRowButton)`
+  background: none;
+  position: relative;
+  margin-left: 10px;
+  border: 1px solid #ffffff55;
+  > i {
+    color: #ffffff44;
+    font-size: 16px;
+    margin-left: 8px;
+    margin-right: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+  > img {
+    width: 14px;
+    margin-left: 10px;
+    margin-right: 12px;
+  }
+`;
+
+const DeleteButton = styled.div`
+  width: 15px;
+  height: 15px;
+  display: flex;
+  align-items: center;
+  margin-left: 8px;
+  margin-top: -3px;
+  justify-content: center;
+
+  > i {
+    font-size: 17px;
+    color: #ffffff44;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    :hover {
+      color: #ffffff88;
+    }
+  }
+`;
+
+const HideButton = styled(DeleteButton)`
+  margin-top: -5px;
+  > i {
+    font-size: 19px;
+    cursor: default;
+    :hover {
+      color: #ffffff44;
+    }
+  }
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  margin-top: 5px;
+`;
+
+const Input = styled.input`
+  outline: none;
+  border: none;
+  margin-bottom: 5px;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { disabled?: boolean; width: string }) =>
+    props.width ? props.width : "270px"};
+  color: ${(props: { disabled?: boolean; width: string }) =>
+    props.disabled ? "#ffffff44" : "white"};
+  padding: 5px 10px;
+  height: 35px;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+`;
+
+const StyledInputArray = styled.div`
+  margin-bottom: 15px;
+  margin-top: 22px;
+`;

+ 0 - 0
dashboard/src/components/values-form/MultiSelect.tsx → dashboard/src/components/porter-form/field-components/MultiSelect.tsx


+ 32 - 0
dashboard/src/components/porter-form/field-components/ResourceList.tsx

@@ -0,0 +1,32 @@
+import React from "react";
+import { ResourceListField } from "../types";
+import ExpandableResource from "../../ExpandableResource";
+import styled from "styled-components";
+
+const ResourceList: React.FC<ResourceListField> = (props) => {
+  return (
+    <ResourceListWrapper>
+      {props.value?.map((resource: any, i: number) => {
+        if (resource.data) {
+          return (
+            <ExpandableResource
+              key={i}
+              resource={resource}
+              isLast={i === props.value.length - 1}
+              roundAllCorners={true}
+            />
+          );
+        }
+      })}
+    </ResourceListWrapper>
+  );
+};
+
+export default ResourceList;
+
+const ResourceListWrapper = styled.div`
+  margin-bottom: 15px;
+  margin-top: 20px;
+  border-radius: 8px;
+  overflow: hidden;
+`;

+ 96 - 0
dashboard/src/components/porter-form/field-components/Select.tsx

@@ -0,0 +1,96 @@
+import React, { useContext } from "react";
+import { GetFinalVariablesFunction, SelectField, SelectFieldState } from "../types";
+import Selector from "../../Selector";
+import styled from "styled-components";
+import useFormField from "../hooks/useFormField";
+import { Context } from "../../../shared/Context";
+
+const Select: React.FC<SelectField> = (props) => {
+  const { currentCluster } = useContext(Context);
+  const { variables, setVars } = useFormField<SelectFieldState>(props.id, {
+    initVars: {
+      [props.variable]: props.value
+        ? props.value[0]
+        : props.settings.default
+        ? props.settings.default
+        : props.settings.type == "provider"
+        ? ({
+            gke: "gcp",
+            eks: "aws",
+            doks: "do",
+          } as Record<string, string>)[currentCluster.service] || "aws"
+        : props.settings.options[0].value,
+    },
+  });
+
+  const providerOptions = [
+    { value: "aws", label: "Amazon Web Services (AWS)" },
+    { value: "gcp", label: "Google Cloud Platform (GCP)" },
+    { value: "do", label: "DigitalOcean" },
+  ];
+
+  return (
+    <StyledSelectRow>
+      <Label>{props.label}</Label>
+      <SelectWrapper>
+        <Selector
+          activeValue={variables[props.variable]}
+          setActiveValue={(val) => {
+            setVars(() => {
+              return {
+                [props.variable]: val,
+              };
+            });
+          }}
+          options={
+            props.settings.type == "provider"
+              ? providerOptions
+              : props.settings.options
+          }
+          dropdownLabel={props.dropdownLabel}
+          width={props.width || "270px"}
+          dropdownWidth={props.width}
+          dropdownMaxHeight={props.dropdownMaxHeight}
+        />
+      </SelectWrapper>
+    </StyledSelectRow>
+  );
+};
+
+export default Select;
+
+export const getFinalVariablesForSelect: GetFinalVariablesFunction = (
+  vars,
+  props: SelectField,
+  state,
+  context
+) => {
+  return vars[props.variable]
+    ? {}
+    : {
+        [props.variable]: props.value
+          ? props.value[0]
+          : props.settings.default
+          ? props.settings.default
+          : props.settings.type == "provider"
+          ? ({
+              gke: "gcp",
+              eks: "aws",
+              doks: "do",
+            } as Record<string, string>)[context.currentCluster.service] ||
+            "aws"
+          : props.settings.options[0].value,
+      };
+};
+
+const SelectWrapper = styled.div``;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+`;
+
+const StyledSelectRow = styled.div`
+  margin-bottom: 15px;
+  margin-top: 20px;
+`;

+ 23 - 0
dashboard/src/components/porter-form/field-components/ServiceIPList.tsx

@@ -0,0 +1,23 @@
+import React from "react";
+import { ServiceIPListField } from "../types";
+import ServiceRow from "./ServiceRow";
+import styled from "styled-components";
+
+const ServiceIPList: React.FC<ServiceIPListField> = (props) => {
+  return (
+    <ResourceList>
+      {props.value?.map((service: any, i: number) => {
+        return <ServiceRow service={service} key={i} />;
+      })}
+    </ResourceList>
+  );
+};
+
+export default ServiceIPList;
+
+const ResourceList = styled.div`
+  margin-bottom: 15px;
+  margin-top: 20px;
+  border-radius: 8px;
+  overflow: hidden;
+`;

+ 114 - 0
dashboard/src/components/porter-form/field-components/ServiceRow.tsx

@@ -0,0 +1,114 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import { Context } from "shared/Context";
+import { hardcodedIcons, hardcodedNames } from "shared/hardcodedNameDict";
+
+type PropsType = {
+  service: {
+    clusterIP: string;
+    name: string;
+    release: string;
+    app: string;
+    namespace: string;
+    type?: string;
+  };
+};
+
+type StateType = any;
+
+export default class ServiceRow extends Component<PropsType, StateType> {
+  render() {
+    let { clusterIP, name, namespace, type, app, release } = this.props.service;
+    name = name || release;
+    type = type || app;
+    return (
+      <>
+        {name &&
+          type &&
+          hardcodedNames[type] &&
+          hardcodedIcons[type] &&
+          namespace !== "kube-system" && (
+            <StyledServiceRow>
+              <Flex>
+                <Icon src={hardcodedIcons[type]} />
+                <Type>{hardcodedNames[type]}</Type>
+                <Name>{name}</Name> <Dash>-</Dash> <IP>{clusterIP}</IP>
+              </Flex>
+              <TagWrapper>
+                Namespace: <NamespaceTag>{namespace}</NamespaceTag>
+              </TagWrapper>
+            </StyledServiceRow>
+          )}
+      </>
+    );
+  }
+}
+
+ServiceRow.contextType = Context;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const TagWrapper = styled.div`
+  float: right;
+  height: 20px;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  border-right: 0;
+  border-radius: 3px;
+  padding-left: 5px;
+`;
+
+const NamespaceTag = styled.div`
+  height: 20px;
+  margin-left: 6px;
+  color: #aaaabb;
+  border-radius: 3px;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding-left: 3px;
+  border-top-left-radius: 0px;
+  border-bottom-left-radius: 0px;
+`;
+
+const Dash = styled.div`
+  margin-right: 10px;
+`;
+
+const Icon = styled.img`
+  width: 20px;
+  margin-right: 12px;
+`;
+
+const Type = styled.div`
+  color: #aaaabb;
+  margin-right: 15px;
+`;
+
+const Name = styled.div`
+  margin-right: 10px;
+`;
+
+const IP = styled.div`
+  user-select: text;
+  font-weight: 500;
+`;
+
+const StyledServiceRow = styled.div`
+  width: 100%;
+  height: 40px;
+  background: #ffffff11;
+  margin-bottom: 15px;
+  border-radius: 5px;
+  padding: 15px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+`;

+ 3 - 3
dashboard/src/components/forms/VeleroForm.tsx → dashboard/src/components/porter-form/field-components/VeleroForm.tsx

@@ -1,8 +1,8 @@
 import React, { Component } from "react";
 
-import Heading from "../values-form/Heading";
-import InputRow from "../values-form/InputRow";
-import MultiSelect from "../values-form/MultiSelect";
+import Heading from "../../form-components/Heading";
+import InputRow from "../../form-components/InputRow";
+import MultiSelect from "./MultiSelect";
 
 type PropsType = {};
 

+ 81 - 0
dashboard/src/components/porter-form/hooks/useFormField.tsx

@@ -0,0 +1,81 @@
+import { useContext, useEffect } from "react";
+import { PorterFormContext } from "../PorterFormContextProvider";
+import { PorterFormFieldFieldState, PorterFormFieldValidationState, PorterFormVariableList } from "../types";
+
+interface FormFieldData<T> {
+  state: T;
+  variables: PorterFormVariableList;
+  setState: (setFunc: (prev: T) => Partial<T>) => void;
+  setVars: (
+    setFunc: (vars: PorterFormVariableList) => PorterFormVariableList
+  ) => void;
+  setValidation: (
+    setFunc: (
+      state: PorterFormFieldValidationState
+    ) => PorterFormFieldValidationState
+  ) => void;
+}
+
+interface Options<T> {
+  initState?: T;
+  initValidation?: Partial<PorterFormFieldValidationState>;
+  initVars?: PorterFormVariableList;
+}
+
+const useFormField = <T extends PorterFormFieldFieldState>(
+  fieldId: string,
+  { initState, initVars, initValidation }: Options<T>
+): FormFieldData<T> => {
+  const { dispatchAction, formState } = useContext(PorterFormContext);
+
+  useEffect(() => {
+    dispatchAction({
+      type: "init-field",
+      id: fieldId,
+      initValue: initState || {},
+      initValidation: initValidation || {
+        validated: false,
+      },
+      initVars: initVars || {},
+    });
+  }, []);
+
+  const setState = (updateFunc: (prev: T) => Partial<T>) => {
+    dispatchAction({
+      type: "update-field",
+      id: fieldId,
+      updateFunc,
+    });
+  };
+
+  const setVars = (
+    mutateFunc: (vars: PorterFormVariableList) => PorterFormVariableList
+  ) => {
+    dispatchAction({
+      type: "mutate-vars",
+      mutateFunc,
+    });
+  };
+
+  const setValidation = (
+    updateFunc: (
+      state: PorterFormFieldValidationState
+    ) => PorterFormFieldValidationState
+  ) => {
+    dispatchAction({
+      id: fieldId,
+      type: "update-validation",
+      updateFunc,
+    });
+  };
+
+  return {
+    state: formState.components[fieldId]?.state as T,
+    variables: formState.variables,
+    setState,
+    setVars,
+    setValidation,
+  };
+};
+
+export default useFormField;

+ 248 - 0
dashboard/src/components/porter-form/types.ts

@@ -0,0 +1,248 @@
+/*
+  Interfaces for the form YAML
+  Will be merged with shared types later
+*/
+
+// YAML Field interfaces
+
+import { ContextProps } from "../../shared/types";
+
+export interface GenericField {
+  id: string;
+}
+
+export interface GenericInputField extends GenericField {
+  isReadOnly?: boolean;
+  required?: boolean;
+  variable: string;
+  settings?: any;
+
+  // Read in value from Helm for existing revisions
+  value?: any[];
+}
+
+export interface HeadingField extends GenericField {
+  type: "heading";
+  label: string;
+}
+
+export interface SubtitleField extends GenericField {
+  type: "subtitle";
+  label: string;
+}
+
+export interface ServiceIPListField extends GenericField {
+  type: "service-ip-list";
+  value: any[];
+}
+
+export interface ResourceListField extends GenericField {
+  type: "resource-list";
+  value: any[];
+}
+
+export interface VeleroBackupField extends GenericField {
+  type: "velero-create-backup";
+}
+
+export interface InputField extends GenericInputField {
+  type: "input";
+  label?: string;
+  placeholder?: string;
+  info?: string;
+  settings?: {
+    type?: "text" | "password" | "number";
+    unit?: string;
+    omitUnitFromValue?: boolean;
+    default: string | number;
+  };
+}
+
+export interface CheckboxField extends GenericInputField {
+  type: "checkbox";
+  label?: string;
+  settings?: {
+    default: boolean;
+  };
+}
+
+export interface KeyValueArrayField extends GenericInputField {
+  type: "key-value-array";
+  label?: string;
+  secretOption?: boolean;
+  envLoader?: boolean;
+  fileUpload?: boolean;
+  settings?: {
+    type: "env" | "normal";
+  };
+}
+
+export interface ArrayInputField extends GenericInputField {
+  type: "array-input";
+  label?: string;
+}
+
+export interface SelectField extends GenericInputField {
+  type: "select";
+  settings:
+    | {
+        type: "normal";
+        options: { value: string; label: string }[];
+        default?: string;
+      }
+    | {
+        type: "provider";
+        default?: string;
+      };
+  width: string;
+  label?: string;
+  dropdownLabel?: string;
+  dropdownWidth?: number;
+  dropdownMaxHeight?: string;
+}
+
+export interface VariableField extends GenericInputField {
+  type: "variable";
+  settings?: {
+    default: any;
+  };
+}
+
+export type FormField =
+  | HeadingField
+  | SubtitleField
+  | InputField
+  | CheckboxField
+  | KeyValueArrayField
+  | ArrayInputField
+  | SelectField
+  | ServiceIPListField
+  | ResourceListField
+  | VeleroBackupField
+  | VariableField;
+
+export interface ShowIfAnd {
+  and: ShowIf[];
+}
+
+export interface ShowIfOr {
+  or: ShowIf[];
+}
+
+export interface ShowIfNot {
+  not: ShowIf;
+}
+
+export type ShowIf = string | ShowIfAnd | ShowIfOr | ShowIfNot;
+
+export interface Section {
+  name: string;
+  show_if?: ShowIf;
+  contents: FormField[];
+}
+
+export interface Tab {
+  name: string;
+  label: string;
+  sections: Section[];
+  settings?: {
+    omitFromLaunch?: boolean;
+  };
+}
+
+export interface PorterFormData {
+  name: string;
+  hasSource: boolean;
+  includeHiddenFields: boolean;
+  tabs: Tab[];
+}
+
+export interface PorterFormValidationInfo {
+  validated: boolean;
+  error?: string;
+}
+
+// internal field state interfaces
+export interface StringInputFieldState {}
+export interface CheckboxFieldState {}
+export interface KeyValueArrayFieldState {
+  values: {
+    key: string;
+    value: string;
+  }[];
+  showEnvModal: boolean;
+  showEditorModal: boolean;
+}
+export interface ArrayInputFieldState {}
+export interface SelectFieldState {}
+
+export type PorterFormFieldFieldState =
+  | StringInputFieldState
+  | CheckboxFieldState
+  | KeyValueArrayField
+  | ArrayInputFieldState
+  | SelectFieldState;
+
+// reducer interfaces
+
+export interface PorterFormFieldValidationState {
+  validated: boolean;
+}
+
+export interface PorterFormVariableList {
+  [key: string]: any;
+}
+
+export interface PorterFormState {
+  components: {
+    [key: string]: {
+      state: PorterFormFieldFieldState;
+    };
+  };
+  validation: {
+    [key: string]: PorterFormFieldValidationState;
+  };
+  variables: PorterFormVariableList;
+}
+
+export interface PorterFormInitFieldAction {
+  type: "init-field";
+  id: string;
+  initValue: PorterFormFieldFieldState;
+  initValidation?: Partial<PorterFormFieldValidationState>;
+  initVars?: PorterFormVariableList;
+}
+
+export interface PorterFormUpdateFieldAction {
+  type: "update-field";
+  id: string;
+  updateFunc: (
+    prev: PorterFormFieldFieldState
+  ) => Partial<PorterFormFieldFieldState>;
+}
+
+export interface PorterFormUpdateValidationAction {
+  type: "update-validation";
+  id: string;
+  updateFunc: (
+    prev: PorterFormFieldValidationState
+  ) => PorterFormFieldValidationState;
+}
+
+export interface PorterFormMutateVariablesAction {
+  type: "mutate-vars";
+  mutateFunc: (prev: PorterFormVariableList) => PorterFormVariableList;
+}
+
+export type PorterFormAction =
+  | PorterFormInitFieldAction
+  | PorterFormUpdateFieldAction
+  | PorterFormMutateVariablesAction
+  | PorterFormUpdateValidationAction;
+
+export type GetFinalVariablesFunction = (
+  vars: PorterFormVariableList,
+  props: FormField,
+  state: PorterFormFieldFieldState,
+  context: Partial<ContextProps>
+) => PorterFormVariableList;

+ 108 - 128
dashboard/src/components/repo-selector/ActionConfEditor.tsx

@@ -1,15 +1,14 @@
-import React, { Component } from "react";
+import React from "react";
 import styled from "styled-components";
 
 import { ActionConfigType } from "shared/types";
-import { Context } from "shared/Context";
 
 import RepoList from "./RepoList";
 import BranchList from "./BranchList";
 import ContentsList from "./ContentsList";
 import ActionDetails from "./ActionDetails";
 
-type PropsType = {
+type Props = {
   actionConfig: ActionConfigType | null;
   branch: string;
   setActionConfig: (x: ActionConfigType) => void;
@@ -27,11 +26,6 @@ type PropsType = {
   selectedRegistry: any;
 };
 
-type StateType = {
-  loading: boolean;
-  error: boolean;
-};
-
 const defaultActionConfig: ActionConfigType = {
   git_repo: "",
   image_repo_uri: "",
@@ -39,133 +33,116 @@ const defaultActionConfig: ActionConfigType = {
   git_repo_id: 0,
 };
 
-export default class ActionConfEditor extends Component<PropsType, StateType> {
-  state = {
-    loading: true,
-    error: false,
-  };
+const ActionConfEditor: React.FC<Props> = (props) => {
+  const { actionConfig, setBranch, setActionConfig, branch } = props;
 
-  renderExpanded = () => {
-    let { actionConfig, branch, setActionConfig, setBranch } = this.props;
-
-    if (!actionConfig.git_repo) {
-      return (
-        <ExpandedWrapper>
-          <RepoList
+  if (!actionConfig.git_repo) {
+    return (
+      <ExpandedWrapperAlt>
+        <RepoList
+          actionConfig={actionConfig}
+          setActionConfig={(x: ActionConfigType) => setActionConfig(x)}
+          readOnly={false}
+        />
+      </ExpandedWrapperAlt>
+    );
+  } else if (!branch) {
+    return (
+      <>
+        <ExpandedWrapperAlt>
+          <BranchList
             actionConfig={actionConfig}
-            setActionConfig={(x: ActionConfigType) => setActionConfig(x)}
-            readOnly={false}
+            setBranch={(branch: string) => setBranch(branch)}
           />
-        </ExpandedWrapper>
-      );
-    } else if (!branch) {
-      return (
-        <>
-          <ExpandedWrapperAlt>
-            <BranchList
-              actionConfig={actionConfig}
-              setBranch={(branch: string) => setBranch(branch)}
-            />
-          </ExpandedWrapperAlt>
-          <Br />
-          <BackButton
-            width="135px"
-            onClick={() => {
-              setActionConfig({ ...defaultActionConfig });
-            }}
-          >
-            <i className="material-icons">keyboard_backspace</i>
-            Select Repo
-          </BackButton>
-        </>
-      );
-    } else if (!this.props.dockerfilePath && !this.props.folderPath) {
-      return (
-        <>
-          <ExpandedWrapperAlt>
-            <ContentsList
-              actionConfig={actionConfig}
-              branch={branch}
-              setActionConfig={setActionConfig}
-              setDockerfilePath={(x: string) => this.props.setDockerfilePath(x)}
-              setProcfilePath={(x: string) => this.props.setProcfilePath(x)}
-              setFolderPath={(x: string) => this.props.setFolderPath(x)}
-            />
-          </ExpandedWrapperAlt>
-          <Br />
-          <BackButton
-            width="145px"
-            onClick={() => {
-              setBranch("");
-            }}
-          >
-            <i className="material-icons">keyboard_backspace</i>
-            Select Branch
-          </BackButton>
-        </>
-      );
-    }
-
-    if (
-      this.props.procfilePath &&
-      this.props.folderPath &&
-      !this.props.dockerfilePath &&
-      !this.props.procfileProcess
-    ) {
-      return (
-        <>
-          <ExpandedWrapperAlt>
-            <ContentsList
-              actionConfig={actionConfig}
-              branch={branch}
-              setActionConfig={setActionConfig}
-              procfilePath={this.props.procfilePath}
-              setDockerfilePath={(x: string) => this.props.setDockerfilePath(x)}
-              setProcfilePath={(x: string) => this.props.setProcfilePath(x)}
-              setProcfileProcess={(x: string) =>
-                this.props.setProcfileProcess(x)
-              }
-              setFolderPath={(x: string) => this.props.setFolderPath(x)}
-            />
-          </ExpandedWrapperAlt>
-          <Br />
-          <BackButton
-            width="145px"
-            onClick={() => {
-              setBranch("");
-            }}
-          >
-            <i className="material-icons">keyboard_backspace</i>
-            Select Branch
-          </BackButton>
-        </>
-      );
-    }
-
+        </ExpandedWrapperAlt>
+        <Br />
+        <BackButton
+          width="135px"
+          onClick={() => {
+            setActionConfig({ ...defaultActionConfig });
+          }}
+        >
+          <i className="material-icons">keyboard_backspace</i>
+          Select Repo
+        </BackButton>
+      </>
+    );
+  } else if (!props.dockerfilePath && !props.folderPath) {
     return (
-      <ActionDetails
-        branch={branch}
-        setDockerfilePath={this.props.setDockerfilePath}
-        setFolderPath={this.props.setFolderPath}
-        setProcfilePath={this.props.setProcfilePath}
-        setProcfileProcess={this.props.setProcfileProcess}
-        actionConfig={actionConfig}
-        setActionConfig={setActionConfig}
-        dockerfilePath={this.props.dockerfilePath}
-        procfilePath={this.props.procfilePath}
-        folderPath={this.props.folderPath}
-        setSelectedRegistry={this.props.setSelectedRegistry}
-        selectedRegistry={this.props.selectedRegistry}
-      />
+      <>
+        <ContentsList
+          actionConfig={actionConfig}
+          branch={branch}
+          setActionConfig={setActionConfig}
+          setDockerfilePath={(x: string) => props.setDockerfilePath(x)}
+          setProcfilePath={(x: string) => props.setProcfilePath(x)}
+          setFolderPath={(x: string) => props.setFolderPath(x)}
+        />
+        <Br />
+        <BackButton
+          width="145px"
+          onClick={() => {
+            setBranch("");
+          }}
+        >
+          <i className="material-icons">keyboard_backspace</i>
+          Select Branch
+        </BackButton>
+      </>
     );
-  };
+  }
 
-  render() {
-    return <>{this.renderExpanded()}</>;
+  if (
+    props.procfilePath &&
+    props.folderPath &&
+    !props.dockerfilePath &&
+    !props.procfileProcess
+  ) {
+    return (
+      <>
+        <ContentsList
+          actionConfig={actionConfig}
+          branch={branch}
+          setActionConfig={setActionConfig}
+          procfilePath={props.procfilePath}
+          setDockerfilePath={(x: string) => props.setDockerfilePath(x)}
+          setProcfilePath={(x: string) => props.setProcfilePath(x)}
+          setProcfileProcess={(x: string) => props.setProcfileProcess(x)}
+          setFolderPath={(x: string) => props.setFolderPath(x)}
+        />
+        <Br />
+        <BackButton
+          width="145px"
+          onClick={() => {
+            setBranch("");
+          }}
+        >
+          <i className="material-icons">keyboard_backspace</i>
+          Select Branch
+        </BackButton>
+      </>
+    );
   }
-}
 
-ActionConfEditor.contextType = Context;
+  return (
+    <ActionDetails
+      branch={branch}
+      setDockerfilePath={props.setDockerfilePath}
+      setFolderPath={props.setFolderPath}
+      setProcfilePath={props.setProcfilePath}
+      setProcfileProcess={props.setProcfileProcess}
+      actionConfig={actionConfig}
+      setActionConfig={setActionConfig}
+      dockerfilePath={props.dockerfilePath}
+      procfilePath={props.procfilePath}
+      folderPath={props.folderPath}
+      setSelectedRegistry={props.setSelectedRegistry}
+      selectedRegistry={props.selectedRegistry}
+    />
+  );
+};
+
+export default ActionConfEditor;
 
 const Br = styled.div`
   width: 100%;
@@ -201,7 +178,10 @@ const ExpandedWrapper = styled.div`
   overflow-y: auto;
 `;
 
-const ExpandedWrapperAlt = styled(ExpandedWrapper)``;
+const ExpandedWrapperAlt = styled(ExpandedWrapper)`
+  border: 0;
+  overflow: hidden;
+`;
 
 const BackButton = styled.div`
   display: flex;

Some files were not shown because too many files changed in this diff