Browse Source

Merge branch 'master' of github.com:porter-dev/porter

jnfrati 4 năm trước cách đây
mục cha
commit
30211f899e
63 tập tin đã thay đổi với 2221 bổ sung507 xóa
  1. 6 2
      .github/workflows/dev.yaml
  2. 6 2
      .github/workflows/production.yaml
  3. 6 2
      .github/workflows/staging.yaml
  4. 17 0
      api/client/deploy.go
  5. 15 29
      api/server/handlers/environment/delete_deployment.go
  6. 8 13
      api/server/handlers/environment/list_deployments_by_cluster.go
  7. 9 20
      api/server/handlers/environment/trigger_deployment_workflow.go
  8. 29 30
      api/server/handlers/gitinstallation/rerun_workflow.go
  9. 17 0
      api/server/handlers/infra/forms.go
  10. 3 8
      api/server/router/cluster.go
  11. 1 1
      api/server/router/middleware/request_logger.go
  12. 1 1
      api/server/shared/apierrors/errors.go
  13. 1 1
      api/server/shared/apitest/config.go
  14. 38 0
      api/server/shared/commonutils/git_utils.go
  15. 1 1
      api/server/shared/config/config.go
  16. 1 1
      api/server/shared/config/loader/loader.go
  17. 1 1
      api/server/shared/reader.go
  18. 1 1
      api/server/shared/writer.go
  19. 65 21
      cli/cmd/apply.go
  20. 3 1
      cli/cmd/create.go
  21. 146 0
      cli/cmd/delete.go
  22. 54 2
      cli/cmd/deploy.go
  23. 97 69
      cli/cmd/deploy/create.go
  24. 133 68
      cli/cmd/deploy/deploy.go
  25. 1 1
      cli/cmd/deploy/shared.go
  26. 73 34
      cli/cmd/list.go
  27. 288 0
      cli/cmd/portforward.go
  28. 70 54
      cli/cmd/preview/build_image_driver.go
  29. 0 1
      cli/cmd/preview/push_image_driver.go
  30. 17 10
      cli/cmd/preview/random_string_driver.go
  31. 15 2
      cli/cmd/run.go
  32. 1 1
      cmd/migrate/main.go
  33. 1 1
      cmd/ready/main.go
  34. 27 21
      dashboard/src/components/MultiSaveButton.tsx
  35. 9 4
      dashboard/src/components/form-components/KeyValueArray.tsx
  36. 2 1
      dashboard/src/components/porter-form/field-components/CronInput.tsx
  37. 5 1
      dashboard/src/components/porter-form/field-components/KeyValueArray.tsx
  38. 2 1
      dashboard/src/components/repo-selector/BuildpackSelection.tsx
  39. 857 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/BuildSettingsTab.tsx
  40. 52 43
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  41. 24 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  42. 30 27
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  43. 1 4
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentCard.tsx
  44. 6 2
      dashboard/src/main/home/modals/LoadEnvGroupModal.tsx
  45. 47 13
      dashboard/src/shared/api.tsx
  46. 7 0
      dashboard/src/shared/release/utils.ts
  47. 12 0
      dashboard/src/shared/types.tsx
  48. 1 0
      docker/Dockerfile
  49. 1 0
      docker/cli.Dockerfile
  50. 1 0
      ee/docker/ee.Dockerfile
  51. 1 0
      ee/docker/provisioner.Dockerfile
  52. 1 0
      go.mod
  53. 1 0
      go.sum
  54. 1 1
      internal/analytics/segment.go
  55. 1 1
      internal/helm/agent_test.go
  56. 1 1
      internal/helm/config.go
  57. 1 1
      internal/helm/storage.go
  58. 1 1
      internal/helm/storage_test.go
  59. 1 1
      internal/models/gitrepo.go
  60. 0 0
      pkg/logger/logger.go
  61. 1 1
      provisioner/server/config/config.go
  62. 1 0
      services/migrator/Dockerfile
  63. 1 0
      services/porter_cli_container/dev.Dockerfile

+ 6 - 2
.github/workflows/dev.yaml

@@ -20,7 +20,9 @@ jobs:
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-region: ${{ secrets.AWS_REGION }}
           aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
       - name: Install kubectl
-        uses: azure/setup-kubectl@v1
+        uses: azure/setup-kubectl@v2.0
+        with:
+          version: 'v1.19.15'
       - name: Log in to gcloud CLI
       - name: Log in to gcloud CLI
         run: gcloud auth configure-docker
         run: gcloud auth configure-docker
       - name: Checkout
       - name: Checkout
@@ -66,7 +68,9 @@ jobs:
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-region: ${{ secrets.AWS_REGION }}
           aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
       - name: Install kubectl
-        uses: azure/setup-kubectl@v1
+        uses: azure/setup-kubectl@v2.0
+        with:
+          version: 'v1.19.15'
       - name: Log in to gcloud CLI
       - name: Log in to gcloud CLI
         run: gcloud auth configure-docker
         run: gcloud auth configure-docker
       - name: Checkout
       - name: Checkout

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

@@ -20,7 +20,9 @@ jobs:
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-region: ${{ secrets.AWS_REGION }}
           aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
       - name: Install kubectl
-        uses: azure/setup-kubectl@v1
+        uses: azure/setup-kubectl@v2.0
+        with:
+          version: 'v1.19.15'
       - name: Log in to gcloud CLI
       - name: Log in to gcloud CLI
         run: gcloud auth configure-docker
         run: gcloud auth configure-docker
       - name: Checkout
       - name: Checkout
@@ -73,7 +75,9 @@ jobs:
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-region: ${{ secrets.AWS_REGION }}
           aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
       - name: Install kubectl
-        uses: azure/setup-kubectl@v1
+        uses: azure/setup-kubectl@v2.0
+        with:
+          version: 'v1.19.15'
       - name: Log in to gcloud CLI
       - name: Log in to gcloud CLI
         run: gcloud auth configure-docker
         run: gcloud auth configure-docker
       - name: Checkout
       - name: Checkout

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

@@ -20,7 +20,9 @@ jobs:
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-region: ${{ secrets.AWS_REGION }}
           aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
       - name: Install kubectl
-        uses: azure/setup-kubectl@v1
+        uses: azure/setup-kubectl@v2.0
+        with:
+          version: 'v1.19.15'
       - name: Log in to gcloud CLI
       - name: Log in to gcloud CLI
         run: gcloud auth configure-docker
         run: gcloud auth configure-docker
       - name: Checkout
       - name: Checkout
@@ -72,7 +74,9 @@ jobs:
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-region: ${{ secrets.AWS_REGION }}
           aws-region: ${{ secrets.AWS_REGION }}
       - name: Install kubectl
       - name: Install kubectl
-        uses: azure/setup-kubectl@v1
+        uses: azure/setup-kubectl@v2.0
+        with:
+          version: 'v1.19.15'
       - name: Log in to gcloud CLI
       - name: Log in to gcloud CLI
         run: gcloud auth configure-docker
         run: gcloud auth configure-docker
       - name: Checkout
       - name: Checkout

+ 17 - 0
api/client/deploy.go

@@ -107,3 +107,20 @@ func (c *Client) UpgradeRelease(
 		},
 		},
 	)
 	)
 }
 }
+
+// DeleteRelease deletes a Porter release
+func (c *Client) DeleteRelease(
+	ctx context.Context,
+	projID, clusterID uint,
+	namespace, name string,
+) error {
+	return c.deleteRequest(
+		fmt.Sprintf(
+			"/projects/%d/clusters/%d/namespaces/%s/releases/%s/0",
+			projID, clusterID,
+			namespace, name,
+		),
+		nil,
+		nil,
+	)
+}

+ 15 - 29
api/server/handlers/environment/delete_deployment.go

@@ -10,7 +10,6 @@ import (
 	"github.com/google/go-github/v41/github"
 	"github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
-	"github.com/porter-dev/porter/api/server/handlers/gitinstallation"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
@@ -40,33 +39,7 @@ func (c *DeleteDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
 
-	envID, reqErr := requestutils.GetURLParamUint(r, "environment_id")
-
-	if reqErr != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
-		return
-	}
-
-	// check that the environment belongs to the project and cluster IDs
-	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, envID)
-
-	if err != nil {
-		if errors.Is(err, gorm.ErrRecordNotFound) {
-			c.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("environment id not found in cluster and project")))
-			return
-		}
-
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
-		return
-	}
-
-	owner, name, ok := gitinstallation.GetOwnerAndNameParams(c, w, r)
-
-	if !ok {
-		return
-	}
-
-	prNumber, reqErr := requestutils.GetURLParamUint(r, "pr_number")
+	deplID, reqErr := requestutils.GetURLParamUint(r, "deployment_id")
 
 
 	if reqErr != nil {
 	if reqErr != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
@@ -74,7 +47,7 @@ func (c *DeleteDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	}
 	}
 
 
 	// read the deployment
 	// read the deployment
-	depl, err := c.Repo().Environment().ReadDeploymentByGitDetails(envID, owner, name, prNumber)
+	depl, err := c.Repo().Environment().ReadDeploymentByID(project.ID, cluster.ID, deplID)
 
 
 	if err != nil {
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -99,6 +72,19 @@ func (c *DeleteDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		}
 		}
 	}
 	}
 
 
+	// check that the environment belongs to the project and cluster IDs
+	env, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, depl.EnvironmentID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("environment id not found in cluster and project")))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
+		return
+	}
+
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 
 
 	if err != nil {
 	if err != nil {

+ 8 - 13
api/server/handlers/environment/list_deployments_by_cluster.go

@@ -9,6 +9,7 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/commonutils"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
@@ -140,23 +141,17 @@ func updateDeploymentWithGithubWorkflowRunStatus(
 	env *models.Environment,
 	env *models.Environment,
 	deployment *types.Deployment,
 	deployment *types.Deployment,
 ) {
 ) {
+	if deployment.Status == types.DeploymentStatusInactive {
+		return
+	}
+
 	client, err := getGithubClientFromEnvironment(config, env)
 	client, err := getGithubClientFromEnvironment(config, env)
 
 
 	if err == nil {
 	if err == nil {
-		workflowRuns, _, err := client.Actions.ListWorkflowRunsByFileName(
-			ctx, deployment.RepoOwner, deployment.RepoName,
-			fmt.Sprintf("porter_%s_env.yml", env.Name), &github.ListWorkflowRunsOptions{
-				Branch: deployment.PRBranchFrom,
-				ListOptions: github.ListOptions{
-					Page:    1,
-					PerPage: 1,
-				},
-			},
-		)
-
-		if err == nil && workflowRuns.GetTotalCount() > 0 {
-			latestWorkflowRun := workflowRuns.WorkflowRuns[0]
+		latestWorkflowRun, err := commonutils.GetLatestWorkflowRun(client, env.GitRepoOwner, env.GitRepoName,
+			fmt.Sprintf("porter_%s_env.yml", env.Name), deployment.PRBranchFrom)
 
 
+		if err == nil {
 			deployment.LastWorkflowRunURL = latestWorkflowRun.GetHTMLURL()
 			deployment.LastWorkflowRunURL = latestWorkflowRun.GetHTMLURL()
 
 
 			if (latestWorkflowRun.GetStatus() == "in_progress" ||
 			if (latestWorkflowRun.GetStatus() == "in_progress" ||

+ 9 - 20
api/server/handlers/environment/trigger_deployment_workflow.go

@@ -1,7 +1,6 @@
 package environment
 package environment
 
 
 import (
 import (
-	"context"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
 	"net/http"
 	"net/http"
@@ -11,6 +10,7 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/commonutils"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
@@ -69,8 +69,8 @@ func (c *TriggerDeploymentWorkflowHandler) ServeHTTP(w http.ResponseWriter, r *h
 		return
 		return
 	}
 	}
 
 
-	latestWorkflowRun, err := getLatestWorkflowRun(client, env.GitRepoOwner, env.GitRepoName,
-		fmt.Sprintf("porter_%s_env.yml", env.Name))
+	latestWorkflowRun, err := commonutils.GetLatestWorkflowRun(client, env.GitRepoOwner, env.GitRepoName,
+		fmt.Sprintf("porter_%s_env.yml", env.Name), depl.PRBranchFrom)
 
 
 	if err != nil && errors.Is(err, ErrNoWorkflowRuns) {
 	if err != nil && errors.Is(err, ErrNoWorkflowRuns) {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, 400))
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, 400))
@@ -107,25 +107,14 @@ func (c *TriggerDeploymentWorkflowHandler) ServeHTTP(w http.ResponseWriter, r *h
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 		return
 	}
 	}
-}
 
 
-func getLatestWorkflowRun(client *github.Client, owner, repo, filename string) (*github.WorkflowRun, error) {
-	workflowRuns, _, err := client.Actions.ListWorkflowRunsByFileName(
-		context.Background(), owner, repo, filename, &github.ListWorkflowRunsOptions{
-			ListOptions: github.ListOptions{
-				Page:    1,
-				PerPage: 1,
-			},
-		},
-	)
+	// set the status to updating manually here for the frontend to case on
+	depl.Status = types.DeploymentStatusUpdating
 
 
-	if err != nil {
-		return nil, err
-	}
+	_, err = c.Repo().Environment().UpdateDeployment(depl)
 
 
-	if workflowRuns.GetTotalCount() == 0 {
-		return nil, ErrNoWorkflowRuns
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
 	}
 	}
-
-	return workflowRuns.WorkflowRuns[0], nil
 }
 }

+ 29 - 30
api/server/handlers/gitinstallation/rerun_workflow.go

@@ -1,20 +1,18 @@
 package gitinstallation
 package gitinstallation
 
 
 import (
 import (
-	"context"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
 	"net/http"
 	"net/http"
+	"strings"
 
 
-	"github.com/google/go-github/v41/github"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/commonutils"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/config"
 )
 )
 
 
-var ErrNoWorkflowRuns = errors.New("no previous workflow runs found")
-
 type RerunWorkflowHandler struct {
 type RerunWorkflowHandler struct {
 	handlers.PorterHandlerReadWriter
 	handlers.PorterHandlerReadWriter
 }
 }
@@ -37,10 +35,28 @@ func (c *RerunWorkflowHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	}
 	}
 
 
 	filename := r.URL.Query().Get("filename")
 	filename := r.URL.Query().Get("filename")
+	// if branch is empty then the latest workflow run is rerun, meaning that if
+	// there were multiple workflow runs for the same file but for different branches
+	// only the very latest of the workflow runs will be rerun
+	branch := r.URL.Query().Get("branch")
+	releaseName := r.URL.Query().Get("release_name")
+
+	if filename == "" && releaseName == "" {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("filename and release name are both empty")))
+		return
+	}
 
 
 	if filename == "" {
 	if filename == "" {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("filename query param not set")))
-		return
+		if c.Config().ServerConf.InstanceName != "" {
+			filename = fmt.Sprintf("porter_%s_%s.yml", strings.Replace(
+				strings.ToLower(releaseName), "-", "_", -1),
+				strings.ToLower(c.Config().ServerConf.InstanceName),
+			)
+		} else {
+			filename = fmt.Sprintf("porter_%s.yml", strings.Replace(
+				strings.ToLower(releaseName), "-", "_", -1),
+			)
+		}
 	}
 	}
 
 
 	client, err := GetGithubAppClientFromRequest(c.Config(), r)
 	client, err := GetGithubAppClientFromRequest(c.Config(), r)
@@ -50,11 +66,15 @@ func (c *RerunWorkflowHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	latestWorkflowRun, err := getLatestWorkflowRun(client, owner, name, filename)
+	latestWorkflowRun, err := commonutils.GetLatestWorkflowRun(client, owner, name, filename, branch)
 
 
-	if err != nil && errors.Is(err, ErrNoWorkflowRuns) {
+	if err != nil && errors.Is(err, commonutils.ErrNoWorkflowRuns) {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, 400))
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, 400))
 		return
 		return
+	} else if err != nil && errors.Is(err, commonutils.ErrWorkflowNotFound) {
+		w.WriteHeader(http.StatusNotFound)
+		c.WriteResult(w, r, filename)
+		return
 	} else if err != nil {
 	} else if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 		return
@@ -73,7 +93,7 @@ func (c *RerunWorkflowHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 		return
 	}
 	}
 
 
-	latestWorkflowRun, err = getLatestWorkflowRun(client, owner, name, filename)
+	latestWorkflowRun, err = commonutils.GetLatestWorkflowRun(client, owner, name, filename, branch)
 
 
 	if err != nil {
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
@@ -82,24 +102,3 @@ func (c *RerunWorkflowHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 
 
 	c.WriteResult(w, r, latestWorkflowRun.GetHTMLURL())
 	c.WriteResult(w, r, latestWorkflowRun.GetHTMLURL())
 }
 }
-
-func getLatestWorkflowRun(client *github.Client, owner, repo, filename string) (*github.WorkflowRun, error) {
-	workflowRuns, _, err := client.Actions.ListWorkflowRunsByFileName(
-		context.Background(), owner, repo, filename, &github.ListWorkflowRunsOptions{
-			ListOptions: github.ListOptions{
-				Page:    1,
-				PerPage: 1,
-			},
-		},
-	)
-
-	if err != nil {
-		return nil, err
-	}
-
-	if workflowRuns.GetTotalCount() == 0 {
-		return nil, ErrNoWorkflowRuns
-	}
-
-	return workflowRuns.WorkflowRuns[0], nil
-}

+ 17 - 0
api/server/handlers/infra/forms.go

@@ -387,6 +387,13 @@ tabs:
       placeholder: "ex: 10"
       placeholder: "ex: 10"
       settings:
       settings:
         default: 10
         default: 10
+- name: advanced
+  label: Advanced
+  sections:
+  - name: spot_instance_should_enable
+    contents:
+    - type: heading
+      label: Spot Instance Settings
     - type: checkbox
     - type: checkbox
       variable: spot_instances_enabled
       variable: spot_instances_enabled
       label: Enable spot instances for this cluster.
       label: Enable spot instances for this cluster.
@@ -399,6 +406,16 @@ tabs:
       label: Assign a bid price for the spot instance (optional).
       label: Assign a bid price for the spot instance (optional).
       variable: spot_price
       variable: spot_price
       placeholder: "ex: 0.05"
       placeholder: "ex: 0.05"
+  - name: net_settings
+    contents:
+    - type: heading
+      label: Networking Settings
+    - type: string-input
+      label: "Add a different CIDR range prefix (first two octets: for example 10.99 will create a VPC with CIDR range 10.99.0.0/16)."
+      variable: cluster_vpc_cidr_octets
+      placeholder: "ex: 10.99"
+      settings:
+        default: "10.99"
 `
 `
 
 
 const gcrForm = `name: GCR
 const gcrForm = `name: GCR

+ 3 - 8
api/server/router/cluster.go

@@ -463,20 +463,15 @@ func getClusterRoutes(
 			Router:   r,
 			Router:   r,
 		})
 		})
 
 
-		// DELETE /api/projects/{project_id}/clusters/{cluster_id}/deployments/{environment_id}/{owner}/{name}/{pr_number} ->
+		// DELETE /api/projects/{project_id}/clusters/{cluster_id}/deployments/{deployment_id} ->
 		// environment.NewDeleteDeploymentHandler
 		// environment.NewDeleteDeploymentHandler
 		deleteDeploymentEndpoint := factory.NewAPIEndpoint(
 		deleteDeploymentEndpoint := factory.NewAPIEndpoint(
 			&types.APIRequestMetadata{
 			&types.APIRequestMetadata{
 				Verb:   types.APIVerbDelete,
 				Verb:   types.APIVerbDelete,
 				Method: types.HTTPVerbDelete,
 				Method: types.HTTPVerbDelete,
 				Path: &types.Path{
 				Path: &types.Path{
-					Parent: basePath,
-					RelativePath: fmt.Sprintf(
-						"%s/deployments/{environment_id}/{%s}/{%s}/{pr_number}",
-						relPath,
-						types.URLParamGitRepoOwner,
-						types.URLParamGitRepoName,
-					),
+					Parent:       basePath,
+					RelativePath: relPath + "/deployments/{deployment_id}",
 				},
 				},
 				Scopes: []types.PermissionScope{
 				Scopes: []types.PermissionScope{
 					types.UserScope,
 					types.UserScope,

+ 1 - 1
api/server/router/middleware/request_logger.go

@@ -7,7 +7,7 @@ import (
 	"net/http"
 	"net/http"
 	"time"
 	"time"
 
 
-	"github.com/porter-dev/porter/internal/logger"
+	"github.com/porter-dev/porter/pkg/logger"
 )
 )
 
 
 type requestLoggerResponseWriter struct {
 type requestLoggerResponseWriter struct {

+ 1 - 1
api/server/shared/apierrors/errors.go

@@ -7,7 +7,7 @@ import (
 
 
 	"github.com/porter-dev/porter/api/server/shared/apierrors/alerter"
 	"github.com/porter-dev/porter/api/server/shared/apierrors/alerter"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
-	"github.com/porter-dev/porter/internal/logger"
+	"github.com/porter-dev/porter/pkg/logger"
 )
 )
 
 
 type RequestError interface {
 type RequestError interface {

+ 1 - 1
api/server/shared/apitest/config.go

@@ -10,8 +10,8 @@ import (
 	"github.com/porter-dev/porter/internal/auth/sessionstore"
 	"github.com/porter-dev/porter/internal/auth/sessionstore"
 	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/internal/billing"
 	"github.com/porter-dev/porter/internal/billing"
-	"github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/repository/test"
 	"github.com/porter-dev/porter/internal/repository/test"
+	"github.com/porter-dev/porter/pkg/logger"
 )
 )
 
 
 type TestConfigLoader struct {
 type TestConfigLoader struct {

+ 38 - 0
api/server/shared/commonutils/git_utils.go

@@ -0,0 +1,38 @@
+package commonutils
+
+import (
+	"context"
+	"errors"
+	"net/http"
+
+	"github.com/google/go-github/v41/github"
+)
+
+var ErrNoWorkflowRuns = errors.New("no previous workflow runs found")
+var ErrWorkflowNotFound = errors.New("no workflow found, file missing")
+
+func GetLatestWorkflowRun(client *github.Client, owner, repo, filename, branch string) (*github.WorkflowRun, error) {
+	workflowRuns, ghResponse, err := client.Actions.ListWorkflowRunsByFileName(
+		context.Background(), owner, repo, filename, &github.ListWorkflowRunsOptions{
+			Branch: branch,
+			ListOptions: github.ListOptions{
+				Page:    1,
+				PerPage: 1,
+			},
+		},
+	)
+
+	if ghResponse != nil && ghResponse.StatusCode == http.StatusNotFound {
+		return nil, ErrWorkflowNotFound
+	}
+
+	if err != nil {
+		return nil, err
+	}
+
+	if workflowRuns == nil || workflowRuns.GetTotalCount() == 0 {
+		return nil, ErrNoWorkflowRuns
+	}
+
+	return workflowRuns.WorkflowRuns[0], nil
+}

+ 1 - 1
api/server/shared/config/config.go

@@ -10,11 +10,11 @@ import (
 	"github.com/porter-dev/porter/internal/billing"
 	"github.com/porter-dev/porter/internal/billing"
 	"github.com/porter-dev/porter/internal/helm/urlcache"
 	"github.com/porter-dev/porter/internal/helm/urlcache"
 	"github.com/porter-dev/porter/internal/integrations/powerdns"
 	"github.com/porter-dev/porter/internal/integrations/powerdns"
-	"github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/notifier"
 	"github.com/porter-dev/porter/internal/notifier"
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository/credentials"
 	"github.com/porter-dev/porter/internal/repository/credentials"
+	"github.com/porter-dev/porter/pkg/logger"
 	"github.com/porter-dev/porter/provisioner/client"
 	"github.com/porter-dev/porter/provisioner/client"
 	"golang.org/x/oauth2"
 	"golang.org/x/oauth2"
 	"gorm.io/gorm"
 	"gorm.io/gorm"

+ 1 - 1
api/server/shared/config/loader/loader.go

@@ -25,7 +25,7 @@ import (
 	"github.com/porter-dev/porter/internal/repository/gorm"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 	"github.com/porter-dev/porter/provisioner/client"
 	"github.com/porter-dev/porter/provisioner/client"
 
 
-	lr "github.com/porter-dev/porter/internal/logger"
+	lr "github.com/porter-dev/porter/pkg/logger"
 
 
 	pgorm "gorm.io/gorm"
 	pgorm "gorm.io/gorm"
 )
 )

+ 1 - 1
api/server/shared/reader.go

@@ -7,7 +7,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/apierrors/alerter"
 	"github.com/porter-dev/porter/api/server/shared/apierrors/alerter"
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
-	"github.com/porter-dev/porter/internal/logger"
+	"github.com/porter-dev/porter/pkg/logger"
 )
 )
 
 
 type RequestDecoderValidator interface {
 type RequestDecoderValidator interface {

+ 1 - 1
api/server/shared/writer.go

@@ -8,7 +8,7 @@ import (
 
 
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/apierrors/alerter"
 	"github.com/porter-dev/porter/api/server/shared/apierrors/alerter"
-	"github.com/porter-dev/porter/internal/logger"
+	"github.com/porter-dev/porter/pkg/logger"
 )
 )
 
 
 type ResultWriter interface {
 type ResultWriter interface {

+ 65 - 21
cli/cmd/apply.go

@@ -175,8 +175,6 @@ type ApplicationConfig struct {
 	OnlyCreate bool
 	OnlyCreate bool
 
 
 	Build struct {
 	Build struct {
-		ForceBuild bool `mapstructure:"force_build"`
-		ForcePush  bool `mapstructure:"force_push"`
 		UseCache   bool `mapstructure:"use_cache"`
 		UseCache   bool `mapstructure:"use_cache"`
 		Method     string
 		Method     string
 		Context    string
 		Context    string
@@ -184,6 +182,7 @@ type ApplicationConfig struct {
 		Image      string
 		Image      string
 		Builder    string
 		Builder    string
 		Buildpacks []string
 		Buildpacks []string
+		Env        map[string]string
 	}
 	}
 
 
 	EnvGroups []types.EnvGroupMeta `mapstructure:"env_groups"`
 	EnvGroups []types.EnvGroupMeta `mapstructure:"env_groups"`
@@ -398,26 +397,31 @@ func (d *Driver) applyApplication(resource *models.Resource, client *api.Client,
 	if d.source.Name == "job" && appConfig.WaitForJob && (shouldCreate || !appConfig.OnlyCreate) {
 	if d.source.Name == "job" && appConfig.WaitForJob && (shouldCreate || !appConfig.OnlyCreate) {
 		color.New(color.FgYellow).Printf("Waiting for job '%s' to finish\n", resource.Name)
 		color.New(color.FgYellow).Printf("Waiting for job '%s' to finish\n", resource.Name)
 
 
-		prevProject := cliConf.Project
-		prevCluster := cliConf.Cluster
-		name = resource.Name
-		namespace = d.target.Namespace
-		cliConf.Project = d.target.Project
-		cliConf.Cluster = d.target.Cluster
-
 		err = wait.WaitForJob(client, &wait.WaitOpts{
 		err = wait.WaitForJob(client, &wait.WaitOpts{
-			ProjectID: cliConf.Project,
-			ClusterID: cliConf.Cluster,
-			Namespace: namespace,
-			Name:      name,
+			ProjectID: d.target.Project,
+			ClusterID: d.target.Cluster,
+			Namespace: d.target.Namespace,
+			Name:      resource.Name,
 		})
 		})
 
 
 		if err != nil {
 		if err != nil {
-			return nil, err
-		}
+			if appConfig.OnlyCreate {
+				err = client.DeleteRelease(
+					context.Background(),
+					d.target.Project,
+					d.target.Cluster,
+					d.target.Namespace,
+					resource.Name,
+				)
+
+				if err != nil {
+					return nil, fmt.Errorf("error deleting job %s with waitForJob and onlyCreate set to true: %w",
+						resource.Name, err)
+				}
+			}
 
 
-		cliConf.Project = prevProject
-		cliConf.Cluster = prevCluster
+			return nil, fmt.Errorf("error waiting for job %s: %w", resource.Name, err)
+		}
 	}
 	}
 
 
 	return resource, err
 	return resource, err
@@ -497,7 +501,7 @@ func (d *Driver) createApplication(resource *models.Resource, client *api.Client
 			}
 			}
 		}
 		}
 
 
-		subdomain, err = createAgent.CreateFromDocker(appConf.Values, sharedOpts.OverrideTag, buildConfig, appConf.Build.ForceBuild)
+		subdomain, err = createAgent.CreateFromDocker(appConf.Values, sharedOpts.OverrideTag, buildConfig)
 	}
 	}
 
 
 	if err != nil {
 	if err != nil {
@@ -510,6 +514,10 @@ func (d *Driver) createApplication(resource *models.Resource, client *api.Client
 func (d *Driver) updateApplication(resource *models.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *ApplicationConfig) (*models.Resource, error) {
 func (d *Driver) updateApplication(resource *models.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *ApplicationConfig) (*models.Resource, error) {
 	color.New(color.FgGreen).Println("Updating existing release:", resource.Name)
 	color.New(color.FgGreen).Println("Updating existing release:", resource.Name)
 
 
+	if len(appConf.Build.Env) > 0 {
+		sharedOpts.AdditionalEnv = appConf.Build.Env
+	}
+
 	updateAgent, err := deploy.NewDeployAgent(client, resource.Name, &deploy.DeployOpts{
 	updateAgent, err := deploy.NewDeployAgent(client, resource.Name, &deploy.DeployOpts{
 		SharedOpts: sharedOpts,
 		SharedOpts: sharedOpts,
 		Local:      appConf.Build.Method != "registry",
 		Local:      appConf.Build.Method != "registry",
@@ -545,14 +553,14 @@ func (d *Driver) updateApplication(resource *models.Resource, client *api.Client
 			}
 			}
 		}
 		}
 
 
-		err = updateAgent.Build(buildConfig, appConf.Build.ForceBuild)
+		err = updateAgent.Build(buildConfig)
 
 
 		if err != nil {
 		if err != nil {
 			return nil, err
 			return nil, err
 		}
 		}
 
 
 		if !appConf.Build.UseCache {
 		if !appConf.Build.UseCache {
-			err = updateAgent.Push(appConf.Build.ForcePush)
+			err = updateAgent.Push()
 
 
 			if err != nil {
 			if err != nil {
 				return nil, err
 				return nil, err
@@ -775,7 +783,43 @@ func (t *DeploymentHook) DataQueries() map[string]interface{} {
 		}
 		}
 
 
 		if isWeb {
 		if isWeb {
-			res[resource.Name] = fmt.Sprintf("{ .%s.ingress.porter_hosts[0] }", resource.Name)
+			// determine if we should query for porter_hosts or just hosts
+			isCustomDomain := false
+
+			ingressMap, err := deploy.GetNestedMap(resource.Config, "values", "ingress")
+
+			if err == nil {
+				enabledVal, enabledExists := ingressMap["enabled"]
+
+				customDomVal, customDomExists := ingressMap["custom_domain"]
+
+				if enabledExists && customDomExists {
+					enabled, eOK := enabledVal.(bool)
+					customDomain, cOK := customDomVal.(bool)
+
+					if eOK && cOK && enabled {
+						if customDomain {
+							// return the first custom domain when one exists
+							hostsArr, hostsExists := ingressMap["hosts"]
+
+							if hostsExists {
+								hostsArrVal, hostsArrOk := hostsArr.([]interface{})
+
+								if hostsArrOk && len(hostsArrVal) > 0 {
+									if _, ok := hostsArrVal[0].(string); ok {
+										res[resource.Name] = fmt.Sprintf("{ .%s.ingress.hosts[0] }", resource.Name)
+										isCustomDomain = true
+									}
+								}
+							}
+						}
+					}
+				}
+			}
+
+			if !isCustomDomain {
+				res[resource.Name] = fmt.Sprintf("{ .%s.ingress.porter_hosts[0] }", resource.Name)
+			}
 		}
 		}
 	}
 	}
 
 

+ 3 - 1
cli/cmd/create.go

@@ -174,6 +174,8 @@ func init() {
 		false,
 		false,
 		"Whether to use cache (currently in beta)",
 		"Whether to use cache (currently in beta)",
 	)
 	)
+
+	createCmd.PersistentFlags().MarkDeprecated("force-build", "--force-build is deprecated")
 }
 }
 
 
 var supportedKinds = map[string]string{"web": "", "job": "", "worker": ""}
 var supportedKinds = map[string]string{"web": "", "job": "", "worker": ""}
@@ -276,7 +278,7 @@ func createFull(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 			}
 			}
 		}
 		}
 
 
-		subdomain, err := createAgent.CreateFromDocker(valuesObj, "default", nil, forceBuild)
+		subdomain, err := createAgent.CreateFromDocker(valuesObj, "default", nil)
 
 
 		return handleSubdomainCreate(subdomain, err)
 		return handleSubdomainCreate(subdomain, err)
 	} else if source == "github" {
 	} else if source == "github" {

+ 146 - 0
cli/cmd/delete.go

@@ -39,7 +39,63 @@ deleting a configuration:
 	},
 	},
 }
 }
 
 
+// deleteAppsCmd represents the "porter delete apps" subcommand
+var deleteAppsCmd = &cobra.Command{
+	Use:     "apps",
+	Aliases: []string{"app", "applications", "application"},
+	Short:   "Deletes an existing app",
+	Args:    cobra.ExactArgs(1),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, deleteApp)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+// deleteJobsCmd represents the "porter delete jobs" subcommand
+var deleteJobsCmd = &cobra.Command{
+	Use:     "jobs",
+	Aliases: []string{"job"},
+	Short:   "Deletes an existing job",
+	Args:    cobra.ExactArgs(1),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, deleteJob)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+// deleteAddonsCmd represents the "porter delete addons" subcommand
+var deleteAddonsCmd = &cobra.Command{
+	Use:     "addons",
+	Aliases: []string{"addon"},
+	Short:   "Deletes an existing addon",
+	Args:    cobra.ExactArgs(1),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, deleteAddon)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
 func init() {
 func init() {
+	deleteCmd.PersistentFlags().StringVar(
+		&namespace,
+		"namespace",
+		"default",
+		"Namespace of the application",
+	)
+
+	deleteCmd.AddCommand(deleteAppsCmd)
+	deleteCmd.AddCommand(deleteJobsCmd)
+	deleteCmd.AddCommand(deleteAddonsCmd)
+
 	rootCmd.AddCommand(deleteCmd)
 	rootCmd.AddCommand(deleteCmd)
 }
 }
 
 
@@ -90,3 +146,93 @@ func delete(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []st
 		gitRepoOwner, gitRepoName, gitPRNumber,
 		gitRepoOwner, gitRepoName, gitPRNumber,
 	)
 	)
 }
 }
+
+func deleteApp(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	name := args[0]
+
+	resp, err := client.GetRelease(
+		context.Background(), cliConf.Project, cliConf.Cluster, namespace, name,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	rel := *resp
+
+	if rel.Chart.Name() != "web" && rel.Chart.Name() != "worker" {
+		return fmt.Errorf("no app found with name: %s", name)
+	}
+
+	color.New(color.FgBlue).Printf("Deleting app: %s\n", name)
+
+	err = client.DeleteRelease(
+		context.Background(), cliConf.Project, cliConf.Cluster, namespace, name,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func deleteJob(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	name := args[0]
+
+	resp, err := client.GetRelease(
+		context.Background(), cliConf.Project, cliConf.Cluster, namespace, name,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	rel := *resp
+
+	if rel.Chart.Name() != "job" {
+		return fmt.Errorf("no job found with name: %s", name)
+	}
+
+	color.New(color.FgBlue).Printf("Deleting job: %s\n", name)
+
+	err = client.DeleteRelease(
+		context.Background(), cliConf.Project, cliConf.Cluster, namespace, name,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func deleteAddon(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	name := args[0]
+
+	resp, err := client.GetRelease(
+		context.Background(), cliConf.Project, cliConf.Cluster, namespace, name,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	rel := *resp
+
+	if rel.Chart.Name() == "web" || rel.Chart.Name() == "worker" || rel.Chart.Name() == "job" {
+		return fmt.Errorf("no addon found with name: %s", name)
+	}
+
+	color.New(color.FgBlue).Printf("Deleting addon: %s\n", name)
+
+	err = client.DeleteRelease(
+		context.Background(), cliConf.Project, cliConf.Cluster, namespace, name,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}

+ 54 - 2
cli/cmd/deploy.go

@@ -12,6 +12,7 @@ import (
 	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/porter-dev/porter/cli/cmd/deploy"
 	"github.com/porter-dev/porter/cli/cmd/deploy"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/porter-dev/porter/cli/cmd/utils"
+	templaterUtils "github.com/porter-dev/porter/internal/templater/utils"
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
 	"k8s.io/client-go/util/homedir"
 	"k8s.io/client-go/util/homedir"
 )
 )
@@ -315,6 +316,10 @@ func init() {
 		"set this to force push an image (images tagged with \"latest\" have this set by default)",
 		"set this to force push an image (images tagged with \"latest\" have this set by default)",
 	)
 	)
 
 
+	updateCmd.PersistentFlags().MarkDeprecated("force-build", "--force-build is now deprecated")
+
+	updateCmd.PersistentFlags().MarkDeprecated("force-push", "--force-push is now deprecated")
+
 	updateCmd.AddCommand(updateGetEnvCmd)
 	updateCmd.AddCommand(updateGetEnvCmd)
 
 
 	updateGetEnvCmd.PersistentFlags().StringVar(
 	updateGetEnvCmd.PersistentFlags().StringVar(
@@ -530,7 +535,7 @@ func updateBuildWithAgent(updateAgent *deploy.DeployAgent) error {
 		return err
 		return err
 	}
 	}
 
 
-	if err := updateAgent.Build(nil, forceBuild); err != nil {
+	if err := updateAgent.Build(nil); err != nil {
 		if stream {
 		if stream {
 			updateAgent.StreamEvent(types.SubEvent{
 			updateAgent.StreamEvent(types.SubEvent{
 				EventID: "build",
 				EventID: "build",
@@ -576,7 +581,7 @@ func updatePushWithAgent(updateAgent *deploy.DeployAgent) error {
 		})
 		})
 	}
 	}
 
 
-	if err := updateAgent.Push(forcePush); err != nil {
+	if err := updateAgent.Push(); err != nil {
 		if stream {
 		if stream {
 			updateAgent.StreamEvent(types.SubEvent{
 			updateAgent.StreamEvent(types.SubEvent{
 				EventID: "push",
 				EventID: "push",
@@ -637,6 +642,53 @@ func updateUpgradeWithAgent(updateAgent *deploy.DeployAgent) error {
 		return err
 		return err
 	}
 	}
 
 
+	if len(updateAgent.Opts.AdditionalEnv) > 0 {
+		syncedEnv, err := deploy.GetSyncedEnv(
+			updateAgent.Client,
+			updateAgent.Release.Config,
+			updateAgent.Opts.ProjectID,
+			updateAgent.Opts.ClusterID,
+			updateAgent.Opts.Namespace,
+			false,
+		)
+
+		if err != nil {
+			return err
+		}
+
+		for k := range updateAgent.Opts.AdditionalEnv {
+			if _, ok := syncedEnv[k]; ok {
+				return fmt.Errorf("environment variable %s already exists as part of a synced environment group", k)
+			}
+		}
+
+		normalEnv, err := deploy.GetNormalEnv(
+			updateAgent.Client,
+			updateAgent.Release.Config,
+			updateAgent.Opts.ProjectID,
+			updateAgent.Opts.ClusterID,
+			updateAgent.Opts.Namespace,
+			false,
+		)
+
+		if err != nil {
+			return err
+		}
+
+		// add the additional environment variables to container.env.normal
+		for k, v := range updateAgent.Opts.AdditionalEnv {
+			normalEnv[k] = v
+		}
+
+		valuesObj = templaterUtils.CoalesceValues(valuesObj, map[string]interface{}{
+			"container": map[string]interface{}{
+				"env": map[string]interface{}{
+					"normal": normalEnv,
+				},
+			},
+		})
+	}
+
 	err = updateAgent.UpdateImageAndValues(valuesObj)
 	err = updateAgent.UpdateImageAndValues(valuesObj)
 
 
 	if err != nil {
 	if err != nil {

+ 97 - 69
cli/cmd/deploy/create.go

@@ -219,7 +219,6 @@ func (c *CreateAgent) CreateFromDocker(
 	overrideValues map[string]interface{},
 	overrideValues map[string]interface{},
 	imageTag string,
 	imageTag string,
 	extraBuildConfig *types.BuildConfig,
 	extraBuildConfig *types.BuildConfig,
-	forceBuild bool,
 ) (string, error) {
 ) (string, error) {
 	opts := c.CreateOpts
 	opts := c.CreateOpts
 
 
@@ -273,66 +272,78 @@ func (c *CreateAgent) CreateFromDocker(
 		return "", err
 		return "", err
 	}
 	}
 
 
-	imageExists := agent.CheckIfImageExists(imageURL, imageTag)
+	env, err := GetEnvForRelease(c.Client, mergedValues, opts.ProjectID, opts.ClusterID, opts.Namespace)
 
 
-	if imageExists && imageTag != "latest" && !forceBuild {
-		fmt.Printf("%s:%s already exists in the registry, so skipping build\n", imageURL, imageTag)
-	} else { // image does not exist or has tag "latest" so we (re)build one
-		env, err := GetEnvForRelease(c.Client, mergedValues, opts.ProjectID, opts.ClusterID, opts.Namespace)
+	if err != nil {
+		env = make(map[string]string)
+	}
 
 
-		if err != nil {
-			env = map[string]string{}
-		}
+	envConfig, err := GetNestedMap(mergedValues, "container", "env")
 
 
-		// add additional env based on options
-		for key, val := range opts.SharedOpts.AdditionalEnv {
-			env[key] = val
-		}
+	if err == nil {
+		_, exists := envConfig["build"]
+
+		if exists {
+			buildEnv, err := GetNestedMap(mergedValues, "container", "env", "build")
 
 
-		buildAgent := &BuildAgent{
-			SharedOpts:  opts.SharedOpts,
-			APIClient:   c.Client,
-			ImageRepo:   imageURL,
-			Env:         env,
-			ImageExists: false,
+			if err == nil {
+				for key, val := range buildEnv {
+					if valStr, ok := val.(string); ok {
+						env[key] = valStr
+					}
+				}
+			}
 		}
 		}
+	}
 
 
-		if opts.Method == DeployBuildTypeDocker {
-			basePath, err := filepath.Abs(".")
+	// add additional env based on options
+	for key, val := range opts.SharedOpts.AdditionalEnv {
+		env[key] = val
+	}
 
 
-			if err != nil {
-				return "", err
-			}
+	buildAgent := &BuildAgent{
+		SharedOpts:  opts.SharedOpts,
+		APIClient:   c.Client,
+		ImageRepo:   imageURL,
+		Env:         env,
+		ImageExists: false,
+	}
 
 
-			err = buildAgent.BuildDocker(agent, basePath, opts.LocalPath, opts.LocalDockerfile, imageTag, "")
-		} else {
-			err = buildAgent.BuildPack(agent, opts.LocalPath, imageTag, "", extraBuildConfig)
-		}
+	if opts.Method == DeployBuildTypeDocker {
+		basePath, err := filepath.Abs(".")
 
 
 		if err != nil {
 		if err != nil {
 			return "", err
 			return "", err
 		}
 		}
 
 
-		if !opts.SharedOpts.UseCache {
-			// create repository
-			err = c.Client.CreateRepository(
-				context.Background(),
-				opts.ProjectID,
-				regID,
-				&types.CreateRegistryRepositoryRequest{
-					ImageRepoURI: imageURL,
-				},
-			)
-
-			if err != nil {
-				return "", err
-			}
+		err = buildAgent.BuildDocker(agent, basePath, opts.LocalPath, opts.LocalDockerfile, imageTag, "")
+	} else {
+		err = buildAgent.BuildPack(agent, opts.LocalPath, imageTag, "", extraBuildConfig)
+	}
 
 
-			err = agent.PushImage(fmt.Sprintf("%s:%s", imageURL, imageTag))
+	if err != nil {
+		return "", err
+	}
 
 
-			if err != nil {
-				return "", err
-			}
+	if !opts.SharedOpts.UseCache {
+		// create repository
+		err = c.Client.CreateRepository(
+			context.Background(),
+			opts.ProjectID,
+			regID,
+			&types.CreateRegistryRepositoryRequest{
+				ImageRepoURI: imageURL,
+			},
+		)
+
+		if err != nil {
+			return "", err
+		}
+
+		err = agent.PushImage(fmt.Sprintf("%s:%s", imageURL, imageTag))
+
+		if err != nil {
+			return "", err
 		}
 		}
 	}
 	}
 
 
@@ -506,7 +517,7 @@ func (c *CreateAgent) CreateSubdomainIfRequired(mergedValues map[string]interfac
 	// check for automatic subdomain creation if web kind
 	// check for automatic subdomain creation if web kind
 	if c.CreateOpts.Kind == "web" {
 	if c.CreateOpts.Kind == "web" {
 		// look for ingress.enabled and no custom domains set
 		// look for ingress.enabled and no custom domains set
-		ingressMap, err := getNestedMap(mergedValues, "ingress")
+		ingressMap, err := GetNestedMap(mergedValues, "ingress")
 
 
 		if err == nil {
 		if err == nil {
 			enabledVal, enabledExists := ingressMap["enabled"]
 			enabledVal, enabledExists := ingressMap["enabled"]
@@ -517,33 +528,50 @@ func (c *CreateAgent) CreateSubdomainIfRequired(mergedValues map[string]interfac
 				enabled, eOK := enabledVal.(bool)
 				enabled, eOK := enabledVal.(bool)
 				customDomain, cOK := customDomVal.(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,
-						c.CreateOpts.Namespace,
-						c.CreateOpts.ReleaseName,
-					)
-
-					if err != nil {
-						return "", fmt.Errorf("Error creating subdomain: %s", err.Error())
-					}
+				if eOK && cOK && enabled {
+					if customDomain {
+						// return the first custom domain when one exists
+						hostsArr, hostsExists := ingressMap["hosts"]
 
 
-					subdomain = dnsRecord.ExternalURL
+						if hostsExists {
+							hostsArrVal, hostsArrOk := hostsArr.([]interface{})
 
 
-					if ingressVal, ok := mergedValues["ingress"]; !ok {
-						mergedValues["ingress"] = map[string]interface{}{
-							"porter_hosts": []string{
-								subdomain,
-							},
+							if hostsArrOk && len(hostsArrVal) > 0 {
+								subdomainStr, ok := hostsArrVal[0].(string)
+
+								if ok {
+									subdomain = subdomainStr
+								}
+							}
 						}
 						}
 					} else {
 					} else {
-						ingressValMap := ingressVal.(map[string]interface{})
+						// in the case of ingress enabled but no custom domain, create subdomain
+						dnsRecord, err := c.Client.CreateDNSRecord(
+							context.Background(),
+							c.CreateOpts.ProjectID,
+							c.CreateOpts.ClusterID,
+							c.CreateOpts.Namespace,
+							c.CreateOpts.ReleaseName,
+						)
+
+						if err != nil {
+							return "", fmt.Errorf("Error creating subdomain: %s", err.Error())
+						}
+
+						subdomain = dnsRecord.ExternalURL
 
 
-						ingressValMap["porter_hosts"] = []string{
-							subdomain,
+						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,
+							}
 						}
 						}
 					}
 					}
 				}
 				}

+ 133 - 68
cli/cmd/deploy/deploy.go

@@ -33,9 +33,9 @@ type DeployAgent struct {
 	App string
 	App string
 
 
 	Client         *client.Client
 	Client         *client.Client
-	release        *types.GetReleaseResponse
+	Opts           *DeployOpts
+	Release        *types.GetReleaseResponse
 	agent          *docker.Agent
 	agent          *docker.Agent
-	opts           *DeployOpts
 	tag            string
 	tag            string
 	envPrefix      string
 	envPrefix      string
 	env            map[string]string
 	env            map[string]string
@@ -56,7 +56,7 @@ type DeployOpts struct {
 func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*DeployAgent, error) {
 func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*DeployAgent, error) {
 	deployAgent := &DeployAgent{
 	deployAgent := &DeployAgent{
 		App:    app,
 		App:    app,
-		opts:   opts,
+		Opts:   opts,
 		Client: client,
 		Client: client,
 		env:    make(map[string]string),
 		env:    make(map[string]string),
 	}
 	}
@@ -68,7 +68,7 @@ func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*Deplo
 		return nil, err
 		return nil, err
 	}
 	}
 
 
-	deployAgent.release = release
+	deployAgent.Release = release
 
 
 	// set an environment prefix to avoid collisions
 	// set an environment prefix to avoid collisions
 	deployAgent.envPrefix = fmt.Sprintf("PORTER_%s", strings.Replace(
 	deployAgent.envPrefix = fmt.Sprintf("PORTER_%s", strings.Replace(
@@ -90,27 +90,27 @@ func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*Deplo
 			// 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 {
 			} else {
 				// otherwise build type is pack
 				// otherwise build type is pack
-				deployAgent.opts.Method = DeployBuildTypePack
+				deployAgent.Opts.Method = DeployBuildTypePack
 			}
 			}
 		} else {
 		} else {
 			// if the git action config does not exist, we use docker by default
 			// if the git action config does not exist, we use docker by default
-			deployAgent.opts.Method = DeployBuildTypeDocker
+			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
 		}
 		}
 
 
-		if deployAgent.opts.LocalDockerfile != "" {
-			deployAgent.dockerfilePath = deployAgent.opts.LocalDockerfile
+		if deployAgent.Opts.LocalDockerfile != "" {
+			deployAgent.dockerfilePath = deployAgent.Opts.LocalDockerfile
 		}
 		}
 
 
-		if deployAgent.dockerfilePath == "" && deployAgent.opts.LocalDockerfile == "" {
+		if deployAgent.dockerfilePath == "" && deployAgent.Opts.LocalDockerfile == "" {
 			deployAgent.dockerfilePath = "./Dockerfile"
 			deployAgent.dockerfilePath = "./Dockerfile"
 		}
 		}
 	}
 	}
@@ -119,7 +119,7 @@ func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*Deplo
 	// will fail. we set the image based on the git action config or the image written in the
 	// will fail. we set the image based on the git action config or the image written in the
 	// helm values
 	// helm values
 	if release.GitActionConfig == nil {
 	if release.GitActionConfig == nil {
-		deployAgent.opts.Local = true
+		deployAgent.Opts.Local = true
 
 
 		imageRepo, err := deployAgent.getReleaseImage()
 		imageRepo, err := deployAgent.getReleaseImage()
 
 
@@ -129,16 +129,16 @@ func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*Deplo
 
 
 		deployAgent.imageRepo = imageRepo
 		deployAgent.imageRepo = imageRepo
 
 
-		deployAgent.dockerfilePath = deployAgent.opts.LocalDockerfile
+		deployAgent.dockerfilePath = deployAgent.Opts.LocalDockerfile
 	} else {
 	} else {
 		deployAgent.imageRepo = release.GitActionConfig.ImageRepoURI
 		deployAgent.imageRepo = release.GitActionConfig.ImageRepoURI
-		deployAgent.opts.LocalPath = release.GitActionConfig.FolderPath
+		deployAgent.Opts.LocalPath = release.GitActionConfig.FolderPath
 	}
 	}
 
 
 	deployAgent.tag = opts.OverrideTag
 	deployAgent.tag = opts.OverrideTag
 
 
-	err = coalesceEnvGroups(deployAgent.Client, deployAgent.opts.ProjectID, deployAgent.opts.ClusterID,
-		deployAgent.opts.Namespace, deployAgent.opts.EnvGroups, deployAgent.release.Config)
+	err = coalesceEnvGroups(deployAgent.Client, deployAgent.Opts.ProjectID, deployAgent.Opts.ClusterID,
+		deployAgent.Opts.Namespace, deployAgent.Opts.EnvGroups, deployAgent.Release.Config)
 
 
 	deployAgent.imageExists = deployAgent.agent.CheckIfImageExists(deployAgent.imageRepo, deployAgent.tag)
 	deployAgent.imageExists = deployAgent.agent.CheckIfImageExists(deployAgent.imageRepo, deployAgent.tag)
 
 
@@ -150,24 +150,48 @@ type GetBuildEnvOpts struct {
 	NewConfig    map[string]interface{}
 	NewConfig    map[string]interface{}
 }
 }
 
 
-// GetBuildEnv retrieves the build env from the release config and returns it
+// GetBuildEnv retrieves the build env from the release config and returns it.
+//
+// It returns a flattened map of all environment variables including:
+//    1. container.env.normal from the release config
+//    2. container.env.build from the release config
+//    3. container.env.synced from the release config
+//    4. any additional env var that was passed into the DeployAgent as opts.SharedOpts.AdditionalEnv
 func (d *DeployAgent) GetBuildEnv(opts *GetBuildEnvOpts) (map[string]string, error) {
 func (d *DeployAgent) GetBuildEnv(opts *GetBuildEnvOpts) (map[string]string, error) {
-	conf := d.release.Config
+	conf := d.Release.Config
 
 
 	if opts.UseNewConfig {
 	if opts.UseNewConfig {
 		if opts.NewConfig != nil {
 		if opts.NewConfig != nil {
-			conf = utils.CoalesceValues(d.release.Config, opts.NewConfig)
+			conf = utils.CoalesceValues(d.Release.Config, opts.NewConfig)
 		}
 		}
 	}
 	}
 
 
-	env, err := GetEnvForRelease(d.Client, conf, d.opts.ProjectID, d.opts.ClusterID, d.opts.Namespace)
+	env, err := GetEnvForRelease(d.Client, conf, d.Opts.ProjectID, d.Opts.ClusterID, d.Opts.Namespace)
 
 
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
+	envConfig, err := GetNestedMap(conf, "container", "env")
+
+	if err == nil {
+		_, exists := envConfig["build"]
+
+		if exists {
+			buildEnv, err := GetNestedMap(conf, "container", "env", "build")
+
+			if err == nil {
+				for key, val := range buildEnv {
+					if valStr, ok := val.(string); ok {
+						env[key] = valStr
+					}
+				}
+			}
+		}
+	}
+
 	// add additional env based on options
 	// add additional env based on options
-	for key, val := range d.opts.SharedOpts.AdditionalEnv {
+	for key, val := range d.Opts.SharedOpts.AdditionalEnv {
 		env[key] = val
 		env[key] = val
 	}
 	}
 
 
@@ -221,30 +245,23 @@ func (d *DeployAgent) WriteBuildEnv(fileDest string) error {
 
 
 // Build uses the deploy agent options to build a new container image from either
 // Build uses the deploy agent options to build a new container image from either
 // buildpack or docker.
 // buildpack or docker.
-func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig, forceBuild bool) error {
+func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig) error {
 	// retrieve current image to use for cache
 	// retrieve current image to use for cache
-	currImageSection := d.release.Config["image"].(map[string]interface{})
+	currImageSection := d.Release.Config["image"].(map[string]interface{})
 	currentTag := currImageSection["tag"].(string)
 	currentTag := currImageSection["tag"].(string)
 
 
 	if d.tag == "" {
 	if d.tag == "" {
 		d.tag = currentTag
 		d.tag = currentTag
 	}
 	}
 
 
-	// we do not want to re-build an image
-	// FIXME: what if overrideBuildConfig == nil but the image stays the same?
-	if overrideBuildConfig == nil && d.imageExists && d.tag != "latest" && !forceBuild {
-		fmt.Printf("%s:%s already exists in the registry, so skipping build\n", d.imageRepo, d.tag)
-		return nil
-	}
-
 	// if build is not local, fetch remote source
 	// if build is not local, fetch remote source
 	var basePath string
 	var basePath string
 	var err error
 	var err error
 
 
-	buildCtx := d.opts.LocalPath
+	buildCtx := d.Opts.LocalPath
 
 
-	if !d.opts.Local {
-		repoSplit := strings.Split(d.release.GitActionConfig.GitRepo, "/")
+	if !d.Opts.Local {
+		repoSplit := strings.Split(d.Release.GitActionConfig.GitRepo, "/")
 
 
 		if len(repoSplit) != 2 {
 		if len(repoSplit) != 2 {
 			return fmt.Errorf("invalid formatting of repo name")
 			return fmt.Errorf("invalid formatting of repo name")
@@ -252,12 +269,12 @@ func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig, forceBuild b
 
 
 		zipResp, err := d.Client.GetRepoZIPDownloadURL(
 		zipResp, err := d.Client.GetRepoZIPDownloadURL(
 			context.Background(),
 			context.Background(),
-			d.opts.ProjectID,
-			int64(d.release.GitActionConfig.GitRepoID),
+			d.Opts.ProjectID,
+			int64(d.Release.GitActionConfig.GitRepoID),
 			"github",
 			"github",
 			repoSplit[0],
 			repoSplit[0],
 			repoSplit[1],
 			repoSplit[1],
-			d.release.GitActionConfig.GitBranch,
+			d.Release.GitActionConfig.GitBranch,
 		)
 		)
 
 
 		if err != nil {
 		if err != nil {
@@ -291,14 +308,14 @@ func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig, forceBuild b
 	}
 	}
 
 
 	buildAgent := &BuildAgent{
 	buildAgent := &BuildAgent{
-		SharedOpts:  d.opts.SharedOpts,
+		SharedOpts:  d.Opts.SharedOpts,
 		APIClient:   d.Client,
 		APIClient:   d.Client,
 		ImageRepo:   d.imageRepo,
 		ImageRepo:   d.imageRepo,
 		Env:         d.env,
 		Env:         d.env,
 		ImageExists: d.imageExists,
 		ImageExists: d.imageExists,
 	}
 	}
 
 
-	if d.opts.Method == DeployBuildTypeDocker {
+	if d.Opts.Method == DeployBuildTypeDocker {
 		return buildAgent.BuildDocker(
 		return buildAgent.BuildDocker(
 			d.agent,
 			d.agent,
 			basePath,
 			basePath,
@@ -309,7 +326,7 @@ func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig, forceBuild b
 		)
 		)
 	}
 	}
 
 
-	buildConfig := d.release.BuildConfig
+	buildConfig := d.Release.BuildConfig
 
 
 	if overrideBuildConfig != nil {
 	if overrideBuildConfig != nil {
 		buildConfig = overrideBuildConfig
 		buildConfig = overrideBuildConfig
@@ -319,12 +336,7 @@ func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig, forceBuild b
 }
 }
 
 
 // Push pushes a local image to the remote repository linked in the release
 // Push pushes a local image to the remote repository linked in the release
-func (d *DeployAgent) Push(forcePush bool) error {
-	if d.imageExists && !forcePush && d.tag != "latest" {
-		fmt.Printf("%s:%s has been pushed already, so skipping push\n", d.imageRepo, d.tag)
-		return nil
-	}
-
+func (d *DeployAgent) Push() error {
 	return d.agent.PushImage(fmt.Sprintf("%s:%s", d.imageRepo, d.tag))
 	return d.agent.PushImage(fmt.Sprintf("%s:%s", d.imageRepo, d.tag))
 }
 }
 
 
@@ -335,11 +347,11 @@ func (d *DeployAgent) Push(forcePush bool) error {
 func (d *DeployAgent) UpdateImageAndValues(overrideValues map[string]interface{}) error {
 func (d *DeployAgent) UpdateImageAndValues(overrideValues map[string]interface{}) error {
 	// if this is a job chart, set "paused" to false so that the job doesn't run, unless
 	// if this is a job chart, set "paused" to false so that the job doesn't run, unless
 	// the user has explicitly overriden the "paused" field
 	// the user has explicitly overriden the "paused" field
-	if _, exists := overrideValues["paused"]; d.release.Chart.Name() == "job" && !exists {
+	if _, exists := overrideValues["paused"]; d.Release.Chart.Name() == "job" && !exists {
 		overrideValues["paused"] = true
 		overrideValues["paused"] = true
 	}
 	}
 
 
-	mergedValues := utils.CoalesceValues(d.release.Config, overrideValues)
+	mergedValues := utils.CoalesceValues(d.Release.Config, overrideValues)
 
 
 	activeBlueGreenTagVal := GetCurrActiveBlueGreenImage(mergedValues)
 	activeBlueGreenTagVal := GetCurrActiveBlueGreenImage(mergedValues)
 
 
@@ -385,10 +397,10 @@ func (d *DeployAgent) UpdateImageAndValues(overrideValues map[string]interface{}
 
 
 	return d.Client.UpgradeRelease(
 	return d.Client.UpgradeRelease(
 		context.Background(),
 		context.Background(),
-		d.opts.ProjectID,
-		d.opts.ClusterID,
-		d.release.Namespace,
-		d.release.Name,
+		d.Opts.ProjectID,
+		d.Opts.ClusterID,
+		d.Release.Namespace,
+		d.Release.Name,
 		&types.UpgradeReleaseRequest{
 		&types.UpgradeReleaseRequest{
 			Values: string(bytes),
 			Values: string(bytes),
 		},
 		},
@@ -407,12 +419,51 @@ type SyncedEnvSectionKey struct {
 }
 }
 
 
 // GetEnvForRelease gets the env vars for a standard Porter template config. These env
 // GetEnvForRelease gets the env vars for a standard Porter template config. These env
-// vars are found at `container.env.normal`.
-func GetEnvForRelease(client *client.Client, config map[string]interface{}, projID, clusterID uint, namespace string) (map[string]string, error) {
+// vars are found at `container.env.normal` and `container.env.synced`.
+func GetEnvForRelease(
+	client *client.Client,
+	config map[string]interface{},
+	projID, clusterID uint,
+	namespace string,
+) (map[string]string, error) {
 	res := make(map[string]string)
 	res := make(map[string]string)
 
 
 	// first, get the env vars from "container.env.normal"
 	// first, get the env vars from "container.env.normal"
-	envConfig, err := getNestedMap(config, "container", "env", "normal")
+	normalEnv, err := GetNormalEnv(client, config, projID, clusterID, namespace, true)
+
+	if err != nil {
+		return nil, fmt.Errorf("error while fetching container.env.normal variables: %w", err)
+	}
+
+	for k, v := range normalEnv {
+		res[k] = v
+	}
+
+	// next, get the env vars specified by "container.env.synced"
+	// look for container.env.synced
+	syncedEnv, err := GetSyncedEnv(client, config, projID, clusterID, namespace, true)
+
+	if err != nil {
+		return nil, fmt.Errorf("error while fetching container.env.synced variables: %w", err)
+	}
+
+	for k, v := range syncedEnv {
+		res[k] = v
+	}
+
+	return res, nil
+}
+
+func GetNormalEnv(
+	client *client.Client,
+	config map[string]interface{},
+	projID, clusterID uint,
+	namespace string,
+	buildTime bool,
+) (map[string]string, error) {
+	res := make(map[string]string)
+
+	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 err != nil {
 	if err != nil {
@@ -428,14 +479,26 @@ func GetEnvForRelease(client *client.Client, config map[string]interface{}, proj
 
 
 		// if the value contains PORTERSECRET, this is a "dummy" env that gets injected during
 		// if the value contains PORTERSECRET, this is a "dummy" env that gets injected during
 		// run-time, so we ignore it
 		// run-time, so we ignore it
-		if !strings.Contains(valStr, "PORTERSECRET") {
+		if buildTime && strings.Contains(valStr, "PORTERSECRET") {
+			continue
+		} else {
 			res[key] = valStr
 			res[key] = valStr
 		}
 		}
 	}
 	}
 
 
-	// next, get the env vars specified by "container.env.synced"
-	// look for container.env.synced
-	envConf, err := getNestedMap(config, "container", "env")
+	return res, nil
+}
+
+func GetSyncedEnv(
+	client *client.Client,
+	config map[string]interface{},
+	projID, clusterID uint,
+	namespace string,
+	buildTime bool,
+) (map[string]string, error) {
+	res := make(map[string]string)
+
+	envConf, err := GetNestedMap(config, "container", "env")
 
 
 	// if error, just return the env detected from above
 	// if error, just return the env detected from above
 	if err != nil {
 	if err != nil {
@@ -542,7 +605,9 @@ func GetEnvForRelease(client *client.Client, config map[string]interface{}, proj
 			}
 			}
 
 
 			for key, val := range eg.Variables {
 			for key, val := range eg.Variables {
-				if !strings.Contains(val, "PORTERSECRET") {
+				if buildTime && strings.Contains(val, "PORTERSECRET") {
+					continue
+				} else {
 					res[key] = val
 					res[key] = val
 				}
 				}
 			}
 			}
@@ -553,12 +618,12 @@ func GetEnvForRelease(client *client.Client, config map[string]interface{}, proj
 }
 }
 
 
 func (d *DeployAgent) getReleaseImage() (string, error) {
 func (d *DeployAgent) getReleaseImage() (string, error) {
-	if d.release.ImageRepoURI != "" {
-		return d.release.ImageRepoURI, nil
+	if d.Release.ImageRepoURI != "" {
+		return d.Release.ImageRepoURI, nil
 	}
 	}
 
 
 	// get the image from the conig
 	// 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 {
 		return "", fmt.Errorf("could not get image config from release: %s", err.Error())
 		return "", fmt.Errorf("could not get image config from release: %s", err.Error())
@@ -581,7 +646,7 @@ func (d *DeployAgent) getReleaseImage() (string, error) {
 
 
 func (d *DeployAgent) pullCurrentReleaseImage() (string, error) {
 func (d *DeployAgent) pullCurrentReleaseImage() (string, error) {
 	// pull the currently deployed image to use cache, if possible
 	// pull the currently deployed image to use cache, if possible
-	imageConfig, err := getNestedMap(d.release.Config, "image")
+	imageConfig, err := GetNestedMap(d.Release.Config, "image")
 
 
 	if err != nil {
 	if err != nil {
 		return "", fmt.Errorf("could not get image config from release: %s", err.Error())
 		return "", fmt.Errorf("could not get image config from release: %s", err.Error())
@@ -616,7 +681,7 @@ func (d *DeployAgent) downloadRepoToDir(downloadURL string) (string, error) {
 	downloader := &github.ZIPDownloader{
 	downloader := &github.ZIPDownloader{
 		ZipFolderDest:       dstDir,
 		ZipFolderDest:       dstDir,
 		AssetFolderDest:     dstDir,
 		AssetFolderDest:     dstDir,
-		ZipName:             fmt.Sprintf("%s.zip", strings.Replace(d.release.GitActionConfig.GitRepo, "/", "-", 1)),
+		ZipName:             fmt.Sprintf("%s.zip", strings.Replace(d.Release.GitActionConfig.GitRepo, "/", "-", 1)),
 		RemoveAfterDownload: true,
 		RemoveAfterDownload: true,
 	}
 	}
 
 
@@ -637,7 +702,7 @@ func (d *DeployAgent) downloadRepoToDir(downloadURL string) (string, error) {
 	dstFiles, err := ioutil.ReadDir(dstDir)
 	dstFiles, err := ioutil.ReadDir(dstDir)
 
 
 	for _, info := range dstFiles {
 	for _, info := range dstFiles {
-		if info.Mode().IsDir() && strings.Contains(info.Name(), strings.Replace(d.release.GitActionConfig.GitRepo, "/", "-", 1)) {
+		if info.Mode().IsDir() && strings.Contains(info.Name(), strings.Replace(d.Release.GitActionConfig.GitRepo, "/", "-", 1)) {
 			res = filepath.Join(dstDir, info.Name())
 			res = filepath.Join(dstDir, info.Name())
 		}
 		}
 	}
 	}
@@ -652,8 +717,8 @@ func (d *DeployAgent) downloadRepoToDir(downloadURL string) (string, error) {
 func (d *DeployAgent) StreamEvent(event types.SubEvent) error {
 func (d *DeployAgent) StreamEvent(event types.SubEvent) error {
 	return d.Client.CreateEvent(
 	return d.Client.CreateEvent(
 		context.Background(),
 		context.Background(),
-		d.opts.ProjectID, d.opts.ClusterID,
-		d.release.Namespace, d.release.Name,
+		d.Opts.ProjectID, d.Opts.ClusterID,
+		d.Release.Namespace, d.Release.Name,
 		&types.UpdateReleaseStepsRequest{
 		&types.UpdateReleaseStepsRequest{
 			Event: event,
 			Event: event,
 		},
 		},
@@ -668,7 +733,7 @@ func (e *NestedMapFieldNotFoundError) Error() string {
 	return fmt.Sprintf("could not find field %s in configuration", e.Field)
 	return fmt.Sprintf("could not find field %s in configuration", e.Field)
 }
 }
 
 
-func getNestedMap(obj map[string]interface{}, fields ...string) (map[string]interface{}, error) {
+func GetNestedMap(obj map[string]interface{}, fields ...string) (map[string]interface{}, error) {
 	var res map[string]interface{}
 	var res map[string]interface{}
 	curr := obj
 	curr := obj
 
 

+ 1 - 1
cli/cmd/deploy/shared.go

@@ -49,7 +49,7 @@ func coalesceEnvGroups(
 			return err
 			return err
 		}
 		}
 
 
-		envConfig, err := getNestedMap(config, "container", "env", "normal")
+		envConfig, err := GetNestedMap(config, "container", "env", "normal")
 
 
 		if err != nil || envConfig == nil {
 		if err != nil || envConfig == nil {
 			envConfig = make(map[string]interface{})
 			envConfig = make(map[string]interface{})

+ 73 - 34
cli/cmd/list.go

@@ -6,18 +6,27 @@ import (
 	"os"
 	"os"
 	"text/tabwriter"
 	"text/tabwriter"
 
 
+	"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/api/types"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
-	"helm.sh/helm/v3/pkg/release"
 )
 )
 
 
-// listCmd represents the "porter list" base command when called
-// without any subcommands
+// listCmd represents the "porter list" base command and "porter list all" subcommand
 var listCmd = &cobra.Command{
 var listCmd = &cobra.Command{
 	Use:   "list",
 	Use:   "list",
-	Args:  cobra.ExactArgs(1),
-	Short: "List applications or jobs.",
+	Short: "List applications, addons or jobs.",
+	Run: func(cmd *cobra.Command, args []string) {
+		if len(args) == 0 || (args[0] == "all") {
+			err := checkLoginAndRun(args, listAll)
+
+			if err != nil {
+				os.Exit(1)
+			}
+		} else {
+			color.New(color.FgRed).Printf("invalid command: %s\n", args[0])
+		}
+	},
 }
 }
 
 
 var listAppsCmd = &cobra.Command{
 var listAppsCmd = &cobra.Command{
@@ -46,6 +55,19 @@ var listJobsCmd = &cobra.Command{
 	},
 	},
 }
 }
 
 
+var listAddonsCmd = &cobra.Command{
+	Use:     "addons",
+	Aliases: []string{"addon"},
+	Short:   "Lists addons in a specific namespace, or across all namespaces",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, listAddons)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
 func init() {
 func init() {
 	listCmd.PersistentFlags().StringVar(
 	listCmd.PersistentFlags().StringVar(
 		&namespace,
 		&namespace,
@@ -56,37 +78,52 @@ func init() {
 
 
 	listCmd.AddCommand(listAppsCmd)
 	listCmd.AddCommand(listAppsCmd)
 	listCmd.AddCommand(listJobsCmd)
 	listCmd.AddCommand(listJobsCmd)
+	listCmd.AddCommand(listAddonsCmd)
 
 
 	rootCmd.AddCommand(listCmd)
 	rootCmd.AddCommand(listCmd)
 }
 }
 
 
-func listApps(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
-	releases, err := client.ListReleases(context.Background(), cliConf.Project, cliConf.Cluster, namespace, &types.ListReleasesRequest{
-		ReleaseListFilter: &types.ReleaseListFilter{
-			Limit: 50,
-			Skip:  0,
-			StatusFilter: []string{
-				"deployed",
-				"uninstalled",
-				"pending",
-				"pending-install",
-				"pending-upgrade",
-				"pending-rollback",
-				"failed",
-			},
-		},
-	})
+func listAll(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	err := writeReleases(client, "all")
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	writeReleases("application", releases)
+	return nil
+}
+
+func listApps(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	err := writeReleases(client, "application")
+
+	if err != nil {
+		return err
+	}
 
 
 	return nil
 	return nil
 }
 }
 
 
 func listJobs(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
 func listJobs(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	err := writeReleases(client, "job")
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func listAddons(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	err := writeReleases(client, "addon")
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func writeReleases(client *api.Client, kind string) error {
 	releases, err := client.ListReleases(context.Background(), cliConf.Project, cliConf.Cluster, namespace, &types.ListReleasesRequest{
 	releases, err := client.ListReleases(context.Background(), cliConf.Project, cliConf.Cluster, namespace, &types.ListReleasesRequest{
 		ReleaseListFilter: &types.ReleaseListFilter{
 		ReleaseListFilter: &types.ReleaseListFilter{
 			Limit: 50,
 			Limit: 50,
@@ -107,24 +144,26 @@ func listJobs(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []
 		return err
 		return err
 	}
 	}
 
 
-	writeReleases("job", releases)
-
-	return nil
-}
-
-func writeReleases(kind string, releases []*release.Release) {
 	w := new(tabwriter.Writer)
 	w := new(tabwriter.Writer)
-	w.Init(os.Stdout, 3, 8, 0, '\t', tabwriter.AlignRight)
+	w.Init(os.Stdout, 3, 8, 2, '\t', tabwriter.AlignRight)
 
 
-	fmt.Fprintf(w, "%s\t%s\t%s\n", "NAME", "NAMESPACE", "STATUS")
+	fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", "NAME", "NAMESPACE", "STATUS", "KIND")
 
 
 	for _, rel := range releases {
 	for _, rel := range releases {
-		if chartName := rel.Chart.Name(); kind == "application" && (chartName == "web" || chartName == "worker") {
-			fmt.Fprintf(w, "%s\t%s\t%s\n", rel.Name, rel.Namespace, rel.Info.Status)
-		} else if chartName := rel.Chart.Name(); kind == "job" && (chartName == "job") {
-			fmt.Fprintf(w, "%s\t%s\t%s\n", rel.Name, rel.Namespace, rel.Info.Status)
+		chartName := rel.Chart.Name()
+
+		if kind == "all" {
+			fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rel.Name, rel.Namespace, rel.Info.Status, chartName)
+		} else if kind == "application" && (chartName == "web" || chartName == "worker") {
+			fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rel.Name, rel.Namespace, rel.Info.Status, chartName)
+		} else if kind == "job" && chartName == "job" {
+			fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rel.Name, rel.Namespace, rel.Info.Status, chartName)
+		} else if kind == "addon" && chartName != "web" && chartName != "worker" && chartName != "job" {
+			fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rel.Name, rel.Namespace, rel.Info.Status, chartName)
 		}
 		}
 	}
 	}
 
 
 	w.Flush()
 	w.Flush()
+
+	return nil
 }
 }

+ 288 - 0
cli/cmd/portforward.go

@@ -0,0 +1,288 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/url"
+	"os"
+	"os/signal"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/briandowns/spinner"
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+	"github.com/spf13/cobra"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"k8s.io/client-go/rest"
+	"k8s.io/client-go/tools/clientcmd"
+	"k8s.io/client-go/tools/portforward"
+	"k8s.io/client-go/transport/spdy"
+	"k8s.io/kubectl/pkg/util"
+)
+
+var address []string
+
+var portForwardCmd = &cobra.Command{
+	Use:   "port-forward [release] [LOCAL_PORT:]REMOTE_PORT [...[LOCAL_PORT_N:]REMOTE_PORT_N]",
+	Short: "Forward one or more local ports to a pod of a release",
+	Args:  cobra.MinimumNArgs(2),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, portForward)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	portForwardCmd.PersistentFlags().StringVar(
+		&namespace,
+		"namespace",
+		"default",
+		"namespace of the release whose pod you want to port-forward to",
+	)
+
+	portForwardCmd.Flags().StringSliceVar(
+		&address,
+		"address",
+		[]string{"localhost"},
+		"Addresses to listen on (comma separated). Only accepts IP addresses or localhost as a value. "+
+			"When localhost is supplied, kubectl will try  to bind on both 127.0.0.1 and ::1 and will fail "+
+			"if neither of these addresses are available to bind.")
+
+	rootCmd.AddCommand(portForwardCmd)
+}
+
+func forwardPorts(
+	method string,
+	url *url.URL,
+	kubeConfig *rest.Config,
+	address, ports []string,
+	stopChan <-chan struct{},
+	readyChan chan struct{},
+) error {
+	transport, upgrader, err := spdy.RoundTripperFor(kubeConfig)
+
+	if err != nil {
+		return err
+	}
+
+	dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, method, url)
+	fw, err := portforward.NewOnAddresses(
+		dialer, address, ports, stopChan, readyChan, os.Stdout, os.Stderr)
+
+	if err != nil {
+		return err
+	}
+
+	return fw.ForwardPorts()
+}
+
+// splitPort splits port string which is in form of [LOCAL PORT]:REMOTE PORT
+// and returns local and remote ports separately
+func splitPort(port string) (local, remote string) {
+	parts := strings.Split(port, ":")
+	if len(parts) == 2 {
+		return parts[0], parts[1]
+	}
+
+	return parts[0], parts[0]
+}
+
+func portForward(user *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	var err error
+	var pod corev1.Pod
+
+	s := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
+	s.Color("cyan")
+	s.Suffix = fmt.Sprintf(" Loading list of pods for %s", args[0])
+	s.Start()
+
+	podsResp, err := client.GetK8sAllPods(context.Background(), cliConf.Project, cliConf.Cluster, namespace, args[0])
+
+	s.Stop()
+
+	if err != nil {
+		return err
+	}
+
+	pods := *podsResp
+
+	if len(pods) > 1 {
+		selectedPod, err := utils.PromptSelect("Select a pod to port-forward", func() []string {
+			var names []string
+
+			for i, pod := range pods {
+				names = append(names, fmt.Sprintf("%d - %s", (i+1), pod.Name))
+			}
+
+			return names
+		}())
+
+		if err != nil {
+			return err
+		}
+
+		podIdxStr := strings.Split(selectedPod, " - ")[0]
+
+		podIdx, err := strconv.Atoi(podIdxStr)
+
+		if err != nil {
+			return err
+		}
+
+		pod = pods[podIdx]
+	} else {
+		pod = pods[0]
+	}
+
+	kubeResp, err := client.GetKubeconfig(context.Background(), cliConf.Project, cliConf.Cluster)
+
+	if err != nil {
+		return err
+	}
+
+	kubeBytes := kubeResp.Kubeconfig
+
+	cmdConf, err := clientcmd.NewClientConfigFromBytes(kubeBytes)
+
+	if err != nil {
+		return err
+	}
+
+	restConf, err := cmdConf.ClientConfig()
+
+	if err != nil {
+		return err
+	}
+
+	restConf.GroupVersion = &schema.GroupVersion{
+		Group:   "api",
+		Version: "v1",
+	}
+
+	restConf.NegotiatedSerializer = runtime.NewSimpleNegotiatedSerializer(runtime.SerializerInfo{})
+
+	restClient, err := rest.RESTClientFor(restConf)
+
+	if err != nil {
+		return err
+	}
+
+	err = checkUDPPortInPod(args[1:], &pod)
+
+	if err != nil {
+		return err
+	}
+
+	ports, err := convertPodNamedPortToNumber(args[1:], pod)
+
+	if err != nil {
+		return err
+	}
+
+	stopChannel := make(chan struct{}, 1)
+	readyChannel := make(chan struct{})
+
+	signals := make(chan os.Signal, 1)
+	signal.Notify(signals, os.Interrupt)
+	defer signal.Stop(signals)
+
+	go func() {
+		<-signals
+		if stopChannel != nil {
+			close(stopChannel)
+		}
+	}()
+
+	req := restClient.Post().
+		Resource("pods").
+		Namespace(namespace).
+		Name(pod.Name).
+		SubResource("portforward")
+
+	return forwardPorts("POST", req.URL(), restConf, address, ports, stopChannel, readyChannel)
+}
+
+func checkUDPPortInPod(ports []string, pod *corev1.Pod) error {
+	udpPorts := sets.NewInt()
+	tcpPorts := sets.NewInt()
+	for _, ct := range pod.Spec.Containers {
+		for _, ctPort := range ct.Ports {
+			portNum := int(ctPort.ContainerPort)
+			switch ctPort.Protocol {
+			case corev1.ProtocolUDP:
+				udpPorts.Insert(portNum)
+			case corev1.ProtocolTCP:
+				tcpPorts.Insert(portNum)
+			}
+		}
+	}
+	return checkUDPPorts(udpPorts.Difference(tcpPorts), ports, pod)
+}
+
+func checkUDPPorts(udpOnlyPorts sets.Int, ports []string, obj metav1.Object) error {
+	for _, port := range ports {
+		_, remotePort := splitPort(port)
+		portNum, err := strconv.Atoi(remotePort)
+		if err != nil {
+			switch v := obj.(type) {
+			case *corev1.Service:
+				svcPort, err := util.LookupServicePortNumberByName(*v, remotePort)
+				if err != nil {
+					return err
+				}
+				portNum = int(svcPort)
+
+			case *corev1.Pod:
+				ctPort, err := util.LookupContainerPortNumberByName(*v, remotePort)
+				if err != nil {
+					return err
+				}
+				portNum = int(ctPort)
+
+			default:
+				return fmt.Errorf("unknown object: %v", obj)
+			}
+		}
+		if udpOnlyPorts.Has(portNum) {
+			return fmt.Errorf("UDP protocol is not supported for %s", remotePort)
+		}
+	}
+	return nil
+}
+
+func convertPodNamedPortToNumber(ports []string, pod corev1.Pod) ([]string, error) {
+	var converted []string
+	for _, port := range ports {
+		localPort, remotePort := splitPort(port)
+
+		containerPortStr := remotePort
+		_, err := strconv.Atoi(remotePort)
+		if err != nil {
+			containerPort, err := util.LookupContainerPortNumberByName(pod, remotePort)
+			if err != nil {
+				return nil, err
+			}
+
+			containerPortStr = strconv.Itoa(int(containerPort))
+		}
+
+		if localPort != remotePort {
+			converted = append(converted, fmt.Sprintf("%s:%s", localPort, containerPortStr))
+		} else {
+			converted = append(converted, containerPortStr)
+		}
+	}
+
+	return converted, nil
+}

+ 70 - 54
cli/cmd/preview/build_image_driver.go

@@ -20,7 +20,6 @@ import (
 
 
 type BuildDriverConfig struct {
 type BuildDriverConfig struct {
 	Build struct {
 	Build struct {
-		ForceBuild   bool `mapstructure:"force_build"`
 		UsePackCache bool `mapstructure:"use_pack_cache"`
 		UsePackCache bool `mapstructure:"use_pack_cache"`
 		Method       string
 		Method       string
 		Context      string
 		Context      string
@@ -28,6 +27,7 @@ type BuildDriverConfig struct {
 		Builder      string
 		Builder      string
 		Buildpacks   []string
 		Buildpacks   []string
 		Image        string
 		Image        string
+		Env          map[string]string
 	}
 	}
 
 
 	EnvGroups []types.EnvGroupMeta `mapstructure:"env_groups"`
 	EnvGroups []types.EnvGroupMeta `mapstructure:"env_groups"`
@@ -231,74 +231,90 @@ func (d *BuildDriver) Apply(resource *models.Resource) (*models.Resource, error)
 		return nil, err
 		return nil, err
 	}
 	}
 
 
-	imageExists := agent.CheckIfImageExists(imageURL, tag) // FIXME: does not seem to work with gcr.io images
+	_, mergedValues, err := createAgent.GetMergedValues(d.config.Values)
 
 
-	if imageExists && tag != "latest" && !d.config.Build.ForceBuild {
-		fmt.Printf("%s:%s already exists in the registry, so skipping build\n", imageURL, tag)
-	} else {
-		_, mergedValues, err := createAgent.GetMergedValues(d.config.Values)
+	if err != nil {
+		return nil, err
+	}
 
 
-		if err != nil {
-			return nil, err
-		}
+	env, err := deploy.GetEnvForRelease(
+		client,
+		mergedValues,
+		d.target.Project,
+		d.target.Cluster,
+		d.target.Namespace,
+	)
 
 
-		env, err := deploy.GetEnvForRelease(
-			client,
-			mergedValues,
-			d.target.Project,
-			d.target.Cluster,
-			d.target.Namespace,
-		)
+	if err != nil {
+		env = make(map[string]string)
+	}
 
 
-		if err != nil {
-			env = map[string]string{}
-		}
+	envConfig, err := deploy.GetNestedMap(mergedValues, "container", "env")
 
 
-		buildAgent := &deploy.BuildAgent{
-			SharedOpts:  createAgent.CreateOpts.SharedOpts,
-			APIClient:   client,
-			ImageRepo:   imageURL,
-			Env:         env,
-			ImageExists: false,
-		}
+	if err == nil {
+		_, exists := envConfig["build"]
 
 
-		if d.config.Build.Method == string(deploy.DeployBuildTypeDocker) {
-			basePath, err := filepath.Abs(".")
+		if exists {
+			buildEnv, err := deploy.GetNestedMap(mergedValues, "container", "env", "build")
 
 
-			if err != nil {
-				return nil, err
+			if err == nil {
+				for key, val := range buildEnv {
+					if valStr, ok := val.(string); ok {
+						env[key] = valStr
+					}
+				}
 			}
 			}
+		}
+	}
 
 
-			err = buildAgent.BuildDocker(
-				agent,
-				basePath,
-				d.config.Build.Context,
-				d.config.Build.Dockerfile,
-				tag,
-				"",
-			)
-		} else {
-			var buildConfig *types.BuildConfig
+	for k, v := range d.config.Build.Env {
+		env[k] = v
+	}
 
 
-			if d.config.Build.Builder != "" {
-				buildConfig = &types.BuildConfig{
-					Builder:    d.config.Build.Builder,
-					Buildpacks: d.config.Build.Buildpacks,
-				}
-			}
+	buildAgent := &deploy.BuildAgent{
+		SharedOpts:  createAgent.CreateOpts.SharedOpts,
+		APIClient:   client,
+		ImageRepo:   imageURL,
+		Env:         env,
+		ImageExists: false,
+	}
 
 
-			err = buildAgent.BuildPack(
-				agent,
-				d.config.Build.Context,
-				tag,
-				"",
-				buildConfig,
-			)
-		}
+	if d.config.Build.Method == string(deploy.DeployBuildTypeDocker) {
+		basePath, err := filepath.Abs(".")
 
 
 		if err != nil {
 		if err != nil {
 			return nil, err
 			return nil, err
 		}
 		}
+
+		err = buildAgent.BuildDocker(
+			agent,
+			basePath,
+			d.config.Build.Context,
+			d.config.Build.Dockerfile,
+			tag,
+			"",
+		)
+	} else {
+		var buildConfig *types.BuildConfig
+
+		if d.config.Build.Builder != "" {
+			buildConfig = &types.BuildConfig{
+				Builder:    d.config.Build.Builder,
+				Buildpacks: d.config.Build.Buildpacks,
+			}
+		}
+
+		err = buildAgent.BuildPack(
+			agent,
+			d.config.Build.Context,
+			tag,
+			"",
+			buildConfig,
+		)
+	}
+
+	if err != nil {
+		return nil, err
 	}
 	}
 
 
 	named, _ := reference.ParseNamed(imageURL)
 	named, _ := reference.ParseNamed(imageURL)

+ 0 - 1
cli/cmd/preview/push_image_driver.go

@@ -12,7 +12,6 @@ import (
 
 
 type PushDriverConfig struct {
 type PushDriverConfig struct {
 	Push struct {
 	Push struct {
-		ForcePush    bool `mapstructure:"force_push"`
 		UsePackCache bool `mapstructure:"use_pack_cache"`
 		UsePackCache bool `mapstructure:"use_pack_cache"`
 		Image        string
 		Image        string
 	}
 	}

+ 17 - 10
cli/cmd/preview/random_string_driver.go

@@ -1,20 +1,19 @@
 package preview
 package preview
 
 
 import (
 import (
-	"math/rand"
-	"time"
+	"crypto/rand"
 
 
 	"github.com/mitchellh/mapstructure"
 	"github.com/mitchellh/mapstructure"
 	"github.com/porter-dev/switchboard/pkg/drivers"
 	"github.com/porter-dev/switchboard/pkg/drivers"
 	"github.com/porter-dev/switchboard/pkg/models"
 	"github.com/porter-dev/switchboard/pkg/models"
 )
 )
 
 
-const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
-
-var seededRand *rand.Rand = rand.New(rand.NewSource(time.Now().UnixNano()))
+const defaultCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+const lowerCharset = "abcdefghijklmnopqrstuvwxyz"
 
 
 type RandomStringDriverConfig struct {
 type RandomStringDriverConfig struct {
-	Length uint
+	Length int
+	Lower  bool
 }
 }
 
 
 type RandomStringDriver struct {
 type RandomStringDriver struct {
@@ -49,7 +48,13 @@ func (d *RandomStringDriver) ShouldApply(resource *models.Resource) bool {
 }
 }
 
 
 func (d *RandomStringDriver) Apply(resource *models.Resource) (*models.Resource, error) {
 func (d *RandomStringDriver) Apply(resource *models.Resource) (*models.Resource, error) {
-	d.output["value"] = randomString(d.config.Length)
+	useCharset := defaultCharset
+
+	if d.config.Lower {
+		useCharset = lowerCharset
+	}
+
+	d.output["value"] = randomString(d.config.Length, useCharset)
 
 
 	return resource, nil
 	return resource, nil
 }
 }
@@ -58,10 +63,12 @@ func (d *RandomStringDriver) Output() (map[string]interface{}, error) {
 	return d.output, nil
 	return d.output, nil
 }
 }
 
 
-func randomString(length uint) string {
+func randomString(length int, charset string) string {
+	ll := len(charset)
 	b := make([]byte, length)
 	b := make([]byte, length)
-	for i := range b {
-		b[i] = charset[seededRand.Intn(len(charset))]
+	rand.Read(b) // generates len(b) random bytes
+	for i := 0; i < length; i++ {
+		b[i] = charset[int(b[i])%ll]
 	}
 	}
 	return string(b)
 	return string(b)
 }
 }

+ 15 - 2
cli/cmd/run.go

@@ -498,8 +498,21 @@ func checkForPodDeletionCronJob(config *PorterRunSharedConfig) error {
 			return err
 			return err
 		}
 		}
 
 
+		if namespace.Name != "default" {
+			for _, cronJob := range cronJobs.Items {
+				if cronJob.Name == "porter-ephemeral-pod-deletion-cronjob" {
+					err = config.Clientset.BatchV1beta1().CronJobs(namespace.Name).Delete(
+						context.Background(), cronJob.Name, metav1.DeleteOptions{},
+					)
+					if err != nil {
+						return err
+					}
+				}
+			}
+		}
+
 		for _, cronJob := range cronJobs.Items {
 		for _, cronJob := range cronJobs.Items {
-			if cronJob.Name == "porter-ephemeral-pod-deletion-cronjob" {
+			if namespace.Name == "default" && cronJob.Name == "porter-ephemeral-pod-deletion-cronjob" {
 				return nil
 				return nil
 			}
 			}
 		}
 		}
@@ -551,7 +564,7 @@ func checkForPodDeletionCronJob(config *PorterRunSharedConfig) error {
 			},
 			},
 		},
 		},
 	}
 	}
-	_, err = config.Clientset.BatchV1beta1().CronJobs(namespace).Create(
+	_, err = config.Clientset.BatchV1beta1().CronJobs("default").Create(
 		context.Background(), cronJob, metav1.CreateOptions{},
 		context.Background(), cronJob, metav1.CreateOptions{},
 	)
 	)
 	if err != nil {
 	if err != nil {

+ 1 - 1
cmd/migrate/main.go

@@ -7,8 +7,8 @@ import (
 	"github.com/porter-dev/porter/cmd/migrate/keyrotate"
 	"github.com/porter-dev/porter/cmd/migrate/keyrotate"
 
 
 	adapter "github.com/porter-dev/porter/internal/adapter"
 	adapter "github.com/porter-dev/porter/internal/adapter"
-	lr "github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 	"github.com/porter-dev/porter/internal/repository/gorm"
+	lr "github.com/porter-dev/porter/pkg/logger"
 
 
 	"github.com/joeshaw/envdecode"
 	"github.com/joeshaw/envdecode"
 )
 )

+ 1 - 1
cmd/ready/main.go

@@ -6,7 +6,7 @@ import (
 	"os"
 	"os"
 
 
 	"github.com/porter-dev/porter/api/server/shared/config/envloader"
 	"github.com/porter-dev/porter/api/server/shared/config/envloader"
-	lr "github.com/porter-dev/porter/internal/logger"
+	lr "github.com/porter-dev/porter/pkg/logger"
 )
 )
 
 
 func main() {
 func main() {

+ 27 - 21
dashboard/src/components/MultiSaveButton.tsx

@@ -1,7 +1,6 @@
-import React, { Component, useState } from "react";
+import React, { useState } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
 import loading from "assets/loading.gif";
 import loading from "assets/loading.gif";
-import MultiSelect from "./porter-form/field-components/MultiSelect";
 import Description from "./Description";
 import Description from "./Description";
 
 
 type MultiSelectOption = {
 type MultiSelectOption = {
@@ -27,12 +26,11 @@ type Props = {
   // Provide the classname to modify styles from other components
   // Provide the classname to modify styles from other components
   className?: string;
   className?: string;
   successText?: string;
   successText?: string;
+  expandTo?: OptionsWrapperProps["expandTo"];
 };
 };
 
 
 const MultiSaveButton: React.FC<Props> = (props) => {
 const MultiSaveButton: React.FC<Props> = (props) => {
-  const [currOption, setCurrOption] = useState<MultiSelectOption>(
-    props.options[0]
-  );
+  const [currOptionIndex, setCurrOptionIndex] = useState<number>(0);
 
 
   const [isDropdownExpanded, setIsDropdownExpanded] = useState(false);
   const [isDropdownExpanded, setIsDropdownExpanded] = useState(false);
 
 
@@ -86,6 +84,7 @@ const MultiSaveButton: React.FC<Props> = (props) => {
         <>
         <>
           <DropdownOverlay onClick={() => setIsDropdownExpanded(false)} />
           <DropdownOverlay onClick={() => setIsDropdownExpanded(false)} />
           <OptionWrapper
           <OptionWrapper
+            expandTo={props.expandTo || "right"}
             dropdownWidth="400px"
             dropdownWidth="400px"
             dropdownMaxHeight="300px"
             dropdownMaxHeight="300px"
             onClick={() => setIsDropdownExpanded(false)}
             onClick={() => setIsDropdownExpanded(false)}
@@ -102,8 +101,8 @@ const MultiSaveButton: React.FC<Props> = (props) => {
       return (
       return (
         <Option
         <Option
           key={i}
           key={i}
-          selected={option.text === currOption.text}
-          onClick={() => setCurrOption(option)}
+          selected={option.text === originalArray[currOptionIndex]?.text}
+          onClick={() => setCurrOptionIndex(i)}
           lastItem={i === originalArray.length - 1}
           lastItem={i === originalArray.length - 1}
         >
         >
           {option.text}
           {option.text}
@@ -126,10 +125,10 @@ const MultiSaveButton: React.FC<Props> = (props) => {
         <Button
         <Button
           rounded={props.rounded}
           rounded={props.rounded}
           disabled={props.disabled}
           disabled={props.disabled}
-          onClick={currOption.onClick}
+          onClick={props.options[currOptionIndex]?.onClick}
           color={props.color || "#5561C0"}
           color={props.color || "#5561C0"}
         >
         >
-          {currOption.text}
+          {props.options[currOptionIndex]?.text}
         </Button>
         </Button>
         <DropdownButton
         <DropdownButton
           disabled={props.disabled}
           disabled={props.disabled}
@@ -165,12 +164,13 @@ const StatusTextWrapper = styled.p`
   margin: 0;
   margin: 0;
 `;
 `;
 
 
-// TODO: prevent status re-render on form refresh to allow animation
-// animation: statusFloatIn 0.5s;
-const StatusWrapper = styled.div<{
+type StatusWrapperProps = {
   successful: boolean;
   successful: boolean;
   position: "right" | "left";
   position: "right" | "left";
-}>`
+};
+// TODO: prevent status re-render on form refresh to allow animation
+// animation: statusFloatIn 0.5s;
+const StatusWrapper = styled.div<StatusWrapperProps>`
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   font-family: "Work Sans", sans-serif;
   font-family: "Work Sans", sans-serif;
@@ -239,11 +239,13 @@ const ButtonWrapper = styled.div`
   }}
   }}
 `;
 `;
 
 
-const Button = styled.button<{
+type ButtonProps = {
   disabled: boolean;
   disabled: boolean;
   color: string;
   color: string;
   rounded: boolean;
   rounded: boolean;
-}>`
+};
+
+const Button = styled.button<ButtonProps>`
   height: 35px;
   height: 35px;
   font-size: 13px;
   font-size: 13px;
   font-weight: 500;
   font-weight: 500;
@@ -321,15 +323,19 @@ const DropdownOverlay = styled.div`
   cursor: default;
   cursor: default;
 `;
 `;
 
 
-const OptionWrapper = styled.div`
+type OptionsWrapperProps = {
+  expandTo: "left" | "right";
+  dropdownWidth: string;
+  dropdownMaxHeight: string;
+};
+
+const OptionWrapper = styled.div<OptionsWrapperProps>`
   position: absolute;
   position: absolute;
-  left: 0;
+  ${(props) => (props.expandTo === "right" ? "left: 0" : "right: 0")};
   top: calc(100% + 10px);
   top: calc(100% + 10px);
   background: #26282f;
   background: #26282f;
-  width: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
-    props.dropdownWidth};
-  max-height: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
-    props.dropdownMaxHeight || "300px"};
+  width: ${(props) => props.dropdownWidth};
+  max-height: ${(props) => props.dropdownMaxHeight || "300px"};
   border-radius: 3px;
   border-radius: 3px;
   z-index: 999;
   z-index: 999;
   overflow-y: auto;
   overflow-y: auto;

+ 9 - 4
dashboard/src/components/form-components/KeyValueArray.tsx

@@ -130,7 +130,9 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
                   let obj = this.valuesToObject();
                   let obj = this.valuesToObject();
                   this.props.setValues(obj);
                   this.props.setValues(obj);
                 }}
                 }}
-                disabled={this.props.disabled || value.includes("PORTERSECRET")}
+                disabled={
+                  this.props.disabled || value?.includes("PORTERSECRET")
+                }
                 spellCheck={false}
                 spellCheck={false}
               />
               />
               <Spacer />
               <Spacer />
@@ -145,12 +147,14 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
                   let obj = this.valuesToObject();
                   let obj = this.valuesToObject();
                   this.props.setValues(obj);
                   this.props.setValues(obj);
                 }}
                 }}
-                disabled={this.props.disabled || value.includes("PORTERSECRET")}
-                type={value.includes("PORTERSECRET") ? "password" : "text"}
+                disabled={
+                  this.props.disabled || value?.includes("PORTERSECRET")
+                }
+                type={value?.includes("PORTERSECRET") ? "password" : "text"}
                 spellCheck={false}
                 spellCheck={false}
               />
               />
               {this.renderDeleteButton(i)}
               {this.renderDeleteButton(i)}
-              {this.renderHiddenOption(value.includes("PORTERSECRET"), i)}
+              {this.renderHiddenOption(value?.includes("PORTERSECRET"), i)}
             </InputWrapper>
             </InputWrapper>
           );
           );
         })}
         })}
@@ -176,6 +180,7 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
               this.props.setValues(newValues);
               this.props.setValues(newValues);
               this.setState({ values: this.objectToValues(newValues) });
               this.setState({ values: this.objectToValues(newValues) });
             }}
             }}
+            normalEnvVarsOnly
           />
           />
         </Modal>
         </Modal>
       );
       );

+ 2 - 1
dashboard/src/components/porter-form/field-components/CronInput.tsx

@@ -10,7 +10,7 @@ import DocsHelper from "components/DocsHelper";
 import DynamicLink from "components/DynamicLink";
 import DynamicLink from "components/DynamicLink";
 
 
 const CronInput: React.FC<CronField> = (props) => {
 const CronInput: React.FC<CronField> = (props) => {
-  const { id, variable, label, placeholder, value } = props;
+  const { id, variable, label, placeholder, value, isReadOnly } = props;
 
 
   const { state, variables, setVars, setValidation, validation } = useFormField(
   const { state, variables, setVars, setValidation, validation } = useFormField(
     id,
     id,
@@ -35,6 +35,7 @@ const CronInput: React.FC<CronField> = (props) => {
         label={label}
         label={label}
         placeholder={placeholder}
         placeholder={placeholder}
         value={variables[variable]}
         value={variables[variable]}
+        disabled={isReadOnly}
         setValue={(x: string) => {
         setValue={(x: string) => {
           setVars((vars) => {
           setVars((vars) => {
             return {
             return {

+ 5 - 1
dashboard/src/components/porter-form/field-components/KeyValueArray.tsx

@@ -31,7 +31,7 @@ const KeyValueArray: React.FC<Props> = (props) => {
       initState: () => {
       initState: () => {
         let values = props.value[0];
         let values = props.value[0];
         const normalValues = Object.entries(values?.normal || {});
         const normalValues = Object.entries(values?.normal || {});
-        values = omit(values, ["normal", "synced"]);
+        values = omit(values, ["normal", "synced", "build"]);
         return {
         return {
           values: hasSetValue(props)
           values: hasSetValue(props)
             ? ([...Object.entries(values), ...normalValues]?.map(([k, v]) => {
             ? ([...Object.entries(values), ...normalValues]?.map(([k, v]) => {
@@ -511,6 +511,10 @@ export const getFinalVariablesForKeyValueArray: GetFinalVariablesFunction = (
       }
       }
     });
     });
 
 
+    if (Array.isArray(props.value) && props.value[0]?.build) {
+      obj.build = props.value[0].build;
+    }
+
     if (state.synced_env_groups?.length) {
     if (state.synced_env_groups?.length) {
       obj.synced = state.synced_env_groups.map((envGroup) => ({
       obj.synced = state.synced_env_groups.map((envGroup) => ({
         name: envGroup?.name,
         name: envGroup?.name,

+ 2 - 1
dashboard/src/components/repo-selector/BuildpackSelection.tsx

@@ -307,7 +307,7 @@ export const BuildpackSelection: React.FC<{
   );
   );
 };
 };
 
 
-const AddCustomBuildpackForm: React.FC<{
+export const AddCustomBuildpackForm: React.FC<{
   onAdd: (buildpack: Buildpack) => void;
   onAdd: (buildpack: Buildpack) => void;
 }> = ({ onAdd }) => {
 }> = ({ onAdd }) => {
   const [buildpackUrl, setBuildpackUrl] = useState("");
   const [buildpackUrl, setBuildpackUrl] = useState("");
@@ -324,6 +324,7 @@ const AddCustomBuildpackForm: React.FC<{
       name: buildpackUrl,
       name: buildpackUrl,
       config: null,
       config: null,
     };
     };
+    setBuildpackUrl("");
     onAdd(buildpack);
     onAdd(buildpack);
   };
   };
 
 

+ 857 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/BuildSettingsTab.tsx

@@ -0,0 +1,857 @@
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import KeyValueArray from "components/form-components/KeyValueArray";
+import SelectRow from "components/form-components/SelectRow";
+import Loading from "components/Loading";
+import MultiSaveButton from "components/MultiSaveButton";
+import _, { differenceBy, unionBy } from "lodash";
+import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import {
+  BuildConfig,
+  ChartTypeWithExtendedConfig,
+  FullActionConfigType,
+} from "shared/types";
+import styled, { keyframes } from "styled-components";
+import yaml from "js-yaml";
+import DynamicLink from "components/DynamicLink";
+import { AxiosError } from "axios";
+import { AddCustomBuildpackForm } from "components/repo-selector/BuildpackSelection";
+
+const DEFAULT_PAKETO_STACK = "paketobuildpacks/builder:full";
+const DEFAULT_HEROKU_STACK = "heroku/buildpacks:20";
+const URLRegex = /[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/;
+
+type Buildpack = {
+  name: string;
+  buildpack: string;
+  config: {
+    [key: string]: string;
+  };
+};
+
+type DetectedBuildpack = {
+  name: string;
+  builders: string[];
+  detected: Buildpack[];
+  others: Buildpack[];
+};
+
+type DetectBuildpackResponse = DetectedBuildpack[];
+
+type UpdateBuildconfigResponse = {
+  CreatedAt: string;
+  DeletedAt: { Time: string; Valid: boolean };
+  Time: string;
+  Valid: boolean;
+  ID: number;
+  UpdatedAt: string;
+  builder: string;
+  buildpacks: string;
+  config: string;
+  name: string;
+};
+
+type Props = {
+  chart: ChartTypeWithExtendedConfig;
+  isPreviousVersion: boolean;
+};
+
+const BuildSettingsTab: React.FC<Props> = ({ chart, isPreviousVersion }) => {
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+
+  const [buildConfig, setBuildConfig] = useState<BuildConfig>(null);
+  const [envVariables, setEnvVariables] = useState(
+    chart.config?.container?.env?.build || null
+  );
+  const [runningWorkflowURL, setRunningWorkflowURL] = useState("");
+  const [reRunError, setReRunError] = useState<{
+    title: string;
+    description: string;
+  }>(null);
+  const [buttonStatus, setButtonStatus] = useState<
+    "loading" | "successful" | string
+  >("");
+
+  const saveBuildConfig = async (config: BuildConfig) => {
+    if (config === null) {
+      return;
+    }
+
+    try {
+      await api.updateBuildConfig<UpdateBuildconfigResponse>(
+        "<token>",
+        { ...config },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: chart.namespace,
+          release_name: chart.name,
+        }
+      );
+    } catch (err) {
+      throw err;
+    }
+  };
+
+  const saveEnvVariables = async (envs: { [key: string]: string }) => {
+    let values = { ...chart.config };
+    if (envs === null) {
+      return;
+    }
+
+    values.container.env.build = { ...envs };
+    const valuesYaml = yaml.dump({ ...values });
+    try {
+      await api.upgradeChartValues(
+        "<token>",
+        {
+          values: valuesYaml,
+        },
+        {
+          id: currentProject.id,
+          namespace: chart.namespace,
+          name: chart.name,
+          cluster_id: currentCluster.id,
+        }
+      );
+    } catch (error) {
+      throw error;
+    }
+  };
+
+  const triggerWorkflow = async () => {
+    try {
+      await api.reRunGHWorkflow(
+        "",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          git_installation_id: chart.git_action_config?.git_repo_id,
+          owner: chart.git_action_config?.git_repo?.split("/")[0],
+          name: chart.git_action_config?.git_repo?.split("/")[1],
+          branch: chart.git_action_config?.git_branch,
+          release_name: chart.name,
+        }
+      );
+    } catch (error) {
+      if (!error?.response) {
+        throw error;
+      }
+
+      let tmpError: AxiosError = error;
+
+      /**
+       * @smell
+       * Currently the expanded chart is clearing all the state when a chart update is triggered (saveEnvVariables).
+       * Temporary usage of setCurrentError until a context is applied to keep the state of the ReRunError during re renders.
+       */
+
+      if (tmpError.response.status === 400) {
+        // setReRunError({
+        //   title: "No previous run found",
+        //   description:
+        //     "There are no previous runs for this workflow, please trigger manually a run before changing the build settings.",
+        // });
+        setCurrentError(
+          "There are no previous runs for this workflow. Please manually trigger a run before changing build settings."
+        );
+        return;
+      }
+
+      if (tmpError.response.status === 409) {
+        // setReRunError({
+        //   title: "The workflow is still running",
+        //   description:
+        //     'If you want to make more changes, please choose the option "Save" until the workflow finishes.',
+        // });
+
+        if (typeof tmpError.response.data === "string") {
+          setRunningWorkflowURL(tmpError.response.data);
+        }
+        setCurrentError(
+          'The workflow is still running. You can "Save" the current build settings for the next workflow run and view the current status of the workflow here: ' +
+            tmpError.response.data
+        );
+        return;
+      }
+
+      if (tmpError.response.status === 404) {
+        let description = "No action file matching this deployment was found.";
+        if (typeof tmpError.response.data === "string") {
+          const filename = tmpError.response.data;
+          description = description.concat(
+            `Please check that the file "${filename}" exists in your repository.`
+          );
+        }
+        // setReRunError({
+        //   title: "The action doesn't seem to exist",
+        //   description,
+        // });
+
+        setCurrentError(description);
+        return;
+      }
+      throw error;
+    }
+  };
+
+  const clearButtonStatus = () => {
+    setTimeout(() => {
+      setButtonStatus("");
+    }, 800);
+  };
+
+  const handleSave = async () => {
+    setButtonStatus("loading");
+    try {
+      await saveBuildConfig(buildConfig);
+      await saveEnvVariables(envVariables);
+      setButtonStatus("successful");
+    } catch (error) {
+      setButtonStatus("Something went wrong");
+      setCurrentError(error);
+    } finally {
+      clearButtonStatus();
+    }
+  };
+
+  const handleSaveAndReDeploy = async () => {
+    setButtonStatus("loading");
+    try {
+      await saveBuildConfig(buildConfig);
+      await saveEnvVariables(envVariables);
+      await triggerWorkflow();
+      setButtonStatus("successful");
+    } catch (error) {
+      setButtonStatus("Something went wrong");
+      setCurrentError(error);
+    } finally {
+      clearButtonStatus();
+    }
+  };
+
+  return (
+    <Wrapper>
+      {isPreviousVersion ? (
+        <DisabledOverlay>
+          Build config is disabled when reviewing past versions. Please go to
+          the current revision to update your app build configuration.
+        </DisabledOverlay>
+      ) : null}
+      <StyledSettingsSection blurContent={isPreviousVersion}>
+        {/* {reRunError !== null ? (
+        <AlertCard>
+          <AlertCardIcon className="material-icons">error</AlertCardIcon>
+          <AlertCardContent className="content">
+            <AlertCardTitle className="title">
+              {reRunError.title}
+            </AlertCardTitle>
+            {reRunError.description}
+            {runningWorkflowURL.length ? (
+              <>
+                {" "}
+                To go to the workflow{" "}
+                <DynamicLink to={runningWorkflowURL} target="_blank">
+                  click here
+                </DynamicLink>
+              </>
+            ) : null}
+          </AlertCardContent>
+          <AlertCardAction
+            onClick={() => {
+              setReRunError(null);
+              setRunningWorkflowURL("");
+            }}
+          >
+            <span className="material-icons">close</span>
+          </AlertCardAction>
+        </AlertCard>
+      ) : null} */}
+        <Heading isAtTop>Build Environment Variables</Heading>
+        <KeyValueArray
+          values={envVariables}
+          envLoader
+          externalValues={{
+            namespace: chart.namespace,
+            clusterId: currentCluster.id,
+          }}
+          setValues={(values) => {
+            setEnvVariables(values);
+          }}
+        ></KeyValueArray>
+
+        {!chart.git_action_config.dockerfile_path ? (
+          <>
+            <Heading>Buildpack Settings</Heading>
+            <BuildpackConfigSection
+              currentChart={chart}
+              actionConfig={chart.git_action_config}
+              onChange={(buildConfig) => setBuildConfig(buildConfig)}
+            />
+          </>
+        ) : null}
+        <SaveButtonWrapper>
+          <MultiSaveButton
+            options={[
+              {
+                text: "Save",
+                onClick: handleSave,
+                description:
+                  "Save the build settings to be used in the next workflow run",
+              },
+              {
+                text: "Save and Redeploy",
+                onClick: handleSaveAndReDeploy,
+                description:
+                  "Immediately trigger a workflow run with updated build settings",
+              },
+            ]}
+            disabled={false}
+            makeFlush={true}
+            clearPosition={true}
+            statusPosition="left"
+            expandTo="left"
+            saveText=""
+            status={buttonStatus}
+          ></MultiSaveButton>
+        </SaveButtonWrapper>
+      </StyledSettingsSection>
+    </Wrapper>
+  );
+};
+
+export default BuildSettingsTab;
+
+const BuildpackConfigSection: React.FC<{
+  actionConfig: FullActionConfigType;
+  currentChart: ChartTypeWithExtendedConfig;
+  onChange: (buildConfig: BuildConfig) => void;
+}> = ({ actionConfig, currentChart, onChange }) => {
+  const { currentProject } = useContext(Context);
+
+  const [builders, setBuilders] = useState<DetectedBuildpack[]>(null);
+  const [selectedBuilder, setSelectedBuilder] = useState<string>(null);
+
+  const [stacks, setStacks] = useState<string[]>(null);
+  const [selectedStack, setSelectedStack] = useState<string>(null);
+
+  const [selectedBuildpacks, setSelectedBuildpacks] = useState<Buildpack[]>([]);
+  const [availableBuildpacks, setAvailableBuildpacks] = useState<Buildpack[]>(
+    []
+  );
+
+  const state = useRef<null | {
+    [builder: string]: {
+      stack: string;
+      selectedBuildpacks: Buildpack[];
+      availableBuildpacks: Buildpack[];
+    };
+  }>(null);
+
+  const populateState = (
+    builder: string,
+    stack: string,
+    availableBuildpacks: Buildpack[] = [],
+    selectedBuildpacks: Buildpack[] = []
+  ) => {
+    state.current = {
+      ...state.current,
+      [builder]: {
+        stack: stack,
+        availableBuildpacks: availableBuildpacks,
+        selectedBuildpacks: selectedBuildpacks,
+      },
+    };
+  };
+
+  const populateBuildpacks = (
+    userBuildpacks: string[],
+    detectedBuildpacks: Buildpack[]
+  ) => {
+    const customBuildpackFactory = (name: string): Buildpack => ({
+      name: name,
+      buildpack: name,
+      config: null,
+    });
+
+    return userBuildpacks.map(
+      (ub) =>
+        detectedBuildpacks.find((db) => db.buildpack === ub) ||
+        customBuildpackFactory(ub)
+    );
+  };
+
+  useEffect(() => {
+    const currentBuildConfig = currentChart?.build_config;
+
+    if (!currentBuildConfig) {
+      return;
+    }
+
+    api
+      .detectBuildpack<DetectBuildpackResponse>(
+        "<token>",
+        {
+          dir: actionConfig.folder_path || ".",
+        },
+        {
+          project_id: currentProject.id,
+          git_repo_id: actionConfig.git_repo_id,
+          kind: "github",
+          owner: actionConfig.git_repo.split("/")[0],
+          name: actionConfig.git_repo.split("/")[1],
+          branch: actionConfig.git_branch,
+        }
+      )
+      .then(({ data }) => {
+        const builders = data;
+
+        const defaultBuilder = builders.find((builder) =>
+          builder.builders.find((stack) => stack === currentBuildConfig.builder)
+        );
+
+        const nonSelectedBuilder = builders.find(
+          (builder) =>
+            !builder.builders.find(
+              (stack) => stack === currentBuildConfig.builder
+            )
+        );
+
+        const fullDetectedBuildpacks = [
+          ...defaultBuilder.detected,
+          ...defaultBuilder.others,
+        ];
+
+        const userSelectedBuildpacks = populateBuildpacks(
+          currentBuildConfig.buildpacks,
+          fullDetectedBuildpacks
+        ).filter((b) => b.buildpack);
+
+        const availableBuildpacks = differenceBy(
+          fullDetectedBuildpacks,
+          userSelectedBuildpacks,
+          "buildpack"
+        );
+
+        const defaultStack = defaultBuilder.builders.find((stack) => {
+          return stack === currentBuildConfig.builder;
+        });
+
+        populateState(
+          defaultBuilder.name.toLowerCase(),
+          defaultStack,
+          userSelectedBuildpacks,
+          availableBuildpacks
+        );
+
+        populateState(
+          nonSelectedBuilder.name.toLowerCase(),
+          nonSelectedBuilder.builders[0],
+          nonSelectedBuilder.others,
+          nonSelectedBuilder.detected
+        );
+
+        setBuilders(builders);
+        setSelectedBuilder(defaultBuilder.name.toLowerCase());
+
+        setStacks(defaultBuilder.builders);
+        setSelectedStack(defaultStack);
+        if (!Array.isArray(userSelectedBuildpacks)) {
+          setSelectedBuildpacks([]);
+        } else {
+          setSelectedBuildpacks(userSelectedBuildpacks);
+        }
+        if (!Array.isArray(availableBuildpacks)) {
+          setAvailableBuildpacks([]);
+        } else {
+          setAvailableBuildpacks(availableBuildpacks);
+        }
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  }, [currentProject, actionConfig, currentChart]);
+
+  useEffect(() => {
+    let buildConfig: BuildConfig = {} as BuildConfig;
+
+    buildConfig.builder = selectedStack;
+    buildConfig.buildpacks = selectedBuildpacks?.map((buildpack) => {
+      return buildpack.buildpack;
+    });
+
+    onChange(buildConfig);
+  }, [selectedBuilder, selectedBuildpacks, selectedStack]);
+
+  useEffect(() => {
+    populateState(
+      selectedBuilder,
+      selectedStack,
+      availableBuildpacks,
+      selectedBuildpacks
+    );
+  }, [selectedBuilder, selectedBuildpacks, selectedStack, availableBuildpacks]);
+
+  const builderOptions = useMemo(() => {
+    if (!Array.isArray(builders)) {
+      return;
+    }
+
+    return builders.map((builder) => ({
+      label: builder.name,
+      value: builder.name.toLowerCase(),
+    }));
+  }, [builders]);
+
+  const stackOptions = useMemo(() => {
+    if (!Array.isArray(stacks)) {
+      return;
+    }
+
+    return stacks.map((stack) => ({
+      label: stack,
+      value: stack.toLowerCase(),
+    }));
+  }, [stacks]);
+
+  const handleAddCustomBuildpack = (buildpack: Buildpack) => {
+    setSelectedBuildpacks((selectedBuildpacks) => [
+      ...selectedBuildpacks,
+      buildpack,
+    ]);
+  };
+
+  const handleSelectBuilder = (builderName: string) => {
+    const builder = builders.find(
+      (b) => b.name.toLowerCase() === builderName.toLowerCase()
+    );
+
+    setBuilders(builders);
+    setStacks(builder.builders);
+
+    const currState = state.current;
+    if (currState[builderName]) {
+      const stateBuilder = currState[builderName];
+      setSelectedBuilder(builderName);
+      setSelectedStack(stateBuilder.stack);
+      setAvailableBuildpacks(stateBuilder.availableBuildpacks);
+      setSelectedBuildpacks(stateBuilder.selectedBuildpacks);
+      return;
+    }
+  };
+
+  const renderBuildpacksList = (
+    buildpacks: Buildpack[],
+    action: "remove" | "add"
+  ) => {
+    if (!buildpacks.length && action === "remove") {
+      return (
+        <StyledCard>Buildpacks will be automatically detected.</StyledCard>
+      );
+    }
+
+    if (!buildpacks.length && action === "add") {
+      return (
+        <StyledCard>
+          No additional buildpacks are available. You can add a custom buildpack
+          below.
+        </StyledCard>
+      );
+    }
+
+    return buildpacks?.map((buildpack, i) => {
+      const icon = `devicon-${buildpack?.name?.toLowerCase()}-plain colored`;
+
+      let disableIcon = false;
+      if (
+        URLRegex.test(buildpack.buildpack) &&
+        !buildpack.buildpack.includes("gcr.io/paketo-buildpacks")
+      ) {
+        disableIcon = true;
+      }
+
+      return (
+        <StyledCard key={i}>
+          <ContentContainer>
+            <Icon disableMarginRight={disableIcon} className={icon} />
+            <EventInformation>
+              <EventName>{buildpack?.name}</EventName>
+            </EventInformation>
+          </ContentContainer>
+          <ActionContainer>
+            {action === "add" && (
+              <DeleteButton
+                onClick={() => handleAddBuildpack(buildpack.buildpack)}
+              >
+                <span className="material-icons-outlined">add</span>
+              </DeleteButton>
+            )}
+            {action === "remove" && (
+              <DeleteButton
+                onClick={() => handleRemoveBuildpack(buildpack.buildpack)}
+              >
+                <span className="material-icons">delete</span>
+              </DeleteButton>
+            )}
+          </ActionContainer>
+        </StyledCard>
+      );
+    });
+  };
+
+  const handleRemoveBuildpack = (buildpackToRemove: string) => {
+    setSelectedBuildpacks((selBuildpacks) => {
+      const tmpSelectedBuildpacks = [...selBuildpacks];
+
+      const indexBuildpackToRemove = tmpSelectedBuildpacks.findIndex(
+        (buildpack) => buildpack.buildpack === buildpackToRemove
+      );
+      const buildpack = tmpSelectedBuildpacks[indexBuildpackToRemove];
+
+      setAvailableBuildpacks((availableBuildpacks) => [
+        ...availableBuildpacks,
+        buildpack,
+      ]);
+
+      tmpSelectedBuildpacks.splice(indexBuildpackToRemove, 1);
+
+      return [...tmpSelectedBuildpacks];
+    });
+  };
+
+  const handleAddBuildpack = (buildpackToAdd: string) => {
+    setAvailableBuildpacks((avBuildpacks) => {
+      const tmpAvailableBuildpacks = [...avBuildpacks];
+      const indexBuildpackToAdd = tmpAvailableBuildpacks.findIndex(
+        (buildpack) => buildpack.buildpack === buildpackToAdd
+      );
+      const buildpack = tmpAvailableBuildpacks[indexBuildpackToAdd];
+
+      setSelectedBuildpacks((selectedBuildpacks) => [
+        ...selectedBuildpacks,
+        buildpack,
+      ]);
+
+      tmpAvailableBuildpacks.splice(indexBuildpackToAdd, 1);
+      return [...tmpAvailableBuildpacks];
+    });
+  };
+
+  if (!stackOptions?.length || !builderOptions?.length) {
+    return <Loading />;
+  }
+
+  return (
+    <BuildpackConfigurationContainer>
+      <>
+        <SelectRow
+          value={selectedBuilder}
+          width="100%"
+          options={builderOptions}
+          setActiveValue={(option) => handleSelectBuilder(option)}
+          label="Select a builder"
+        />
+        <SelectRow
+          value={selectedStack}
+          width="100%"
+          options={stackOptions}
+          setActiveValue={(option) => setSelectedStack(option)}
+          label="Select your stack"
+        />
+        <Helper>
+          The following buildpacks were automatically detected. You can also
+          manually add/remove buildpacks.
+        </Helper>
+        <>{renderBuildpacksList(selectedBuildpacks, "remove")}</>
+        <Helper>Available buildpacks:</Helper>
+        <>{renderBuildpacksList(availableBuildpacks, "add")}</>
+        <Helper>
+          You may also add buildpacks by directly providing their GitHub links
+          or links to ZIP files that contain the buildpack source code.
+        </Helper>
+        <AddCustomBuildpackForm onAdd={handleAddCustomBuildpack} />
+      </>
+    </BuildpackConfigurationContainer>
+  );
+};
+
+const DisabledOverlay = styled.div`
+  position: absolute;
+  width: 100%;
+  height: inherit;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #00000099;
+  z-index: 1000;
+  border-radius: 8px;
+  padding: 0 35px;
+  text-align: center;
+`;
+const fadeIn = keyframes`
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+`;
+
+const SaveButtonWrapper = styled.div`
+  width: 100%;
+  margin-top: 30px;
+  display: flex;
+  justify-content: flex-end;
+`;
+
+const BuildpackConfigurationContainer = styled.div`
+  animation: ${fadeIn} 0.75s;
+`;
+
+const Wrapper = styled.div`
+  position: relative;
+  width: 100%;
+  margin-bottom: 65px;
+  height: 100%;
+`;
+
+const StyledSettingsSection = styled.div<{ blurContent: boolean }>`
+  width: 100%;
+  background: #ffffff11;
+  padding: 0 35px;
+  padding-top: 35px;
+  padding-bottom: 15px;
+  position: relative;
+  border-radius: 8px;
+  height: calc(100% - 55px);
+  ${(props) => (props.blurContent ? "filter: blur(5px);" : "")}
+`;
+
+const StyledCard = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border: 1px solid #ffffff00;
+  background: #ffffff08;
+  margin-bottom: 5px;
+  border-radius: 8px;
+  padding: 14px;
+  overflow: hidden;
+  height: 60px;
+  font-size: 13px;
+  animation: ${fadeIn} 0.5s;
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  height: 100%;
+  width: 100%;
+  align-items: center;
+`;
+
+const Icon = styled.span<{ disableMarginRight: boolean }>`
+  font-size: 20px;
+  margin-left: 10px;
+  ${(props) => {
+    if (!props.disableMarginRight) {
+      return "margin-right: 20px";
+    }
+  }}
+`;
+
+const EventInformation = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  height: 100%;
+`;
+
+const EventName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const ActionContainer = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  height: 100%;
+`;
+
+const DeleteButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  cursor: pointer;
+  color: #aaaabb;
+
+  :hover {
+    background: #ffffff11;
+    border: 1px solid #ffffff44;
+  }
+
+  > span {
+    font-size: 20px;
+  }
+`;
+
+const AlertCard = styled.div`
+  transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
+  border-radius: 4px;
+  box-shadow: none;
+  font-weight: 400;
+  font-size: 0.875rem;
+  line-height: 1.43;
+  letter-spacing: 0.01071em;
+  border: 1px solid rgb(229, 115, 115);
+  display: flex;
+  padding: 6px 16px;
+  color: rgb(244, 199, 199);
+  margin-top: 20px;
+  position: relative;
+`;
+
+const AlertCardIcon = styled.span`
+  color: rgb(239, 83, 80);
+  margin-right: 12px;
+  padding: 7px 0px;
+  display: flex;
+  font-size: 22px;
+  opacity: 0.9;
+`;
+
+const AlertCardTitle = styled.div`
+  margin: -2px 0px 0.35em;
+  font-size: 1rem;
+  line-height: 1.5;
+  letter-spacing: 0.00938em;
+  font-weight: 500;
+`;
+
+const AlertCardContent = styled.div`
+  padding: 8px 0px;
+`;
+
+const AlertCardAction = styled.button`
+  position: absolute;
+  right: 5px;
+  top: 5px;
+  border: none;
+  background-color: unset;
+  color: white;
+  :hover {
+    cursor: pointer;
+  }
+`;

+ 52 - 43
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -30,6 +30,7 @@ import TitleSection from "components/TitleSection";
 import DeploymentType from "./DeploymentType";
 import DeploymentType from "./DeploymentType";
 import { onlyInLeft } from "shared/array_utils";
 import { onlyInLeft } from "shared/array_utils";
 import IncidentsTab from "./incidents/IncidentsTab";
 import IncidentsTab from "./incidents/IncidentsTab";
+import BuildSettingsTab from "./BuildSettingsTab";
 
 
 type Props = {
 type Props = {
   namespace: string;
   namespace: string;
@@ -515,6 +516,8 @@ const ExpandedChart: React.FC<Props> = (props) => {
             disabled={!isAuthorized("application", "", ["get", "update"])}
             disabled={!isAuthorized("application", "", ["get", "update"])}
           />
           />
         );
         );
+      case "build-settings":
+        return <BuildSettingsTab chart={chart} isPreviousVersion={isPreview} />;
       default:
       default:
     }
     }
   };
   };
@@ -539,6 +542,13 @@ const ExpandedChart: React.FC<Props> = (props) => {
       );
       );
     }
     }
 
 
+    if (currentChart?.git_action_config?.git_repo) {
+      rightTabOptions.push({
+        label: "Build Settings",
+        value: "build-settings",
+      });
+    }
+
     // Settings tab is always last
     // Settings tab is always last
     if (isAuthorized("application", "", ["get", "delete"])) {
     if (isAuthorized("application", "", ["get", "delete"])) {
       rightTabOptions.push({ label: "Settings", value: "settings" });
       rightTabOptions.push({ label: "Settings", value: "settings" });
@@ -580,47 +590,47 @@ const ExpandedChart: React.FC<Props> = (props) => {
     }
     }
   };
   };
 
 
-  const chartStatus = useMemo(() => {
-    const getAvailability = (kind: string, c: any) => {
-      switch (kind?.toLowerCase()) {
-        case "deployment":
-        case "replicaset":
-          return c.status.availableReplicas == c.status.replicas;
-        case "statefulset":
-          return c.status.readyReplicas == c.status.replicas;
-        case "daemonset":
-          return c.status.numberAvailable == c.status.desiredNumberScheduled;
-      }
-    };
-
-    const chartStatus = currentChart.info.status;
-
-    if (chartStatus === "deployed") {
-      for (var uid in controllers) {
-        let value = controllers[uid];
-        let available = getAvailability(value.metadata.kind, value);
-        let progressing = true;
-
-        controllers[uid]?.status?.conditions?.forEach((condition: any) => {
-          if (
-            condition.type == "Progressing" &&
-            condition.status == "False" &&
-            condition.reason == "ProgressDeadlineExceeded"
-          ) {
-            progressing = false;
-          }
-        });
-
-        if (!available && progressing) {
-          return "loading";
-        } else if (!available && !progressing) {
-          return "failed";
-        }
-      }
-      return "deployed";
-    }
-    return chartStatus;
-  }, [currentChart, controllers]);
+  // const chartStatus = useMemo(() => {
+  //   const getAvailability = (kind: string, c: any) => {
+  //     switch (kind?.toLowerCase()) {
+  //       case "deployment":
+  //       case "replicaset":
+  //         return c.status.availableReplicas == c.status.replicas;
+  //       case "statefulset":
+  //         return c.status.readyReplicas == c.status.replicas;
+  //       case "daemonset":
+  //         return c.status.numberAvailable == c.status.desiredNumberScheduled;
+  //     }
+  //   };
+
+  //   const chartStatus = currentChart.info.status;
+
+  //   if (chartStatus === "deployed") {
+  //     for (var uid in controllers) {
+  //       let value = controllers[uid];
+  //       let available = getAvailability(value.metadata.kind, value);
+  //       let progressing = true;
+
+  //       controllers[uid]?.status?.conditions?.forEach((condition: any) => {
+  //         if (
+  //           condition.type == "Progressing" &&
+  //           condition.status == "False" &&
+  //           condition.reason == "ProgressDeadlineExceeded"
+  //         ) {
+  //           progressing = false;
+  //         }
+  //       });
+
+  //       if (!available && progressing) {
+  //         return "loading";
+  //       } else if (!available && !progressing) {
+  //         return "failed";
+  //       }
+  //     }
+  //     return "deployed";
+  //   }
+  //   return chartStatus;
+  // }, [currentChart, controllers]);
 
 
   const renderUrl = () => {
   const renderUrl = () => {
     if (url) {
     if (url) {
@@ -836,7 +846,6 @@ const ExpandedChart: React.FC<Props> = (props) => {
                 setRevision={setRevision}
                 setRevision={setRevision}
                 forceRefreshRevisions={forceRefreshRevisions}
                 forceRefreshRevisions={forceRefreshRevisions}
                 refreshRevisionsOff={() => setForceRefreshRevisions(false)}
                 refreshRevisionsOff={() => setForceRefreshRevisions(false)}
-                status={chartStatus}
                 shouldUpdate={
                 shouldUpdate={
                   currentChart.latest_version &&
                   currentChart.latest_version &&
                   currentChart.latest_version !==
                   currentChart.latest_version !==
@@ -855,6 +864,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
                     }}
                     }}
                     renderTabContents={renderTabContents}
                     renderTabContents={renderTabContents}
                     isReadOnly={
                     isReadOnly={
+                      isPreview ||
                       imageIsPlaceholder ||
                       imageIsPlaceholder ||
                       !isAuthorized("application", "", ["get", "update"])
                       !isAuthorized("application", "", ["get", "update"])
                     }
                     }
@@ -939,7 +949,6 @@ const LineBreak = styled.div`
 
 
 const BodyWrapper = styled.div`
 const BodyWrapper = styled.div`
   position: relative;
   position: relative;
-  overflow: hidden;
   margin-bottom: 120px;
   margin-bottom: 120px;
 `;
 `;
 
 

+ 24 - 5
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -27,6 +27,7 @@ import ConnectToJobInstructionsModal from "./jobs/ConnectToJobInstructionsModal"
 import CommandLineIcon from "assets/command-line-icon";
 import CommandLineIcon from "assets/command-line-icon";
 import CronParser from "cron-parser";
 import CronParser from "cron-parser";
 import CronPrettifier from "cronstrue";
 import CronPrettifier from "cronstrue";
+import BuildSettingsTab from "./BuildSettingsTab";
 
 
 const readableDate = (s: string) => {
 const readableDate = (s: string) => {
   let ts = new Date(s);
   let ts = new Date(s);
@@ -84,6 +85,13 @@ export const ExpandedJobChartFC: React.FC<{
     rightTabOptions.push({ label: "Settings", value: "settings" });
     rightTabOptions.push({ label: "Settings", value: "settings" });
   }
   }
 
 
+  if (chart?.git_action_config?.git_repo) {
+    rightTabOptions.push({
+      label: "Build Settings",
+      value: "build-settings",
+    });
+  }
+
   const leftTabOptions = [{ label: "Jobs", value: "jobs" }];
   const leftTabOptions = [{ label: "Jobs", value: "jobs" }];
 
 
   const processValuesToUpdateChart = (newConfig?: any) => (
   const processValuesToUpdateChart = (newConfig?: any) => (
@@ -152,6 +160,17 @@ export const ExpandedJobChartFC: React.FC<{
       timeStyle: "long",
       timeStyle: "long",
     });
     });
 
 
+    let runDescription = "";
+
+    try {
+      runDescription = `Runs ${CronPrettifier.toString(
+        chart?.config?.schedule.value
+      ).toLowerCase()} UTC`;
+    } catch (error) {
+      runDescription =
+        "An unexpected error happened while trying to parse the cron expression.";
+    }
+
     if (currentTab === "jobs") {
     if (currentTab === "jobs") {
       return (
       return (
         <TabWrapper>
         <TabWrapper>
@@ -186,11 +205,7 @@ export const ExpandedJobChartFC: React.FC<{
           {chart?.config?.schedule?.enabled ? (
           {chart?.config?.schedule?.enabled ? (
             <RunsDescription>
             <RunsDescription>
               <i className="material-icons">access_time</i>
               <i className="material-icons">access_time</i>
-              Runs{" "}
-              {CronPrettifier.toString(
-                chart?.config?.schedule.value
-              ).toLowerCase()}{" "}
-              UTC
+              {runDescription}
               <Dot
               <Dot
                 style={{
                 style={{
                   color: "#ffffff88",
                   color: "#ffffff88",
@@ -234,6 +249,10 @@ export const ExpandedJobChartFC: React.FC<{
       );
       );
     }
     }
 
 
+    if (currentTab === "build-settings") {
+      return <BuildSettingsTab chart={chart} />;
+    }
+
     if (
     if (
       currentTab === "settings" &&
       currentTab === "settings" &&
       isAuthorized("job", "", ["get", "delete"])
       isAuthorized("job", "", ["get", "delete"])

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

@@ -16,6 +16,7 @@ import useAuth from "shared/auth/useAuth";
 import Loading from "components/Loading";
 import Loading from "components/Loading";
 import NotificationSettingsSection from "./NotificationSettingsSection";
 import NotificationSettingsSection from "./NotificationSettingsSection";
 import { Link } from "react-router-dom";
 import { Link } from "react-router-dom";
+import { isDeployedFromGithub } from "shared/release/utils";
 
 
 type PropsType = {
 type PropsType = {
   currentChart: ChartType;
   currentChart: ChartType;
@@ -212,33 +213,35 @@ const SettingsSection: React.FC<PropsType> = ({
 
 
     return (
     return (
       <>
       <>
-        <>
-          <Heading>Source Settings</Heading>
-          <Helper>Specify an image tag to use.</Helper>
-          <ImageSelector
-            selectedTag={selectedTag}
-            selectedImageUrl={selectedImageUrl}
-            setSelectedImageUrl={(x: string) => setSelectedImageUrl(x)}
-            setSelectedTag={(x: string) => setSelectedTag(x)}
-            forceExpanded={true}
-            disableImageSelect={true}
-          />
-          {!loadingWebhookToken && (
-            <>
-              <Br />
-              <Br />
-              <Br />
-              <SaveButton
-                clearPosition={true}
-                statusPosition="right"
-                text="Save Source Settings"
-                status={saveValuesStatus}
-                onClick={handleSubmit}
-              />
-            </>
-          )}
-          <Br />
-        </>
+        {!isDeployedFromGithub(currentChart) ? (
+          <>
+            <Heading>Source Settings</Heading>
+            <Helper>Specify an image tag to use.</Helper>
+            <ImageSelector
+              selectedTag={selectedTag}
+              selectedImageUrl={selectedImageUrl}
+              setSelectedImageUrl={(x: string) => setSelectedImageUrl(x)}
+              setSelectedTag={(x: string) => setSelectedTag(x)}
+              forceExpanded={true}
+              disableImageSelect={true}
+            />
+            {!loadingWebhookToken && (
+              <>
+                <Br />
+                <Br />
+                <Br />
+                <SaveButton
+                  clearPosition={true}
+                  statusPosition="right"
+                  text="Save Source Settings"
+                  status={saveValuesStatus}
+                  onClick={handleSubmit}
+                />
+              </>
+            )}
+            <Br />
+          </>
+        ) : null}
 
 
         <>
         <>
           <Heading>Redeploy Webhook</Heading>
           <Heading>Redeploy Webhook</Heading>

+ 1 - 4
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentCard.tsx

@@ -41,10 +41,7 @@ const DeploymentCard: React.FC<{
         {
         {
           cluster_id: currentCluster.id,
           cluster_id: currentCluster.id,
           project_id: currentProject.id,
           project_id: currentProject.id,
-          environment_id: deployment.environment_id,
-          repo_owner: deployment.gh_repo_owner,
-          repo_name: deployment.gh_repo_name,
-          pr_number: deployment.pull_request_id,
+          deployment_id: deployment.id,
         }
         }
       )
       )
       .then(() => {
       .then(() => {

+ 6 - 2
dashboard/src/main/home/modals/LoadEnvGroupModal.tsx

@@ -30,6 +30,7 @@ type PropsType = {
   enableSyncedEnvGroups?: boolean;
   enableSyncedEnvGroups?: boolean;
   syncedEnvGroups?: PopulatedEnvGroup[];
   syncedEnvGroups?: PopulatedEnvGroup[];
   setSyncedEnvGroups?: (values: PopulatedEnvGroup) => void;
   setSyncedEnvGroups?: (values: PopulatedEnvGroup) => void;
+  normalEnvVarsOnly?: boolean;
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -132,6 +133,9 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
     } else {
     } else {
       return this.state.envGroups
       return this.state.envGroups
         .filter((envGroup) => {
         .filter((envGroup) => {
+          if (!Array.isArray(this.props.syncedEnvGroups)) {
+            return true;
+          }
           return !this.props.syncedEnvGroups.find(
           return !this.props.syncedEnvGroups.find(
             (syncedEnvGroup) => syncedEnvGroup.name === envGroup.name
             (syncedEnvGroup) => syncedEnvGroup.name === envGroup.name
           );
           );
@@ -265,11 +269,11 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
                   />
                   />
                 </IconWrapper>
                 </IconWrapper>
               </>
               </>
-            ) : (
+            ) : !this.props.normalEnvVarsOnly ? (
               <Helper color="#f5cb42">
               <Helper color="#f5cb42">
                 Upgrade the job template to enable sync env groups
                 Upgrade the job template to enable sync env groups
               </Helper>
               </Helper>
-            )}
+            ) : null}
           </AbsoluteWrapper>
           </AbsoluteWrapper>
         </GroupModalSections>
         </GroupModalSections>
 
 

+ 47 - 13
dashboard/src/shared/api.tsx

@@ -2,7 +2,7 @@ import { PullRequest } from "main/home/cluster-dashboard/preview-environments/ty
 import { release } from "process";
 import { release } from "process";
 import { baseApi } from "./baseApi";
 import { baseApi } from "./baseApi";
 
 
-import { FullActionConfigType, StorageType } from "./types";
+import { BuildConfig, FullActionConfigType, StorageType } from "./types";
 
 
 /**
 /**
  * Generic api call format
  * Generic api call format
@@ -367,21 +367,15 @@ const deletePRDeployment = baseApi<
   {
   {
     cluster_id: number;
     cluster_id: number;
     project_id: number;
     project_id: number;
-    environment_id: number;
-    repo_owner: string;
-    repo_name: string;
-    pr_number: number;
+    deployment_id: number;
   }
   }
 >("DELETE", (pathParams) => {
 >("DELETE", (pathParams) => {
   const {
   const {
     cluster_id,
     cluster_id,
     project_id,
     project_id,
-    environment_id,
-    repo_owner,
-    repo_name,
-    pr_number,
+    deployment_id,
   } = pathParams;
   } = pathParams;
-  return `/api/projects/${project_id}/clusters/${cluster_id}/deployments/${environment_id}/${repo_owner}/${repo_name}/${pr_number}`;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/deployments/${deployment_id}`;
 });
 });
 
 
 const getNotificationConfig = baseApi<
 const getNotificationConfig = baseApi<
@@ -1699,6 +1693,20 @@ const upgradePorterAgent = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/agent/upgrade`
     `/api/projects/${project_id}/clusters/${cluster_id}/agent/upgrade`
 );
 );
 
 
+const updateBuildConfig = baseApi<
+  BuildConfig,
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    release_name: string;
+  }
+>(
+  "POST",
+  ({ project_id, cluster_id, namespace, release_name }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${release_name}/buildconfig`
+);
+
 const reRunGHWorkflow = baseApi<
 const reRunGHWorkflow = baseApi<
   {},
   {},
   {
   {
@@ -1707,12 +1715,37 @@ const reRunGHWorkflow = baseApi<
     git_installation_id: number;
     git_installation_id: number;
     owner: string;
     owner: string;
     name: string;
     name: string;
-    filename: string;
+    branch?: string;
+    filename?: string;
+    release_name?: string;
   }
   }
 >(
 >(
   "POST",
   "POST",
-  ({ project_id, git_installation_id, owner, name, cluster_id, filename }) =>
-    `/api/projects/${project_id}/gitrepos/${git_installation_id}/${owner}/${name}/clusters/${cluster_id}/rerun_workflow?filename=${filename}`
+  ({
+    project_id,
+    git_installation_id,
+    owner,
+    name,
+    cluster_id,
+    filename,
+    release_name,
+    branch,
+  }) => {
+    const queryParams = new URLSearchParams();
+
+    if (branch) {
+      queryParams.set("branch", branch);
+    }
+
+    if (release_name) {
+      queryParams.set("release_name", release_name);
+    }
+    if (filename) {
+      queryParams.set("filename", filename);
+    }
+
+    return `/api/projects/${project_id}/gitrepos/${git_installation_id}/${owner}/${name}/clusters/${cluster_id}/rerun_workflow?${queryParams.toString()}`;
+  }
 );
 );
 
 
 const triggerPreviewEnvWorkflow = baseApi<
 const triggerPreviewEnvWorkflow = baseApi<
@@ -1885,6 +1918,7 @@ export default {
   getIncidentLogsByLogId,
   getIncidentLogsByLogId,
   upgradePorterAgent,
   upgradePorterAgent,
   deletePRDeployment,
   deletePRDeployment,
+  updateBuildConfig,
   reRunGHWorkflow,
   reRunGHWorkflow,
   triggerPreviewEnvWorkflow,
   triggerPreviewEnvWorkflow,
 };
 };

+ 7 - 0
dashboard/src/shared/release/utils.ts

@@ -0,0 +1,7 @@
+import { ChartTypeWithExtendedConfig } from "shared/types";
+
+export const isDeployedFromGithub = (release: ChartTypeWithExtendedConfig) => {
+  const githubRepository = release?.git_action_config?.git_repo;
+
+  return !!githubRepository?.length;
+};

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

@@ -21,6 +21,7 @@ export interface DetailedIngressError {
 export interface ChartType {
 export interface ChartType {
   image_repo_uri: string;
   image_repo_uri: string;
   git_action_config: any;
   git_action_config: any;
+  build_config: BuildConfig;
   name: string;
   name: string;
   info: {
   info: {
     last_deployed: string;
     last_deployed: string;
@@ -72,6 +73,9 @@ export interface ChartTypeWithExtendedConfig extends ChartType {
         normal: {
         normal: {
           [key: string]: string;
           [key: string]: string;
         };
         };
+        build: {
+          [key: string]: string;
+        };
         synced: any;
         synced: any;
       };
       };
       lifecycle: { postStart: string; preStop: string };
       lifecycle: { postStart: string; preStop: string };
@@ -540,3 +544,11 @@ export type InfraCredentialOptions =
 export type InfraCredentials = {
 export type InfraCredentials = {
   [key in InfraCredentialOptions]?: number;
   [key in InfraCredentialOptions]?: number;
 };
 };
+
+export type BuildConfig = {
+  builder: string;
+  buildpacks: string[];
+  config: null | {
+    [key: string]: string;
+  };
+};

+ 1 - 0
docker/Dockerfile

@@ -13,6 +13,7 @@ COPY /internal ./internal
 COPY /api ./api
 COPY /api ./api
 COPY /scripts ./scripts
 COPY /scripts ./scripts
 COPY /provisioner ./provisioner
 COPY /provisioner ./provisioner
+COPY /pkg ./pkg
 
 
 RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26
 RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26
 RUN go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1
 RUN go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1

+ 1 - 0
docker/cli.Dockerfile

@@ -12,6 +12,7 @@ COPY Makefile .
 COPY /cli ./cli
 COPY /cli ./cli
 COPY /internal ./internal
 COPY /internal ./internal
 COPY /api ./api
 COPY /api ./api
+COPY /pkg ./pkg
 
 
 RUN --mount=type=cache,target=$GOPATH/pkg/mod \
 RUN --mount=type=cache,target=$GOPATH/pkg/mod \
     go mod download
     go mod download

+ 1 - 0
ee/docker/ee.Dockerfile

@@ -14,6 +14,7 @@ COPY /api ./api
 COPY /ee ./ee
 COPY /ee ./ee
 COPY /scripts ./scripts
 COPY /scripts ./scripts
 COPY /provisioner ./provisioner
 COPY /provisioner ./provisioner
+COPY /pkg ./pkg
 
 
 RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26
 RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26
 RUN go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1
 RUN go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1

+ 1 - 0
ee/docker/provisioner.Dockerfile

@@ -14,6 +14,7 @@ COPY /api ./api
 COPY /ee ./ee
 COPY /ee ./ee
 COPY /scripts ./scripts
 COPY /scripts ./scripts
 COPY /provisioner ./provisioner
 COPY /provisioner ./provisioner
+COPY /pkg ./pkg
 
 
 RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26
 RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26
 RUN go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1
 RUN go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1

+ 1 - 0
go.mod

@@ -118,6 +118,7 @@ require (
 	github.com/evanphx/json-patch v4.12.0+incompatible // indirect
 	github.com/evanphx/json-patch v4.12.0+incompatible // indirect
 	github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect
 	github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect
 	github.com/fsnotify/fsnotify v1.5.1 // indirect
 	github.com/fsnotify/fsnotify v1.5.1 // indirect
+	github.com/fvbommel/sortorder v1.0.1 // indirect
 	github.com/gdamore/encoding v1.0.0 // indirect
 	github.com/gdamore/encoding v1.0.0 // indirect
 	github.com/gdamore/tcell/v2 v2.4.0 // indirect
 	github.com/gdamore/tcell/v2 v2.4.0 // indirect
 	github.com/ghodss/yaml v1.0.0 // indirect
 	github.com/ghodss/yaml v1.0.0 // indirect

+ 1 - 0
go.sum

@@ -561,6 +561,7 @@ github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4
 github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI=
 github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI=
 github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
 github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
 github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA=
 github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA=
+github.com/fvbommel/sortorder v1.0.1 h1:dSnXLt4mJYH25uDDGa3biZNQsozaUWDSWeKJ0qqFfzE=
 github.com/fvbommel/sortorder v1.0.1/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0=
 github.com/fvbommel/sortorder v1.0.1/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0=
 github.com/gabriel-vasile/mimetype v1.1.2/go.mod h1:6CDPel/o/3/s4+bp6kIbsWATq8pmgOisOPG40CJa6To=
 github.com/gabriel-vasile/mimetype v1.1.2/go.mod h1:6CDPel/o/3/s4+bp6kIbsWATq8pmgOisOPG40CJa6To=
 github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
 github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=

+ 1 - 1
internal/analytics/segment.go

@@ -1,7 +1,7 @@
 package analytics
 package analytics
 
 
 import (
 import (
-	"github.com/porter-dev/porter/internal/logger"
+	"github.com/porter-dev/porter/pkg/logger"
 	segment "gopkg.in/segmentio/analytics-go.v3"
 	segment "gopkg.in/segmentio/analytics-go.v3"
 )
 )
 
 

+ 1 - 1
internal/helm/agent_test.go

@@ -7,7 +7,7 @@ import (
 
 
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/helm"
-	"github.com/porter-dev/porter/internal/logger"
+	"github.com/porter-dev/porter/pkg/logger"
 
 
 	"helm.sh/helm/v3/pkg/chart"
 	"helm.sh/helm/v3/pkg/chart"
 	"helm.sh/helm/v3/pkg/release"
 	"helm.sh/helm/v3/pkg/release"

+ 1 - 1
internal/helm/config.go

@@ -5,9 +5,9 @@ import (
 	"io/ioutil"
 	"io/ioutil"
 
 
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes"
-	"github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/pkg/logger"
 	"golang.org/x/oauth2"
 	"golang.org/x/oauth2"
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/chartutil"
 	"helm.sh/helm/v3/pkg/chartutil"

+ 1 - 1
internal/helm/storage.go

@@ -14,7 +14,7 @@ package helm
 // TODO -- include support for SQL storage...
 // TODO -- include support for SQL storage...
 
 
 import (
 import (
-	"github.com/porter-dev/porter/internal/logger"
+	"github.com/porter-dev/porter/pkg/logger"
 
 
 	"helm.sh/helm/v3/pkg/storage"
 	"helm.sh/helm/v3/pkg/storage"
 	"helm.sh/helm/v3/pkg/storage/driver"
 	"helm.sh/helm/v3/pkg/storage/driver"

+ 1 - 1
internal/helm/storage_test.go

@@ -9,7 +9,7 @@ import (
 
 
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes"
-	"github.com/porter-dev/porter/internal/logger"
+	"github.com/porter-dev/porter/pkg/logger"
 	"helm.sh/helm/v3/pkg/storage"
 	"helm.sh/helm/v3/pkg/storage"
 	"k8s.io/client-go/kubernetes/fake"
 	"k8s.io/client-go/kubernetes/fake"
 )
 )

+ 1 - 1
internal/models/gitrepo.go

@@ -61,7 +61,7 @@ func (r *GitActionConfig) ToGitActionConfigType() *types.GitActionConfig {
 		GitRepo:        r.GitRepo,
 		GitRepo:        r.GitRepo,
 		GitBranch:      r.GitBranch,
 		GitBranch:      r.GitBranch,
 		ImageRepoURI:   r.ImageRepoURI,
 		ImageRepoURI:   r.ImageRepoURI,
-		GitRepoID:      r.GithubInstallationID,
+		GitRepoID:      r.GitRepoID,
 		DockerfilePath: r.DockerfilePath,
 		DockerfilePath: r.DockerfilePath,
 		FolderPath:     r.FolderPath,
 		FolderPath:     r.FolderPath,
 	}
 	}

+ 0 - 0
internal/logger/logger.go → pkg/logger/logger.go


+ 1 - 1
provisioner/server/config/config.go

@@ -16,10 +16,10 @@ import (
 	klocal "github.com/porter-dev/porter/internal/kubernetes/local"
 	klocal "github.com/porter-dev/porter/internal/kubernetes/local"
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/oauth"
 
 
-	"github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository/credentials"
 	"github.com/porter-dev/porter/internal/repository/credentials"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 	"github.com/porter-dev/porter/internal/repository/gorm"
+	"github.com/porter-dev/porter/pkg/logger"
 	"github.com/porter-dev/porter/provisioner/integrations/provisioner"
 	"github.com/porter-dev/porter/provisioner/integrations/provisioner"
 	"github.com/porter-dev/porter/provisioner/integrations/provisioner/k8s"
 	"github.com/porter-dev/porter/provisioner/integrations/provisioner/k8s"
 	"github.com/porter-dev/porter/provisioner/integrations/provisioner/local"
 	"github.com/porter-dev/porter/provisioner/integrations/provisioner/local"

+ 1 - 0
services/migrator/Dockerfile

@@ -12,6 +12,7 @@ COPY /cmd ./cmd
 COPY /internal ./internal
 COPY /internal ./internal
 COPY /api ./api
 COPY /api ./api
 COPY /ee ./ee
 COPY /ee ./ee
+COPY /pkg ./pkg
 
 
 RUN --mount=type=cache,target=$GOPATH/pkg/mod \
 RUN --mount=type=cache,target=$GOPATH/pkg/mod \
     go mod download
     go mod download

+ 1 - 0
services/porter_cli_container/dev.Dockerfile

@@ -12,6 +12,7 @@ COPY /cli ./cli
 COPY /internal ./internal
 COPY /internal ./internal
 COPY /api ./api
 COPY /api ./api
 COPY /ee ./ee
 COPY /ee ./ee
+COPY /pkg ./pkg
 
 
 RUN --mount=type=cache,target=$GOPATH/pkg/mod \
 RUN --mount=type=cache,target=$GOPATH/pkg/mod \
     go mod download
     go mod download