فهرست منبع

merge with master and fix conflicts

Alexander Belanger 4 سال پیش
والد
کامیت
9e392993e3
100فایلهای تغییر یافته به همراه9531 افزوده شده و 1232 حذف شده
  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]
 [build]
 # Just plain old shell command. You could use `make` as well.
 # 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`.
 # Binary file yields from `cmd`.
-bin = "tmp/migrate; tmp/app"
+bin = "tmp/app"
 # Customize binary.
 # Customize binary.
-full_bin = "tmp/migrate; tmp/app"
+full_bin = "tmp/app"
 # Watch these filename extensions.
 # Watch these filename extensions.
 include_ext = ["go", "mod", "sum", "html"]
 include_ext = ["go", "mod", "sum", "html"]
 # Ignore these filename extensions or directories.
 # Ignore these filename extensions or directories.

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

@@ -13,6 +13,12 @@ jobs:
           project_id: ${{ secrets.GCP_PROJECT_ID }}
           project_id: ${{ secrets.GCP_PROJECT_ID }}
           service_account_key: ${{ secrets.GCP_SA_KEY }}
           service_account_key: ${{ secrets.GCP_SA_KEY }}
           export_default_credentials: true
           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
       - name: Install kubectl
         uses: azure/setup-kubectl@v1
         uses: azure/setup-kubectl@v1
       - name: Log in to gcloud CLI
       - name: Log in to gcloud CLI
@@ -42,7 +48,6 @@ jobs:
           docker push gcr.io/porter-dev-273614/porter:dev
           docker push gcr.io/porter-dev-273614/porter:dev
       - name: Deploy to cluster
       - name: Deploy to cluster
         run: |
         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 }}
           project_id: ${{ secrets.GCP_PROJECT_ID }}
           service_account_key: ${{ secrets.GCP_SA_KEY }}
           service_account_key: ${{ secrets.GCP_SA_KEY }}
           export_default_credentials: true
           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
       - name: Install kubectl
         uses: azure/setup-kubectl@v1
         uses: azure/setup-kubectl@v1
       - name: Log in to gcloud CLI
       - name: Log in to gcloud CLI
@@ -42,7 +48,6 @@ jobs:
           docker push gcr.io/porter-dev-273614/porter:latest
           docker push gcr.io/porter-dev-273614/porter:latest
       - name: Deploy to cluster
       - name: Deploy to cluster
         run: |
         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
           kubectl rollout restart deployment/porter

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

@@ -27,11 +27,8 @@ jobs:
         run: |
         run: |
           cat >./dashboard/.env <<EOL
           cat >./dashboard/.env <<EOL
           NODE_ENV=production
           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
           EOL
 
 
           cat ./dashboard/.env
           cat ./dashboard/.env
@@ -62,13 +59,8 @@ jobs:
         run: |
         run: |
           cat >./dashboard/.env <<EOL
           cat >./dashboard/.env <<EOL
           NODE_ENV=production
           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
           EOL
       - name: Build and zip static folder
       - name: Build and zip static folder
         run: |
         run: |
@@ -355,3 +347,38 @@ jobs:
           asset_path: ./release/static/static_${{steps.tag_name.outputs.tag}}.zip
           asset_path: ./release/static/static_${{steps.tag_name.outputs.tag}}.zip
           asset_name: static_${{steps.tag_name.outputs.tag}}.zip
           asset_name: static_${{steps.tag_name.outputs.tag}}.zip
           asset_content_type: application/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 }}
           project_id: ${{ secrets.GCP_PROJECT_ID }}
           service_account_key: ${{ secrets.GCP_SA_KEY }}
           service_account_key: ${{ secrets.GCP_SA_KEY }}
           export_default_credentials: true
           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
       - name: Install kubectl
         uses: azure/setup-kubectl@v1
         uses: azure/setup-kubectl@v1
       - name: Log in to gcloud CLI
       - name: Log in to gcloud CLI
@@ -42,7 +48,6 @@ jobs:
           docker push gcr.io/porter-dev-273614/porter:staging
           docker push gcr.io/porter-dev-273614/porter:staging
       - name: Deploy to cluster
       - name: Deploy to cluster
         run: |
         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
           kubectl rollout restart deployment/porter

+ 6 - 0
.gitignore

@@ -1,6 +1,7 @@
 .DS_Store
 .DS_Store
 .env
 .env
 docker/.env
 docker/.env
+docker/github_app_private_key.pem
 app
 app
 *.db
 *.db
 test.yaml
 test.yaml
@@ -10,6 +11,9 @@ internal/local_templates
 gon*.hcl
 gon*.hcl
 *prod.Dockerfile
 *prod.Dockerfile
 staging.sh
 staging.sh
+*.crt
+*.key
+bin
 
 
 # Local .terraform directories
 # Local .terraform directories
 **/.terraform/*
 **/.terraform/*
@@ -52,3 +56,5 @@ override.tf.json
 # Ignore CLI configuration files
 # Ignore CLI configuration files
 .terraformrc
 .terraformrc
 terraform.rc
 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
 ### 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!
 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"`
 	ProjectID uint `json:"project_id"`
 }
 }
 
 
-func GetProjectIDFromToken(token string) (uint, error) {
+func GetProjectIDFromToken(token string) (uint, bool, error) {
 	var encoded string
 	var encoded string
 
 
 	if tokenSplit := strings.Split(token, "."); len(tokenSplit) != 3 {
 	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 {
 	} else {
 		encoded = tokenSplit[1]
 		encoded = tokenSplit[1]
 	}
 	}
@@ -156,7 +156,7 @@ func GetProjectIDFromToken(token string) (uint, error) {
 	decodedBytes, err := base64.RawStdEncoding.DecodeString(encoded)
 	decodedBytes, err := base64.RawStdEncoding.DecodeString(encoded)
 
 
 	if err != nil {
 	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{}
 	res := &TokenProjectID{}
@@ -164,8 +164,13 @@ func GetProjectIDFromToken(token string) (uint, error) {
 	err = json.Unmarshal(decodedBytes, res)
 	err = json.Unmarshal(decodedBytes, res)
 
 
 	if err != nil {
 	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 (
 import (
 	"context"
 	"context"
+	"encoding/json"
 	"fmt"
 	"fmt"
 	"net/http"
 	"net/http"
 	"net/url"
 	"net/url"
+	"strings"
 
 
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 )
 )
@@ -72,3 +74,151 @@ func (c *Client) DeployWithWebhook(
 
 
 	return nil
 	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
 // 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
 // ListGitRepos returns a list of Git repos for a project
 func (c *Client) ListGitRepos(
 func (c *Client) ListGitRepos(
@@ -80,3 +80,35 @@ func (c *Client) GetRepoZIPDownloadURL(
 
 
 	return bodyResp, nil
 	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
 // CreateGithubActionRequest represents the accepted fields for creating
 // a Github action
 // a Github action
 type CreateGithubActionRequest struct {
 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
 // CreateGithubAction creates a Github action with basic authentication
@@ -33,7 +38,7 @@ func (c *Client) CreateGithubAction(
 	req, err := http.NewRequest(
 	req, err := http.NewRequest(
 		"POST",
 		"POST",
 		fmt.Sprintf(
 		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,
 			c.BaseURL,
 			projectID,
 			projectID,
 			clusterID,
 			clusterID,

+ 39 - 0
api/client/registry.go

@@ -472,3 +472,42 @@ func (c *Client) ListImages(
 
 
 	return *bodyResp, nil
 	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
 	PorterHandlerReader
 }
 }
 
 
+// default
+
 // type PorterHandler struct {
 // type PorterHandler struct {
 // 	config           *shared.Config
 // 	config           *shared.Config
 // 	decoderValidator shared.RequestDecoderValidator
 // 	decoderValidator shared.RequestDecoderValidator

+ 56 - 0
api/types/policy.go

@@ -26,7 +26,10 @@ type PolicyDocument struct {
 type ScopeTree map[PermissionScope]ScopeTree
 type ScopeTree map[PermissionScope]ScopeTree
 
 
 /* ScopeHeirarchy describes the scope tree:
 /* ScopeHeirarchy describes the scope tree:
+<<<<<<< HEAD
 
 
+=======
+>>>>>>> master
 			Project
 			Project
 		   /	   \
 		   /	   \
 		Cluster   Settings
 		Cluster   Settings
@@ -47,3 +50,56 @@ var ScopeHeirarchy = ScopeTree{
 }
 }
 
 
 type Policy []*PolicyDocument
 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
 var manual bool = false
 
 
 func init() {
 func init() {
@@ -80,7 +79,44 @@ func login() error {
 	user, _ := client.AuthCheck(context.Background())
 	user, _ := client.AuthCheck(context.Background())
 
 
 	if user != nil {
 	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
 		return nil
 	}
 	}
 
 
@@ -89,70 +125,50 @@ func login() error {
 		return loginManual()
 		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 {
 		if err != nil {
 			return err
 			return err
 		}
 		}
-
-		config.SetProject(projID)
 	}
 	}
 
 
 	return nil
 	return nil
@@ -200,6 +216,12 @@ func loginManual() error {
 
 
 	if len(projects) > 0 {
 	if len(projects) > 0 {
 		config.SetProject(projects[0].ID)
 		config.SetProject(projects[0].ID)
+
+		err = setProjectCluster(client, projects[0].ID)
+
+		if err != nil {
+			return err
+		}
 	}
 	}
 
 
 	return nil
 	return nil

+ 133 - 230
cli/cmd/config.go

@@ -1,11 +1,14 @@
 package cmd
 package cmd
 
 
 import (
 import (
+	"fmt"
 	"io/ioutil"
 	"io/ioutil"
 	"os"
 	"os"
 	"path/filepath"
 	"path/filepath"
+	"strconv"
 
 
 	"github.com/fatih/color"
 	"github.com/fatih/color"
+	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
 	"github.com/spf13/viper"
 
 
 	flag "github.com/spf13/pflag"
 	flag "github.com/spf13/pflag"
@@ -258,233 +261,133 @@ func (c *CLIConfig) SetHelmRepo(helmRepoID uint) error {
 	return nil
 	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{
 var connectGCRCmd = &cobra.Command{
 	Use:   "gcr",
 	Use:   "gcr",
 	Short: "Adds a GCR instance to a project",
 	Short: "Adds a GCR instance to a project",
@@ -135,7 +123,6 @@ func init() {
 		"the context to connect (defaults to the current context)",
 		"the context to connect (defaults to the current context)",
 	)
 	)
 
 
-	connectCmd.AddCommand(connectActionsCmd)
 	connectCmd.AddCommand(connectECRCmd)
 	connectCmd.AddCommand(connectECRCmd)
 	connectCmd.AddCommand(connectRegistryCmd)
 	connectCmd.AddCommand(connectRegistryCmd)
 	connectCmd.AddCommand(connectDockerhubCmd)
 	connectCmd.AddCommand(connectDockerhubCmd)
@@ -243,10 +230,3 @@ func runConnectHelmRepoBasic(_ *api.AuthCheckResponse, client *api.Client, _ []s
 
 
 	return config.SetHelmRepo(hrID)
 	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"
 	"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
 // 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(`
 	Long: fmt.Sprintf(`
 %s 
 %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
   %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 
 --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":
 local directory ~/path-to-dir with the tag "testing":
 
 
   %s
   %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
   %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
   %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
   %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) {
 	Run: func(cmd *cobra.Command, args []string) {
-		err := checkLoginAndRun(args, deployFull)
+		err := checkLoginAndRun(args, updateFull)
 
 
 		if err != nil {
 		if err != nil {
 			os.Exit(1)
 			os.Exit(1)
@@ -67,7 +64,7 @@ as documented above. For example:
 	},
 	},
 }
 }
 
 
-var deployGetEnvCmd = &cobra.Command{
+var updateGetEnvCmd = &cobra.Command{
 	Use:   "get-env",
 	Use:   "get-env",
 	Short: "Gets environment variables for a deployment for a specified application given by the --app flag.",
 	Short: "Gets environment variables for a deployment for a specified application given by the --app flag.",
 	Long: fmt.Sprintf(`
 	Long: fmt.Sprintf(`
@@ -83,12 +80,12 @@ destination path for a .env file. For example:
 
 
   %s
   %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) {
 	Run: func(cmd *cobra.Command, args []string) {
-		err := checkLoginAndRun(args, deployGetEnv)
+		err := checkLoginAndRun(args, updateGetEnv)
 
 
 		if err != nil {
 		if err != nil {
 			os.Exit(1)
 			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",
 	Use:   "build",
 	Short: "Builds a new version of the application specified by the --app flag.",
 	Short: "Builds a new version of the application specified by the --app flag.",
 	Long: fmt.Sprintf(`
 	Long: fmt.Sprintf(`
@@ -125,13 +122,13 @@ for the application:
 
 
   %s
   %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) {
 	Run: func(cmd *cobra.Command, args []string) {
-		err := checkLoginAndRun(args, deployBuild)
+		err := checkLoginAndRun(args, updateBuild)
 
 
 		if err != nil {
 		if err != nil {
 			os.Exit(1)
 			os.Exit(1)
@@ -139,7 +136,7 @@ for the application:
 	},
 	},
 }
 }
 
 
-var deployPushCmd = &cobra.Command{
+var updatePushCmd = &cobra.Command{
 	Use:   "push",
 	Use:   "push",
 	Short: "Pushes a new image for an application specified by the --app flag.",
 	Short: "Pushes a new image for an application specified by the --app flag.",
 	Long: fmt.Sprintf(`
 	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 
 are using an image registry that was created outside of Porter, make sure that you have 
 linked it via "porter connect".
 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) {
 	Run: func(cmd *cobra.Command, args []string) {
-		err := checkLoginAndRun(args, deployPush)
+		err := checkLoginAndRun(args, updatePush)
 
 
 		if err != nil {
 		if err != nil {
 			os.Exit(1)
 			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(`
 	Long: fmt.Sprintf(`
 %s 
 %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
   %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
   %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) {
 	Run: func(cmd *cobra.Command, args []string) {
-		err := checkLoginAndRun(args, deployCallWebhook)
+		err := checkLoginAndRun(args, updateUpgrade)
 
 
 		if err != nil {
 		if err != nil {
 			os.Exit(1)
 			os.Exit(1)
@@ -199,47 +198,46 @@ specify a different tag with the --tag flag:
 
 
 var app string
 var app string
 var getEnvFileDest string
 var getEnvFileDest string
-var local bool
 var localPath string
 var localPath string
 var tag string
 var tag string
 var dockerfile string
 var dockerfile string
 var method string
 var method string
 
 
 func init() {
 func init() {
-	rootCmd.AddCommand(deployCmd)
+	rootCmd.AddCommand(updateCmd)
 
 
-	deployCmd.PersistentFlags().StringVar(
+	updateCmd.PersistentFlags().StringVar(
 		&app,
 		&app,
 		"app",
 		"app",
 		"",
 		"",
 		"Application in the Porter dashboard",
 		"Application in the Porter dashboard",
 	)
 	)
 
 
-	deployCmd.MarkPersistentFlagRequired("app")
+	updateCmd.MarkPersistentFlagRequired("app")
 
 
-	deployCmd.PersistentFlags().StringVar(
+	updateCmd.PersistentFlags().StringVar(
 		&namespace,
 		&namespace,
 		"namespace",
 		"namespace",
 		"default",
 		"default",
 		"Namespace of the application",
 		"Namespace of the application",
 	)
 	)
 
 
-	deployCmd.PersistentFlags().BoolVar(
-		&local,
+	updateCmd.PersistentFlags().StringVar(
+		&source,
+		"source",
 		"local",
 		"local",
-		false,
-		"Whether local context should be used for build",
+		"the type of source (\"local\" or \"github\")",
 	)
 	)
 
 
-	deployCmd.PersistentFlags().StringVarP(
+	updateCmd.PersistentFlags().StringVarP(
 		&localPath,
 		&localPath,
 		"path",
 		"path",
 		"p",
 		"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,
 		"tag",
 		"tag",
 		"t",
 		"t",
@@ -247,56 +245,64 @@ func init() {
 		"the specified tag to use, if not \"latest\"",
 		"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,
 		"dockerfile",
 		"dockerfile",
 		"",
 		"",
 		"the path to the dockerfile",
 		"the path to the dockerfile",
 	)
 	)
 
 
-	deployCmd.PersistentFlags().StringVar(
+	updateCmd.PersistentFlags().StringVar(
 		&method,
 		&method,
 		"method",
 		"method",
 		"",
 		"",
 		"the build method to use (\"docker\" or \"pack\")",
 		"the build method to use (\"docker\" or \"pack\")",
 	)
 	)
 
 
-	deployCmd.AddCommand(deployGetEnvCmd)
+	updateCmd.AddCommand(updateGetEnvCmd)
 
 
-	deployGetEnvCmd.PersistentFlags().StringVar(
+	updateGetEnvCmd.PersistentFlags().StringVar(
 		&getEnvFileDest,
 		&getEnvFileDest,
 		"file",
 		"file",
 		"",
 		"",
 		"file destination for .env files",
 		"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)
 	color.New(color.FgGreen).Println("Deploying app:", app)
 
 
-	deployAgent, err := deployGetAgent(client)
+	updateAgent, err := updateGetAgent(client)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	err = deployBuildWithAgent(deployAgent)
+	err = updateBuildWithAgent(updateAgent)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	err = deployPushWithAgent(deployAgent)
+	err = updatePushWithAgent(updateAgent)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	err = deployCallWebhookWithAgent(deployAgent)
+	err = updateUpgradeWithAgent(updateAgent)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
@@ -305,119 +311,128 @@ func deployFull(resp *api.AuthCheckResponse, client *api.Client, args []string)
 	return nil
 	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 {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	buildEnv, err := deployAgent.GetBuildEnv()
+	buildEnv, err := updateAgent.GetBuildEnv()
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
 	// set the environment variables in the process
 	// set the environment variables in the process
-	err = deployAgent.SetBuildEnv(buildEnv)
+	err = updateAgent.SetBuildEnv(buildEnv)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
 	// write the environment variables to either a file or stdout (stdout by default)
 	// 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 {
 	if err != nil {
 		return err
 		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 {
 	if err != nil {
 		return err
 		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 {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	return deployCallWebhookWithAgent(deployAgent)
+	return updateUpgradeWithAgent(updateAgent)
 }
 }
 
 
 // HELPER METHODS
 // HELPER METHODS
-func deployGetAgent(client *api.Client) (*deploy.DeployAgent, error) {
+func updateGetAgent(client *api.Client) (*deploy.DeployAgent, error) {
 	var buildMethod deploy.DeployBuildType
 	var buildMethod deploy.DeployBuildType
 
 
 	if method != "" {
 	if method != "" {
 		buildMethod = deploy.DeployBuildType(method)
 		buildMethod = deploy.DeployBuildType(method)
 	}
 	}
 
 
-	// initialize the deploy agent
+	// initialize the update agent
 	return deploy.NewDeployAgent(client, app, &deploy.DeployOpts{
 	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
 	// build the deployment
 	color.New(color.FgGreen).Println("Building docker image for", app)
 	color.New(color.FgGreen).Println("Building docker image for", app)
 
 
-	buildEnv, err := deployAgent.GetBuildEnv()
+	buildEnv, err := updateAgent.GetBuildEnv()
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
 	// set the environment variables in the process
 	// set the environment variables in the process
-	err = deployAgent.SetBuildEnv(buildEnv)
+	err = updateAgent.SetBuildEnv(buildEnv)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	return deployAgent.Build()
+	return updateAgent.Build()
 }
 }
 
 
-func deployPushWithAgent(deployAgent *deploy.DeployAgent) error {
+func updatePushWithAgent(updateAgent *deploy.DeployAgent) error {
 	// push the deployment
 	// push the deployment
 	color.New(color.FgGreen).Println("Pushing new image for", app)
 	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
 	// push the deployment
 	color.New(color.FgGreen).Println("Calling webhook for", app)
 	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 {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	color.New(color.FgGreen).Println("Successfully re-deployed", app)
+	color.New(color.FgGreen).Println("Successfully updated", app)
 
 
 	return nil
 	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 (
 import (
 	"context"
 	"context"
+	"encoding/json"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
 	"io/ioutil"
 	"io/ioutil"
@@ -12,7 +13,7 @@ import (
 	api "github.com/porter-dev/porter/api/client"
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/cli/cmd/docker"
 	"github.com/porter-dev/porter/cli/cmd/docker"
 	"github.com/porter-dev/porter/cli/cmd/github"
 	"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"
 	"k8s.io/client-go/util/homedir"
 )
 )
 
 
@@ -21,10 +22,10 @@ type DeployBuildType string
 
 
 const (
 const (
 	// uses local Docker daemon to build and push images
 	// 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
 	// 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
 // 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
 // DeployOpts are the options for creating a new DeployAgent
 type DeployOpts struct {
 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
 // 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
 			// if the git action config exists, and dockerfile path is not empty, build type
 			// is docker
 			// is docker
 			if release.GitActionConfig.DockerfilePath != "" {
 			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 {
 		} 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 {
 		if release.GitActionConfig != nil {
 			deployAgent.dockerfilePath = release.GitActionConfig.DockerfilePath
 			deployAgent.dockerfilePath = release.GitActionConfig.DockerfilePath
 		}
 		}
@@ -143,10 +139,13 @@ func NewDeployAgent(client *api.Client, app string, opts *DeployOpts) (*DeployAg
 	return deployAgent, nil
 	return deployAgent, nil
 }
 }
 
 
+// GetBuildEnv retrieves the build env from the release config and returns it
 func (d *DeployAgent) GetBuildEnv() (map[string]string, error) {
 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 {
 func (d *DeployAgent) SetBuildEnv(envVars map[string]string) error {
 	d.env = envVars
 	d.env = envVars
 
 
@@ -166,6 +165,7 @@ func (d *DeployAgent) SetBuildEnv(envVars map[string]string) error {
 	return nil
 	return nil
 }
 }
 
 
+// WriteBuildEnv writes the build env to either a file or stdout
 func (d *DeployAgent) WriteBuildEnv(fileDest string) error {
 func (d *DeployAgent) WriteBuildEnv(fileDest string) error {
 	// join lines together
 	// join lines together
 	lines := make([]string, 0)
 	lines := make([]string, 0)
@@ -189,9 +189,12 @@ func (d *DeployAgent) WriteBuildEnv(fileDest string) error {
 	return nil
 	return nil
 }
 }
 
 
+// Build uses the deploy agent options to build a new container image from either
+// buildpack or docker.
 func (d *DeployAgent) Build() error {
 func (d *DeployAgent) Build() error {
 	// if build is not local, fetch remote source
 	// if build is not local, fetch remote source
-	var dst string
+	var basePath string
+	buildCtx := d.opts.LocalPath
 	var err error
 	var err error
 
 
 	if !d.opts.Local {
 	if !d.opts.Local {
@@ -206,26 +209,40 @@ func (d *DeployAgent) Build() error {
 		}
 		}
 
 
 		// download the repository from remote source into a temp directory
 		// 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 == "" {
 		if d.tag == "" {
 			shortRef := fmt.Sprintf("%.7s", zipResp.LatestCommitSHA)
 			shortRef := fmt.Sprintf("%.7s", zipResp.LatestCommitSHA)
 			d.tag = shortRef
 			d.tag = shortRef
 		}
 		}
+	} else {
+		basePath, err = filepath.Abs(".")
 
 
 		if err != nil {
 		if err != nil {
 			return err
 			return err
 		}
 		}
-	} else {
-		dst = filepath.Dir(d.opts.LocalPath)
 	}
 	}
 
 
 	if d.tag == "" {
 	if d.tag == "" {
-		d.tag = "latest"
+		currImageSection := d.release.Config["image"].(map[string]interface{})
+
+		d.tag = currImageSection["tag"].(string)
 	}
 	}
 
 
 	err = d.pullCurrentReleaseImage()
 	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 image is not found, don't return an error
 	if err != nil && err != docker.PullImageErrNotFound {
 	if err != nil && err != docker.PullImageErrNotFound {
 		return err
 		return err
@@ -234,93 +251,76 @@ func (d *DeployAgent) Build() error {
 		d.imageExists = false
 		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 {
 		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 {
 	if err != nil {
 		return err
 		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(),
 		context.Background(),
 		d.opts.ProjectID,
 		d.opts.ProjectID,
 		d.opts.ClusterID,
 		d.opts.ClusterID,
 		d.release.Name,
 		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 the field is not found, set envConfig to an empty map; this release has no env set
 	if e := (&NestedMapFieldNotFoundError{}); errors.As(err, &e) {
 	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) {
 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")
 	imageConfig, err := getNestedMap(d.release.Config, "image")
 
 
 	if err != nil {
 	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)
 		configFile.CredentialHelpers = make(map[string]string)
 	}
 	}
 
 
+	if configFile.AuthConfigs == nil {
+		configFile.AuthConfigs = make(map[string]types.AuthConfig)
+	}
+
 	for _, regURL := range regToAdd {
 	for _, regURL := range regToAdd {
 		// if this is a dockerhub registry, see if an auth config has already been generated
 		// if this is a dockerhub registry, see if an auth config has already been generated
 		// for index.docker.io
 		// for index.docker.io

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

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

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

@@ -1,31 +1,59 @@
 package docker
 package docker
 
 
 import (
 import (
+	"archive/tar"
+	"bytes"
 	"context"
 	"context"
 	"fmt"
 	"fmt"
+	"io"
+	"io/ioutil"
 	"os"
 	"os"
+	"time"
 
 
 	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/pkg/archive"
 	"github.com/docker/docker/pkg/archive"
 	"github.com/moby/moby/pkg/jsonmessage"
 	"github.com/moby/moby/pkg/jsonmessage"
+	"github.com/moby/moby/pkg/stringid"
 	"github.com/moby/term"
 	"github.com/moby/term"
+	"github.com/pkg/errors"
 )
 )
 
 
 type BuildOpts struct {
 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
 // 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{})
 	tar, err := archive.TarWithOptions(opts.BuildContext, &archive.TarOptions{})
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		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)
 	buildArgs := make(map[string]*string)
 
 
 	for key, val := range opts.Env {
 	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)
 	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 (
 import (
 	"context"
 	"context"
+	"errors"
 	"strings"
 	"strings"
 
 
 	"github.com/fatih/color"
 	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
 	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 {
 func checkLoginAndRun(args []string, runner func(user *api.AuthCheckResponse, client *api.Client, args []string) error) error {
 	client := GetAPIClient(config)
 	client := GetAPIClient(config)
 
 
@@ -18,12 +22,12 @@ func checkLoginAndRun(args []string, runner func(user *api.AuthCheckResponse, cl
 
 
 		if strings.Contains(err.Error(), "403") {
 		if strings.Contains(err.Error(), "403") {
 			red.Print("You are not logged in. Log in using \"porter auth login\"\n")
 			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") {
 		} else if strings.Contains(err.Error(), "connection refused") {
 			red.Printf("Unable to connect to the Porter server at %s\n", config.Host)
 			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 set a different host, run \"porter config set-host [HOST]\"\n")
 			red.Print("To start a local server, run \"porter server start\"\n")
 			red.Print("To start a local server, run \"porter server start\"\n")
-			return nil
+			return ErrCannotConnect
 		}
 		}
 
 
 		red.Printf("Error: %v\n", err.Error())
 		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")
 		w.Header().Set("Content-Type", "text/html; charset=utf-8")
 		fmt.Fprint(w, successScreen)
 		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
 	// 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))
 	loginURL := fmt.Sprintf("%s/api/cli/login?redirect=%s", host, url.QueryEscape(redirectHost))
 
 
 	err = utils.OpenBrowser(loginURL)
 	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
 	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 (
 import (
 	"context"
 	"context"
 	"fmt"
 	"fmt"
+	"io"
 	"os"
 	"os"
 	"strings"
 	"strings"
+	"time"
 
 
 	"github.com/fatih/color"
 	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/spf13/cobra"
 	"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"
 	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/rest"
 	"k8s.io/client-go/rest"
 	"k8s.io/client-go/tools/clientcmd"
 	"k8s.io/client-go/tools/clientcmd"
 	"k8s.io/client-go/tools/remotecommand"
 	"k8s.io/client-go/tools/remotecommand"
-	"k8s.io/kubectl/pkg/util/term"
 )
 )
 
 
 var namespace string
 var namespace string
+var verbose bool
 
 
 // runCmd represents the "porter run" base command when called
 // runCmd represents the "porter run" base command when called
 // without any subcommands
 // without any subcommands
@@ -35,6 +42,8 @@ var runCmd = &cobra.Command{
 	},
 	},
 }
 }
 
 
+var existingPod bool
+
 func init() {
 func init() {
 	rootCmd.AddCommand(runCmd)
 	rootCmd.AddCommand(runCmd)
 
 
@@ -44,6 +53,22 @@ func init() {
 		"default",
 		"default",
 		"namespace of release to connect to",
 		"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 {
 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 {
 	if len(podsSimple) == 0 {
 		return fmt.Errorf("At least one pod must exist in this deployment.")
 		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]
 		selectedPod = podsSimple[0]
 	} else {
 	} else {
 		podNames := make([]string, 0)
 		podNames := make([]string, 0)
@@ -100,23 +125,38 @@ func run(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
 		selectedContainerName = selectedContainer
 		selectedContainerName = selectedContainer
 	}
 	}
 
 
-	restConf, err := getRESTConfig(client)
+	config := &PorterRunSharedConfig{
+		Client: client,
+	}
+
+	err = config.setSharedConfig()
 
 
 	if err != nil {
 	if err != nil {
 		return fmt.Errorf("Could not retrieve kube credentials: %s", err.Error())
 		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
 	pID := config.Project
 	cID := config.Cluster
 	cID := config.Cluster
 
 
-	kubeResp, err := client.GetKubeconfig(context.TODO(), pID, cID)
+	kubeResp, err := p.Client.GetKubeconfig(context.TODO(), pID, cID)
 
 
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return err
 	}
 	}
 
 
 	kubeBytes := kubeResp.Kubeconfig
 	kubeBytes := kubeResp.Kubeconfig
@@ -124,13 +164,13 @@ func getRESTConfig(client *api.Client) (*rest.Config, error) {
 	cmdConf, err := clientcmd.NewClientConfigFromBytes(kubeBytes)
 	cmdConf, err := clientcmd.NewClientConfigFromBytes(kubeBytes)
 
 
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return err
 	}
 	}
 
 
 	restConf, err := cmdConf.ClientConfig()
 	restConf, err := cmdConf.ClientConfig()
 
 
 	if err != nil {
 	if err != nil {
-		return nil, err
+		return err
 	}
 	}
 
 
 	restConf.GroupVersion = &schema.GroupVersion{
 	restConf.GroupVersion = &schema.GroupVersion{
@@ -140,7 +180,25 @@ func getRESTConfig(client *api.Client) (*rest.Config, error) {
 
 
 	restConf.NegotiatedSerializer = runtime.NewSimpleNegotiatedSerializer(runtime.SerializerInfo{})
 	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 {
 type podSimple struct {
@@ -176,27 +234,20 @@ func getPods(client *api.Client, namespace, releaseName string) ([]podSimple, er
 	return res, nil
 	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").
 		Resource("pods").
 		Name(name).
 		Name(name).
 		Namespace(namespace).
 		Namespace(namespace).
 		SubResource("exec")
 		SubResource("exec")
 
 
-	// req.Param("container", "web")
 	for _, arg := range args {
 	for _, arg := range args {
 		req.Param("command", arg)
 		req.Param("command", arg)
 	}
 	}
 	req.Param("stdin", "true")
 	req.Param("stdin", "true")
 	req.Param("stdout", "true")
 	req.Param("stdout", "true")
 	req.Param("tty", "true")
 	req.Param("tty", "true")
-	req.Param("container", "sidecar")
+	req.Param("container", container)
 
 
 	t := term.TTY{
 	t := term.TTY{
 		In:  os.Stdin,
 		In:  os.Stdin,
@@ -205,7 +256,7 @@ func executeRun(config *rest.Config, namespace, name, container string, args []s
 	}
 	}
 
 
 	fn := func() error {
 	fn := func() error {
-		exec, err := remotecommand.NewSPDYExecutor(config, "POST", req.URL())
+		exec, err := remotecommand.NewSPDYExecutor(config.RestConf, "POST", req.URL())
 
 
 		if err != nil {
 		if err != nil {
 			return err
 			return err
@@ -223,5 +274,207 @@ func executeRun(config *rest.Config, namespace, name, container string, args []s
 		return err
 		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
 	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=true",
 		"SQL_LITE_PATH=" + sqlLitePath,
 		"SQL_LITE_PATH=" + sqlLitePath,
 		"STATIC_FILE_PATH=" + staticFilePath,
 		"STATIC_FILE_PATH=" + staticFilePath,
+		fmt.Sprintf("SERVER_PORT=%d", port),
 		"REDIS_ENABLED=false",
 		"REDIS_ENABLED=false",
 	}...)
 	}...)
 
 

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

@@ -1,6 +1,7 @@
 package utils
 package utils
 
 
 import (
 import (
+	"fmt"
 	"os/exec"
 	"os/exec"
 	"runtime"
 	"runtime"
 )
 )
@@ -10,6 +11,8 @@ func OpenBrowser(url string) error {
 	var cmd string
 	var cmd string
 	var args []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 {
 	switch runtime.GOOS {
 	case "windows":
 	case "windows":
 		cmd = "cmd"
 		cmd = "cmd"
@@ -17,8 +20,14 @@ func OpenBrowser(url string) error {
 	case "darwin":
 	case "darwin":
 		cmd = "open"
 		cmd = "open"
 	default: // "linux", "freebsd", "openbsd", "netbsd"
 	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)
 	args = append(args, url)
 	return exec.Command(cmd, args...).Start()
 	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
 // 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{
 var versionCmd = &cobra.Command{
 	Use:     "version",
 	Use:     "version",

+ 1 - 30
cmd/app/main.go

@@ -7,7 +7,6 @@ import (
 	"net/http"
 	"net/http"
 	"os"
 	"os"
 
 
-	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 
 
 	"github.com/porter-dev/porter/server/api"
 	"github.com/porter-dev/porter/server/api"
@@ -18,7 +17,6 @@ import (
 	"github.com/porter-dev/porter/server/router"
 	"github.com/porter-dev/porter/server/router"
 
 
 	prov "github.com/porter-dev/porter/internal/kubernetes/provisioner"
 	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
 // Version will be linked by an ldflag during build
@@ -45,34 +43,7 @@ func main() {
 		return
 		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 {
 	if err != nil {
 		logger.Fatal().Err(err).Msg("")
 		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{
 	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)
 	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"
 	adapter "github.com/porter-dev/porter/internal/adapter"
 	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/config"
 	lr "github.com/porter-dev/porter/internal/logger"
 	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"
 	"github.com/joeshaw/envdecode"
 )
 )
@@ -29,38 +27,11 @@ func main() {
 		return
 		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 {
 	if err != nil {
-		panic(err)
+		logger.Fatal().Err(err).Msg("")
+		return
 	}
 	}
 
 
 	if shouldRotate, oldKeyStr, newKeyStr := shouldKeyRotate(); shouldRotate {
 	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"]
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 2340 - 25
dashboard/package-lock.json


+ 27 - 10
dashboard/package.json

@@ -4,14 +4,6 @@
   "private": true,
   "private": true,
   "dependencies": {
   "dependencies": {
     "@material-ui/core": "^4.11.3",
     "@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/axis": "^1.6.1",
     "@visx/curve": "^1.0.0",
     "@visx/curve": "^1.0.0",
     "@visx/event": "^1.3.0",
     "@visx/event": "^1.3.0",
@@ -26,6 +18,8 @@
     "anser": "^2.0.1",
     "anser": "^2.0.1",
     "axios": "^0.20.0",
     "axios": "^0.20.0",
     "brace": "^0.11.1",
     "brace": "^0.11.1",
+    "clipboard": "^2.0.8",
+    "core-js": "^3.16.1",
     "d3-array": "^2.11.0",
     "d3-array": "^2.11.0",
     "d3-time-format": "^3.0.0",
     "d3-time-format": "^3.0.0",
     "dotenv": "^8.2.0",
     "dotenv": "^8.2.0",
@@ -40,22 +34,37 @@
     "react": "^16.13.1",
     "react": "^16.13.1",
     "react-ace": "^9.1.3",
     "react-ace": "^9.1.3",
     "react-dom": "^16.13.1",
     "react-dom": "^16.13.1",
+    "react-error-boundary": "^3.1.3",
     "react-modal": "^3.11.2",
     "react-modal": "^3.11.2",
     "react-router-dom": "^5.2.0",
     "react-router-dom": "^5.2.0",
+    "react-table": "^7.7.0",
+    "regenerator-runtime": "^0.13.9",
     "semver": "^7.3.5",
     "semver": "^7.3.5",
     "styled-components": "^5.2.0"
     "styled-components": "^5.2.0"
   },
   },
   "scripts": {
   "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1",
     "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": {
   "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/jest-dom": "^4.2.4",
     "@testing-library/react": "^9.3.2",
     "@testing-library/react": "^9.3.2",
     "@testing-library/user-event": "^7.1.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/jest": "^24.0.0",
     "@types/js-base64": "^3.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/node": "^12.12.62",
     "@types/qs": "^6.9.5",
     "@types/qs": "^6.9.5",
     "@types/random-words": "^1.1.0",
     "@types/random-words": "^1.1.0",
@@ -64,16 +73,24 @@
     "@types/react-modal": "^3.10.6",
     "@types/react-modal": "^3.10.6",
     "@types/react-router": "^5.1.8",
     "@types/react-router": "^5.1.8",
     "@types/react-router-dom": "^5.1.5",
     "@types/react-router-dom": "^5.1.5",
+    "@types/react-table": "^7.7.1",
     "@types/semver": "^7.3.5",
     "@types/semver": "^7.3.5",
     "@types/styled-components": "^5.1.3",
     "@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",
     "file-loader": "^6.1.0",
     "html-webpack-plugin": "^4.5.0",
     "html-webpack-plugin": "^4.5.0",
     "prettier": "2.2.1",
     "prettier": "2.2.1",
     "qs": "^6.9.4",
     "qs": "^6.9.4",
+    "react-refresh": "^0.10.0",
     "source-map-loader": "^1.1.0",
     "source-map-loader": "^1.1.0",
+    "terser-webpack-plugin": "^4.2.3",
     "ts-loader": "^8.0.4",
     "ts-loader": "^8.0.4",
     "typescript": "^4.1.2",
     "typescript": "^4.1.2",
     "webpack": "^4.44.2",
     "webpack": "^4.44.2",
+    "webpack-bundle-analyzer": "^4.4.2",
     "webpack-cli": "^3.3.12",
     "webpack-cli": "^3.3.12",
     "webpack-dev-server": "^3.11.0"
     "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 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() {
   render() {
     return (
     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`
 const StatusSection = styled.div`
-  border-radius: 5px;
+  border-radius: 8px;
   background: #ffffff11;
   background: #ffffff11;
   font-size: 13px;
   font-size: 13px;
   padding: 20px 20px 25px;
   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 = {
 type PropsType = {
   offset?: string;
   offset?: string;
+  width?: string;
+  height?: string;
 };
 };
 
 
 type StateType = {};
 type StateType = {};
@@ -13,7 +15,11 @@ export default class Loading extends Component<PropsType, StateType> {
 
 
   render() {
   render() {
     return (
     return (
-      <StyledLoading offset={this.props.offset}>
+      <StyledLoading
+        offset={this.props.offset}
+        width={this.props.width || "100%"}
+        height={this.props.height || "100%"}
+      >
         <Spinner src={loading} />
         <Spinner src={loading} />
       </StyledLoading>
       </StyledLoading>
     );
     );
@@ -24,11 +30,13 @@ const Spinner = styled.img`
   width: 20px;
   width: 20px;
 `;
 `;
 
 
+type StyleLoadingProps = PropsType;
+
 const StyledLoading = styled.div`
 const StyledLoading = styled.div`
-  width: 100%;
-  height: 100%;
+  width: ${(props: StyleLoadingProps) => props.width};
+  height: ${(props: StyleLoadingProps) => props.height};
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   justify-content: 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) => {
           (option: { label: string; value: string }, i: number) => {
             let selected = option.value === this.props.selected;
             let selected = option.value === this.props.selected;
             return (
             return (
-              <RadioRow onClick={() => this.props.setSelected(option.value)}>
+              <RadioRow
+                key={option.value}
+                onClick={() => this.props.setSelected(option.value)}
+              >
                 <Indicator selected={selected}>
                 <Indicator selected={selected}>
                   {selected && <Circle />}
                   {selected && <Circle />}
                 </Indicator>
                 </Indicator>

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

@@ -146,11 +146,7 @@ const StyledResourceTab = styled.div`
   border-bottom-left-radius: ${(props: {
   border-bottom-left-radius: ${(props: {
     isLast: boolean;
     isLast: boolean;
     roundAllCorners: 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`
 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 styled from "styled-components";
 import loading from "assets/loading.gif";
 import loading from "assets/loading.gif";
 
 
-type PropsType = {
-  text: string;
+type Props = {
+  text?: string;
   onClick: () => void;
   onClick: () => void;
   disabled?: boolean;
   disabled?: boolean;
   status?: string | null;
   status?: string | null;
   color?: string;
   color?: string;
+  rounded?: boolean;
   helper?: string | null;
   helper?: string | null;
+  saveText?: string | null;
 
 
   // Makes flush with corner if not within a modal
   // Makes flush with corner if not within a modal
   makeFlush?: boolean;
   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 (
         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>
           </StatusWrapper>
         );
         );
-      } else if (this.props.status === "loading") {
+      } else if (props.status === "loading") {
         return (
         return (
-          <StatusWrapper successful={false}>
-            <LoadingGif src={loading} /> Updating . . .
+          <StatusWrapper position={props.statusPosition} successful={false}>
+            <LoadingGif src={loading} />
+            <StatusTextWrapper>
+              {props.saveText || "Updating . . ."}
+            </StatusTextWrapper>
           </StatusWrapper>
           </StatusWrapper>
         );
         );
-      } else if (this.props.status === "error") {
+      } else if (props.status === "error") {
         return (
         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>
           </StatusWrapper>
         );
         );
       } else {
       } else {
         return (
         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>
           </StatusWrapper>
         );
         );
       }
       }
-    } else if (this.props.helper) {
+    } else if (props.helper) {
       return (
       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`
 const LoadingGif = styled.img`
   width: 15px;
   width: 15px;
@@ -74,28 +89,43 @@ const LoadingGif = styled.img`
   margin-bottom: 0px;
   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;
   display: flex;
   align-items: center;
   align-items: center;
   font-family: "Work Sans", sans-serif;
   font-family: "Work Sans", sans-serif;
   font-size: 13px;
   font-size: 13px;
   color: #ffffff55;
   color: #ffffff55;
-  margin-right: 25px;
-  padding: 0 10px;
-
+  ${(props) => {
+    if (props.position !== "right") {
+      return "margin-right: 25px;";
+    }
+    return "margin-left: 25px;";
+  }}
   max-width: 500px;
   max-width: 500px;
-  white-space: nowrap;
   overflow: hidden;
   overflow: hidden;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
 
 
   > i {
   > i {
     font-size: 18px;
     font-size: 18px;
     margin-right: 10px;
     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;
   animation-fill-mode: forwards;
 
 
   @keyframes statusFloatIn {
   @keyframes statusFloatIn {
@@ -111,33 +141,52 @@ const StatusWrapper = styled.div`
 `;
 `;
 
 
 const ButtonWrapper = 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) {
     if (!props.makeFlush) {
       return `
       return `
+        ${baseStyles}
+        position: absolute;
+        justify-content: flex-end;
         bottom: 25px;
         bottom: 25px;
         right: 27px;
         right: 27px;
+        left: 27px;
       `;
       `;
     }
     }
     return `
     return `
+      ${baseStyles}
+      position: absolute;
+      justify-content: flex-end;
       bottom: 5px;
       bottom: 5px;
       right: 0;
       right: 0;
     `;
     `;
   }}
   }}
 `;
 `;
 
 
-const Button = styled.button`
+const Button = styled.button<{
+  disabled: boolean;
+  color: string;
+  rounded: boolean;
+}>`
   height: 35px;
   height: 35px;
   font-size: 13px;
   font-size: 13px;
   font-weight: 500;
   font-weight: 500;
   font-family: "Work Sans", sans-serif;
   font-family: "Work Sans", sans-serif;
   color: white;
   color: white;
+  display: flex;
+  align-items: center;
   padding: 6px 20px 7px 20px;
   padding: 6px 20px 7px 20px;
   text-align: left;
   text-align: left;
   border: 0;
   border: 0;
-  border-radius: 5px;
+  border-radius: ${(props) => (props.rounded ? "100px" : "5px")};
   background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
   background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
   box-shadow: ${(props) =>
   box-shadow: ${(props) =>
     !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
     !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
@@ -149,4 +198,18 @@ const Button = styled.button`
   :hover {
   :hover {
     filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
     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 React, { Component } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
+import { Context } from "shared/Context";
 
 
 type PropsType = {
 type PropsType = {
   activeValue: string;
   activeValue: string;
+  refreshOptions?: () => void;
   options: { value: string; label: string }[];
   options: { value: string; label: string }[];
+  addButton?: boolean;
   setActiveValue: (x: string) => void;
   setActiveValue: (x: string) => void;
   width: string;
   width: string;
   height?: 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 = () => {
   renderDropdown = () => {
     if (this.state.expanded) {
     if (this.state.expanded) {
       return (
       return (
@@ -91,6 +109,7 @@ export default class Selector extends Component<PropsType, StateType> {
         >
         >
           {this.renderDropdownLabel()}
           {this.renderDropdownLabel()}
           {this.renderOptionList()}
           {this.renderOptionList()}
+          {this.renderAddButton()}
         </Dropdown>
         </Dropdown>
       );
       );
     }
     }
@@ -107,11 +126,17 @@ export default class Selector extends Component<PropsType, StateType> {
 
 
   render() {
   render() {
     let { activeValue } = this.props;
     let { activeValue } = this.props;
+
     return (
     return (
       <StyledSelector width={this.props.width}>
       <StyledSelector width={this.props.width}>
         <MainSelector
         <MainSelector
           ref={this.parentRef}
           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}
           expanded={this.state.expanded}
           width={this.props.width}
           width={this.props.width}
           height={this.props.height}
           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`
 const TextWrap = styled.div`
   white-space: nowrap;
   white-space: nowrap;
   overflow: hidden;
   overflow: hidden;
@@ -141,6 +173,26 @@ const DropdownLabel = styled.div`
   margin: 10px 13px;
   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`
 const Option = styled.div`
   width: 100%;
   width: 100%;
   border-top: 1px solid #00000000;
   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 TabSelector from "./TabSelector";
 import Loading from "./Loading";
 import Loading from "./Loading";
 
 
+export interface TabOption {
+  label: string;
+  value: string;
+}
+
 type PropsType = {
 type PropsType = {
-  options: { label: string; value: string }[];
+  options: TabOption[];
   currentTab: string;
   currentTab: string;
   setCurrentTab: (x: string) => void;
   setCurrentTab: (x: string) => void;
   defaultTab?: string;
   defaultTab?: string;
   addendum?: any;
   addendum?: any;
   color?: string | null;
   color?: string | null;
+  suppressAnimation?: boolean;
 };
 };
 
 
 type StateType = {};
 type StateType = {};
@@ -33,49 +39,29 @@ export default class TabRegion extends Component<PropsType, StateType> {
     }
     }
   }
   }
 
 
-  renderContents = () => {
-    if (!this.props.currentTab) {
-      return <Loading />;
-    }
-
+  render() {
     return (
     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`
 const TabContents = styled.div`
   height: calc(100% - 65px);
   height: calc(100% - 65px);
 `;
 `;
@@ -86,9 +72,11 @@ const Gap = styled.div`
   height: 30px;
   height: 30px;
 `;
 `;
 
 
-const StyledTabRegion = styled.div`
+const StyledTabRegion = styled.div<{ suppressAnimation: boolean }>`
   width: 100%;
   width: 100%;
   height: 100%;
   height: 100%;
+  animation: ${(props) => (props.suppressAnimation ? "" : "fadeIn 0.25s 0s")};
   position: relative;
   position: relative;
   overflow-y: auto;
   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 }}
             editorProps={{ $blockScrolling: true }}
             height={this.props.height}
             height={this.props.height}
             width="100%"
             width="100%"
-            style={{ borderRadius: "5px" }}
+            style={{ borderRadius: "10px" }}
             showPrintMargin={false}
             showPrintMargin={false}
             showGutter={true}
             showGutter={true}
             highlightActiveLine={true}
             highlightActiveLine={true}
@@ -67,9 +67,10 @@ class YamlEditor extends Component<PropsType, StateType> {
 export default YamlEditor;
 export default YamlEditor;
 
 
 const Editor = styled.form`
 const Editor = styled.form`
-  border-radius: ${(props: { border: boolean }) => (props.border ? "5px" : "")};
+  border-radius: ${(props: { border: boolean }) =>
+    props.border ? "10px" : ""};
   border: ${(props: { border: boolean }) =>
   border: ${(props: { border: boolean }) =>
-    props.border ? "1px solid #ffffff22" : ""};
+    props.border ? "1px solid #ffffff33" : ""};
 `;
 `;
 
 
 const Holder = styled.div`
 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 React, { ChangeEvent, Component } from "react";
+import Tooltip from "@material-ui/core/Tooltip";
 import styled from "styled-components";
 import styled from "styled-components";
 
 
 type PropsType = {
 type PropsType = {
   label?: string;
   label?: string;
+  info?: string;
   type: string;
   type: string;
   value: string | number;
   value: string | number;
   setValue?: (x: string | number) => void;
   setValue?: (x: string | number) => void;
@@ -11,6 +13,7 @@ type PropsType = {
   width?: string;
   width?: string;
   disabled?: boolean;
   disabled?: boolean;
   isRequired?: boolean;
   isRequired?: boolean;
+  className?: string;
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -31,12 +34,34 @@ export default class InputRow extends Component<PropsType, StateType> {
   };
   };
 
 
   render() {
   render() {
-    let { label, value, type, unit, placeholder, width } = this.props;
+    let { label, value, type, unit, placeholder, width, info } = this.props;
     return (
     return (
-      <StyledInputRow>
-        {label && (
+      <StyledInputRow className={this.props.className}>
+        {(label || info) && (
           <Label>
           <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>
           </Label>
         )}
         )}
         <InputWrapper>
         <InputWrapper>
@@ -63,13 +88,21 @@ const Required = styled.div`
 `;
 `;
 
 
 const Unit = 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`
 const InputWrapper = styled.div`
   display: flex;
   display: flex;
   margin-bottom: -1px;
   margin-bottom: -1px;
   align-items: center;
   align-items: center;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
 `;
 `;
 
 
 const Input = styled.input<{ disabled: boolean; width: string }>`
 const Input = styled.input<{ disabled: boolean; width: string }>`
@@ -77,9 +110,7 @@ const Input = styled.input<{ disabled: boolean; width: string }>`
   border: none;
   border: none;
   font-size: 13px;
   font-size: 13px;
   background: #ffffff11;
   background: #ffffff11;
-  border: 1px solid #ffffff55;
   cursor: ${(props) => (props.disabled ? "not-allowed" : "")};
   cursor: ${(props) => (props.disabled ? "not-allowed" : "")};
-  border-radius: 3px;
   width: ${(props) => (props.width ? props.width : "270px")};
   width: ${(props) => (props.width ? props.width : "270px")};
   color: ${(props) => (props.disabled ? "#ffffff44" : "white")};
   color: ${(props) => (props.disabled ? "#ffffff44" : "white")};
   padding: 5px 10px;
   padding: 5px 10px;
@@ -99,3 +130,21 @@ const StyledInputRow = styled.div`
   margin-bottom: 15px;
   margin-bottom: 15px;
   margin-top: 22px;
   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 sliders from "assets/sliders.svg";
 import upload from "assets/upload.svg";
 import upload from "assets/upload.svg";
-import { keysIn } from "lodash";
+
+export type KeyValue = {
+  key: string;
+  value: string;
+};
 
 
 type PropsType = {
 type PropsType = {
   label?: string;
   label?: string;
@@ -45,21 +49,32 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
 
 
   valuesToObject = () => {
   valuesToObject = () => {
     let obj = {} as any;
     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) => {
     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;
     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) => {
   renderDeleteButton = (i: number) => {
@@ -93,7 +108,7 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
   renderInputList = () => {
   renderInputList = () => {
     return (
     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
           // Preprocess non-string env values set via raw Helm values
           let { value } = entry;
           let { value } = entry;
           if (typeof value === "object") {
           if (typeof value === "object") {
@@ -148,16 +163,18 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
       return (
       return (
         <Modal
         <Modal
           onRequestClose={() => this.setState({ showEnvModal: false })}
           onRequestClose={() => this.setState({ showEnvModal: false })}
-          width="665px"
-          height="342px"
+          width="765px"
+          height="542px"
         >
         >
           <LoadEnvGroupModal
           <LoadEnvGroupModal
+            existingValues={this.props.values}
             namespace={this.props.externalValues?.namespace}
             namespace={this.props.externalValues?.namespace}
             clusterId={this.props.externalValues?.clusterId}
             clusterId={this.props.externalValues?.clusterId}
             closeModal={() => this.setState({ showEnvModal: false })}
             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>
         </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() {
   render() {
     let { label, placeholder } = this.props;
     let { label, placeholder } = this.props;
-    console.log(this.state.fileName);
     if (this.state.fileName) {
     if (this.state.fileName) {
       placeholder = `Uploaded ${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;
   setSelectedImageUrl: (x: string) => void;
   setSelectedTag: (x: string) => void;
   setSelectedTag: (x: string) => void;
   setClickedImage: (x: ImageType) => void;
   setClickedImage: (x: ImageType) => void;
+  disableImageSelect?: boolean;
 };
 };
 
 
 type StateType = {
 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 = () => {
   renderImageList = () => {
     let { images, loading, error } = this.state;
     let { images, loading, error } = this.state;
 
 
@@ -206,8 +202,8 @@ export default class ImageList extends Component<PropsType, StateType> {
   };
   };
 
 
   renderBackButton = () => {
   renderBackButton = () => {
-    let { setSelectedImageUrl } = this.props;
-    if (this.props.clickedImage) {
+    let { setSelectedImageUrl, clickedImage, disableImageSelect } = this.props;
+    if (clickedImage && !disableImageSelect) {
       return (
       return (
         <BackButton
         <BackButton
           width="175px"
           width="175px"

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

@@ -17,6 +17,7 @@ type PropsType = {
   setSelectedImageUrl: (x: string) => void;
   setSelectedImageUrl: (x: string) => void;
   setSelectedTag: (x: string) => void;
   setSelectedTag: (x: string) => void;
   noTagSelection?: boolean;
   noTagSelection?: boolean;
+  disableImageSelect?: boolean;
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -36,87 +37,6 @@ export default class ImageSelector extends Component<PropsType, StateType> {
     clickedImage: null as ImageType | null,
     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 = () => {
   renderImageList = () => {
     let { images, loading, error } = this.state;
     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 = () => {
   renderSelected = () => {
     let { selectedImageUrl, setSelectedImageUrl } = this.props;
     let { selectedImageUrl, setSelectedImageUrl } = this.props;
     let { clickedImage } = this.state;
     let { clickedImage } = this.state;
@@ -192,6 +94,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
       <Label>
       <Label>
         <img src={icon} />
         <img src={icon} />
         <Input
         <Input
+          disabled={this.props.disableImageSelect}
           autoFocus={true}
           autoFocus={true}
           onClick={(e: any) => e.stopPropagation()}
           onClick={(e: any) => e.stopPropagation()}
           value={selectedImageUrl}
           value={selectedImageUrl}
@@ -233,6 +136,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
 
 
         {this.state.isExpanded ? (
         {this.state.isExpanded ? (
           <ImageList
           <ImageList
+            disableImageSelect={this.props.disableImageSelect}
             selectedImageUrl={this.props.selectedImageUrl}
             selectedImageUrl={this.props.selectedImageUrl}
             selectedTag={this.props.selectedTag}
             selectedTag={this.props.selectedTag}
             clickedImage={this.state.clickedImage}
             clickedImage={this.state.clickedImage}
@@ -284,13 +188,13 @@ const BackButton = styled.div`
   }
   }
 `;
 `;
 
 
-const Input = styled.input`
+const Input = styled.input<{ disabled: boolean }>`
   outline: 0;
   outline: 0;
   background: none;
   background: none;
   border: 0;
   border: 0;
   font-size: 13px;
   font-size: 13px;
   width: calc(100% - 60px);
   width: calc(100% - 60px);
-  color: white;
+  color: ${(props) => (props.disabled ? "#aaaabb" : "#ffffff")};
 `;
 `;
 
 
 const ImageItem = styled.div`
 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,
     currentTag: this.props.selectedTag,
   };
   };
 
 
-  componentDidMount() {
+  refreshTagList = () => {
+    this.setState({ loading: true });
     const { currentProject } = this.context;
     const { currentProject } = this.context;
 
 
     let splits = this.props.selectedImageUrl.split("/");
     let splits = this.props.selectedImageUrl.split("/");
@@ -55,6 +56,14 @@ export default class TagList extends Component<PropsType, StateType> {
         }
         }
       )
       )
       .then((res) => {
       .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) => {
         let tags = res.data.map((tag: any, i: number) => {
           return tag.tag;
           return tag.tag;
         });
         });
@@ -64,6 +73,10 @@ export default class TagList extends Component<PropsType, StateType> {
         console.log(err);
         console.log(err);
         this.setState({ loading: false, error: true });
         this.setState({ loading: false, error: true });
       });
       });
+  };
+
+  componentDidMount() {
+    this.refreshTagList();
   }
   }
 
 
   setTag = (tag: string) => {
   setTag = (tag: string) => {
@@ -105,7 +118,12 @@ export default class TagList extends Component<PropsType, StateType> {
     return (
     return (
       <>
       <>
         <TagNameAlt>
         <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>
         </TagNameAlt>
         <StyledTagList>{this.renderTagList()}</StyledTagList>
         <StyledTagList>{this.renderTagList()}</StyledTagList>
       </>
       </>
@@ -115,6 +133,36 @@ export default class TagList extends Component<PropsType, StateType> {
 
 
 TagList.contextType = Context;
 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`
 const StyledTagList = styled.div`
   max-height: 175px;
   max-height: 175px;
   position: relative;
   position: relative;
@@ -152,10 +200,13 @@ const TagName = styled.div`
 `;
 `;
 
 
 const TagNameAlt = styled(TagName)`
 const TagNameAlt = styled(TagName)`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
   color: #ffffff55;
   color: #ffffff55;
   cursor: default;
   cursor: default;
   :hover {
   :hover {
-    background: #ffffff11;
+    background: none;
     > i {
     > i {
       background: none;
       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 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 = {};
 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 styled from "styled-components";
 
 
 import { ActionConfigType } from "shared/types";
 import { ActionConfigType } from "shared/types";
-import { Context } from "shared/Context";
 
 
 import RepoList from "./RepoList";
 import RepoList from "./RepoList";
 import BranchList from "./BranchList";
 import BranchList from "./BranchList";
 import ContentsList from "./ContentsList";
 import ContentsList from "./ContentsList";
 import ActionDetails from "./ActionDetails";
 import ActionDetails from "./ActionDetails";
 
 
-type PropsType = {
+type Props = {
   actionConfig: ActionConfigType | null;
   actionConfig: ActionConfigType | null;
   branch: string;
   branch: string;
   setActionConfig: (x: ActionConfigType) => void;
   setActionConfig: (x: ActionConfigType) => void;
@@ -27,11 +26,6 @@ type PropsType = {
   selectedRegistry: any;
   selectedRegistry: any;
 };
 };
 
 
-type StateType = {
-  loading: boolean;
-  error: boolean;
-};
-
 const defaultActionConfig: ActionConfigType = {
 const defaultActionConfig: ActionConfigType = {
   git_repo: "",
   git_repo: "",
   image_repo_uri: "",
   image_repo_uri: "",
@@ -39,133 +33,116 @@ const defaultActionConfig: ActionConfigType = {
   git_repo_id: 0,
   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}
             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 (
     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`
 const Br = styled.div`
   width: 100%;
   width: 100%;
@@ -201,7 +178,10 @@ const ExpandedWrapper = styled.div`
   overflow-y: auto;
   overflow-y: auto;
 `;
 `;
 
 
-const ExpandedWrapperAlt = styled(ExpandedWrapper)``;
+const ExpandedWrapperAlt = styled(ExpandedWrapper)`
+  border: 0;
+  overflow: hidden;
+`;
 
 
 const BackButton = styled.div`
 const BackButton = styled.div`
   display: flex;
   display: flex;

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است