Răsfoiți Sursa

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

jnfrati 4 ani în urmă
părinte
comite
30211f899e
63 a modificat fișierele cu 2221 adăugiri și 507 ștergeri
  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-region: ${{ secrets.AWS_REGION }}
       - 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
         run: gcloud auth configure-docker
       - name: Checkout
@@ -66,7 +68,9 @@ jobs:
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-region: ${{ secrets.AWS_REGION }}
       - 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
         run: gcloud auth configure-docker
       - name: Checkout

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

@@ -20,7 +20,9 @@ jobs:
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-region: ${{ secrets.AWS_REGION }}
       - 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
         run: gcloud auth configure-docker
       - name: Checkout
@@ -73,7 +75,9 @@ jobs:
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-region: ${{ secrets.AWS_REGION }}
       - 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
         run: gcloud auth configure-docker
       - name: Checkout

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

@@ -20,7 +20,9 @@ jobs:
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-region: ${{ secrets.AWS_REGION }}
       - 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
         run: gcloud auth configure-docker
       - name: Checkout
@@ -72,7 +74,9 @@ jobs:
           aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
           aws-region: ${{ secrets.AWS_REGION }}
       - 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
         run: gcloud auth configure-docker
       - 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/porter-dev/porter/api/server/authz"
 	"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/apierrors"
 	"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)
 	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 {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(reqErr))
@@ -74,7 +47,7 @@ func (c *DeleteDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	}
 
 	// 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 {
 		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)
 
 	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/shared"
 	"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/types"
 	"github.com/porter-dev/porter/internal/models"
@@ -140,23 +141,17 @@ func updateDeploymentWithGithubWorkflowRunStatus(
 	env *models.Environment,
 	deployment *types.Deployment,
 ) {
+	if deployment.Status == types.DeploymentStatusInactive {
+		return
+	}
+
 	client, err := getGithubClientFromEnvironment(config, env)
 
 	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()
 
 			if (latestWorkflowRun.GetStatus() == "in_progress" ||

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

@@ -1,7 +1,6 @@
 package environment
 
 import (
-	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -11,6 +10,7 @@ import (
 	"github.com/porter-dev/porter/api/server/handlers"
 	"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/commonutils"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
@@ -69,8 +69,8 @@ func (c *TriggerDeploymentWorkflowHandler) ServeHTTP(w http.ResponseWriter, r *h
 		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) {
 		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))
 		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
 
 import (
-	"context"
 	"errors"
 	"fmt"
 	"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/shared"
 	"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"
 )
 
-var ErrNoWorkflowRuns = errors.New("no previous workflow runs found")
-
 type RerunWorkflowHandler struct {
 	handlers.PorterHandlerReadWriter
 }
@@ -37,10 +35,28 @@ func (c *RerunWorkflowHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	}
 
 	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 == "" {
-		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)
@@ -50,11 +66,15 @@ func (c *RerunWorkflowHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		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))
 		return
+	} else if err != nil && errors.Is(err, commonutils.ErrWorkflowNotFound) {
+		w.WriteHeader(http.StatusNotFound)
+		c.WriteResult(w, r, filename)
+		return
 	} else if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
@@ -73,7 +93,7 @@ func (c *RerunWorkflowHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	latestWorkflowRun, err = getLatestWorkflowRun(client, owner, name, filename)
+	latestWorkflowRun, err = commonutils.GetLatestWorkflowRun(client, owner, name, filename, branch)
 
 	if err != nil {
 		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())
 }
-
-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"
       settings:
         default: 10
+- name: advanced
+  label: Advanced
+  sections:
+  - name: spot_instance_should_enable
+    contents:
+    - type: heading
+      label: Spot Instance Settings
     - type: checkbox
       variable: spot_instances_enabled
       label: Enable spot instances for this cluster.
@@ -399,6 +406,16 @@ tabs:
       label: Assign a bid price for the spot instance (optional).
       variable: spot_price
       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

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

@@ -463,20 +463,15 @@ func getClusterRoutes(
 			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
 		deleteDeploymentEndpoint := factory.NewAPIEndpoint(
 			&types.APIRequestMetadata{
 				Verb:   types.APIVerbDelete,
 				Method: types.HTTPVerbDelete,
 				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{
 					types.UserScope,

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

@@ -7,7 +7,7 @@ import (
 	"net/http"
 	"time"
 
-	"github.com/porter-dev/porter/internal/logger"
+	"github.com/porter-dev/porter/pkg/logger"
 )
 
 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/types"
-	"github.com/porter-dev/porter/internal/logger"
+	"github.com/porter-dev/porter/pkg/logger"
 )
 
 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/token"
 	"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/pkg/logger"
 )
 
 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/helm/urlcache"
 	"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/oauth"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository/credentials"
+	"github.com/porter-dev/porter/pkg/logger"
 	"github.com/porter-dev/porter/provisioner/client"
 	"golang.org/x/oauth2"
 	"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/provisioner/client"
 
-	lr "github.com/porter-dev/porter/internal/logger"
+	lr "github.com/porter-dev/porter/pkg/logger"
 
 	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/alerter"
 	"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 {

+ 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/alerter"
-	"github.com/porter-dev/porter/internal/logger"
+	"github.com/porter-dev/porter/pkg/logger"
 )
 
 type ResultWriter interface {

+ 65 - 21
cli/cmd/apply.go

@@ -175,8 +175,6 @@ type ApplicationConfig struct {
 	OnlyCreate bool
 
 	Build struct {
-		ForceBuild bool `mapstructure:"force_build"`
-		ForcePush  bool `mapstructure:"force_push"`
 		UseCache   bool `mapstructure:"use_cache"`
 		Method     string
 		Context    string
@@ -184,6 +182,7 @@ type ApplicationConfig struct {
 		Image      string
 		Builder    string
 		Buildpacks []string
+		Env        map[string]string
 	}
 
 	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) {
 		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{
-			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 {
-			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
@@ -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 {
@@ -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) {
 	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{
 		SharedOpts: sharedOpts,
 		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 {
 			return nil, err
 		}
 
 		if !appConf.Build.UseCache {
-			err = updateAgent.Push(appConf.Build.ForcePush)
+			err = updateAgent.Push()
 
 			if err != nil {
 				return nil, err
@@ -775,7 +783,43 @@ func (t *DeploymentHook) DataQueries() map[string]interface{} {
 		}
 
 		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,
 		"Whether to use cache (currently in beta)",
 	)
+
+	createCmd.PersistentFlags().MarkDeprecated("force-build", "--force-build is deprecated")
 }
 
 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)
 	} 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() {
+	deleteCmd.PersistentFlags().StringVar(
+		&namespace,
+		"namespace",
+		"default",
+		"Namespace of the application",
+	)
+
+	deleteCmd.AddCommand(deleteAppsCmd)
+	deleteCmd.AddCommand(deleteJobsCmd)
+	deleteCmd.AddCommand(deleteAddonsCmd)
+
 	rootCmd.AddCommand(deleteCmd)
 }
 
@@ -90,3 +146,93 @@ func delete(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []st
 		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/deploy"
 	"github.com/porter-dev/porter/cli/cmd/utils"
+	templaterUtils "github.com/porter-dev/porter/internal/templater/utils"
 	"github.com/spf13/cobra"
 	"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)",
 	)
 
+	updateCmd.PersistentFlags().MarkDeprecated("force-build", "--force-build is now deprecated")
+
+	updateCmd.PersistentFlags().MarkDeprecated("force-push", "--force-push is now deprecated")
+
 	updateCmd.AddCommand(updateGetEnvCmd)
 
 	updateGetEnvCmd.PersistentFlags().StringVar(
@@ -530,7 +535,7 @@ func updateBuildWithAgent(updateAgent *deploy.DeployAgent) error {
 		return err
 	}
 
-	if err := updateAgent.Build(nil, forceBuild); err != nil {
+	if err := updateAgent.Build(nil); err != nil {
 		if stream {
 			updateAgent.StreamEvent(types.SubEvent{
 				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 {
 			updateAgent.StreamEvent(types.SubEvent{
 				EventID: "push",
@@ -637,6 +642,53 @@ func updateUpgradeWithAgent(updateAgent *deploy.DeployAgent) error {
 		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)
 
 	if err != nil {

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

@@ -219,7 +219,6 @@ func (c *CreateAgent) CreateFromDocker(
 	overrideValues map[string]interface{},
 	imageTag string,
 	extraBuildConfig *types.BuildConfig,
-	forceBuild bool,
 ) (string, error) {
 	opts := c.CreateOpts
 
@@ -273,66 +272,78 @@ func (c *CreateAgent) CreateFromDocker(
 		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 {
 			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
 	if c.CreateOpts.Kind == "web" {
 		// look for ingress.enabled and no custom domains set
-		ingressMap, err := getNestedMap(mergedValues, "ingress")
+		ingressMap, err := GetNestedMap(mergedValues, "ingress")
 
 		if err == nil {
 			enabledVal, enabledExists := ingressMap["enabled"]
@@ -517,33 +528,50 @@ func (c *CreateAgent) CreateSubdomainIfRequired(mergedValues map[string]interfac
 				enabled, eOK := enabledVal.(bool)
 				customDomain, cOK := customDomVal.(bool)
 
-				// in the case of ingress enabled but no custom domain, create subdomain
-				if eOK && cOK && enabled && !customDomain {
-					dnsRecord, err := c.Client.CreateDNSRecord(
-						context.Background(),
-						c.CreateOpts.ProjectID,
-						c.CreateOpts.ClusterID,
-						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 {
-						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
 
 	Client         *client.Client
-	release        *types.GetReleaseResponse
+	Opts           *DeployOpts
+	Release        *types.GetReleaseResponse
 	agent          *docker.Agent
-	opts           *DeployOpts
 	tag            string
 	envPrefix      string
 	env            map[string]string
@@ -56,7 +56,7 @@ type DeployOpts struct {
 func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*DeployAgent, error) {
 	deployAgent := &DeployAgent{
 		App:    app,
-		opts:   opts,
+		Opts:   opts,
 		Client: client,
 		env:    make(map[string]string),
 	}
@@ -68,7 +68,7 @@ func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*Deplo
 		return nil, err
 	}
 
-	deployAgent.release = release
+	deployAgent.Release = release
 
 	// set an environment prefix to avoid collisions
 	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
 			// is docker
 			if release.GitActionConfig.DockerfilePath != "" {
-				deployAgent.opts.Method = DeployBuildTypeDocker
+				deployAgent.Opts.Method = DeployBuildTypeDocker
 			} else {
 				// otherwise build type is pack
-				deployAgent.opts.Method = DeployBuildTypePack
+				deployAgent.Opts.Method = DeployBuildTypePack
 			}
 		} else {
 			// 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 {
 			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"
 		}
 	}
@@ -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
 	// helm values
 	if release.GitActionConfig == nil {
-		deployAgent.opts.Local = true
+		deployAgent.Opts.Local = true
 
 		imageRepo, err := deployAgent.getReleaseImage()
 
@@ -129,16 +129,16 @@ func NewDeployAgent(client *client.Client, app string, opts *DeployOpts) (*Deplo
 
 		deployAgent.imageRepo = imageRepo
 
-		deployAgent.dockerfilePath = deployAgent.opts.LocalDockerfile
+		deployAgent.dockerfilePath = deployAgent.Opts.LocalDockerfile
 	} else {
 		deployAgent.imageRepo = release.GitActionConfig.ImageRepoURI
-		deployAgent.opts.LocalPath = release.GitActionConfig.FolderPath
+		deployAgent.Opts.LocalPath = release.GitActionConfig.FolderPath
 	}
 
 	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)
 
@@ -150,24 +150,48 @@ type GetBuildEnvOpts struct {
 	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) {
-	conf := d.release.Config
+	conf := d.Release.Config
 
 	if opts.UseNewConfig {
 		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 {
 		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
-	for key, val := range d.opts.SharedOpts.AdditionalEnv {
+	for key, val := range d.Opts.SharedOpts.AdditionalEnv {
 		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
 // 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
-	currImageSection := d.release.Config["image"].(map[string]interface{})
+	currImageSection := d.Release.Config["image"].(map[string]interface{})
 	currentTag := currImageSection["tag"].(string)
 
 	if d.tag == "" {
 		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
 	var basePath string
 	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 {
 			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(
 			context.Background(),
-			d.opts.ProjectID,
-			int64(d.release.GitActionConfig.GitRepoID),
+			d.Opts.ProjectID,
+			int64(d.Release.GitActionConfig.GitRepoID),
 			"github",
 			repoSplit[0],
 			repoSplit[1],
-			d.release.GitActionConfig.GitBranch,
+			d.Release.GitActionConfig.GitBranch,
 		)
 
 		if err != nil {
@@ -291,14 +308,14 @@ func (d *DeployAgent) Build(overrideBuildConfig *types.BuildConfig, forceBuild b
 	}
 
 	buildAgent := &BuildAgent{
-		SharedOpts:  d.opts.SharedOpts,
+		SharedOpts:  d.Opts.SharedOpts,
 		APIClient:   d.Client,
 		ImageRepo:   d.imageRepo,
 		Env:         d.env,
 		ImageExists: d.imageExists,
 	}
 
-	if d.opts.Method == DeployBuildTypeDocker {
+	if d.Opts.Method == DeployBuildTypeDocker {
 		return buildAgent.BuildDocker(
 			d.agent,
 			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 {
 		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
-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))
 }
 
@@ -335,11 +347,11 @@ func (d *DeployAgent) Push(forcePush bool) 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
 	// 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
 	}
 
-	mergedValues := utils.CoalesceValues(d.release.Config, overrideValues)
+	mergedValues := utils.CoalesceValues(d.Release.Config, overrideValues)
 
 	activeBlueGreenTagVal := GetCurrActiveBlueGreenImage(mergedValues)
 
@@ -385,10 +397,10 @@ func (d *DeployAgent) UpdateImageAndValues(overrideValues map[string]interface{}
 
 	return d.Client.UpgradeRelease(
 		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{
 			Values: string(bytes),
 		},
@@ -407,12 +419,51 @@ type SyncedEnvSectionKey struct {
 }
 
 // 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)
 
 	// 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 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
 		// run-time, so we ignore it
-		if !strings.Contains(valStr, "PORTERSECRET") {
+		if buildTime && strings.Contains(valStr, "PORTERSECRET") {
+			continue
+		} else {
 			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 err != nil {
@@ -542,7 +605,9 @@ func GetEnvForRelease(client *client.Client, config map[string]interface{}, proj
 			}
 
 			for key, val := range eg.Variables {
-				if !strings.Contains(val, "PORTERSECRET") {
+				if buildTime && strings.Contains(val, "PORTERSECRET") {
+					continue
+				} else {
 					res[key] = val
 				}
 			}
@@ -553,12 +618,12 @@ func GetEnvForRelease(client *client.Client, config map[string]interface{}, proj
 }
 
 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
-	imageConfig, err := getNestedMap(d.release.Config, "image")
+	imageConfig, err := GetNestedMap(d.Release.Config, "image")
 
 	if err != nil {
 		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) {
 	// 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 {
 		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{
 		ZipFolderDest:       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,
 	}
 
@@ -637,7 +702,7 @@ func (d *DeployAgent) downloadRepoToDir(downloadURL string) (string, error) {
 	dstFiles, err := ioutil.ReadDir(dstDir)
 
 	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())
 		}
 	}
@@ -652,8 +717,8 @@ func (d *DeployAgent) downloadRepoToDir(downloadURL string) (string, error) {
 func (d *DeployAgent) StreamEvent(event types.SubEvent) error {
 	return d.Client.CreateEvent(
 		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{
 			Event: event,
 		},
@@ -668,7 +733,7 @@ func (e *NestedMapFieldNotFoundError) Error() string {
 	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{}
 	curr := obj
 

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

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

+ 73 - 34
cli/cmd/list.go

@@ -6,18 +6,27 @@ import (
 	"os"
 	"text/tabwriter"
 
+	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/api/types"
 	"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{
 	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{
@@ -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() {
 	listCmd.PersistentFlags().StringVar(
 		&namespace,
@@ -56,37 +78,52 @@ func init() {
 
 	listCmd.AddCommand(listAppsCmd)
 	listCmd.AddCommand(listJobsCmd)
+	listCmd.AddCommand(listAddonsCmd)
 
 	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 {
 		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
 }
 
 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{
 		ReleaseListFilter: &types.ReleaseListFilter{
 			Limit: 50,
@@ -107,24 +144,26 @@ func listJobs(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []
 		return err
 	}
 
-	writeReleases("job", releases)
-
-	return nil
-}
-
-func writeReleases(kind string, releases []*release.Release) {
 	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 {
-		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()
+
+	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 {
 	Build struct {
-		ForceBuild   bool `mapstructure:"force_build"`
 		UsePackCache bool `mapstructure:"use_pack_cache"`
 		Method       string
 		Context      string
@@ -28,6 +27,7 @@ type BuildDriverConfig struct {
 		Builder      string
 		Buildpacks   []string
 		Image        string
+		Env          map[string]string
 	}
 
 	EnvGroups []types.EnvGroupMeta `mapstructure:"env_groups"`
@@ -231,74 +231,90 @@ func (d *BuildDriver) Apply(resource *models.Resource) (*models.Resource, error)
 		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 {
 			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)

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

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

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

@@ -1,20 +1,19 @@
 package preview
 
 import (
-	"math/rand"
-	"time"
+	"crypto/rand"
 
 	"github.com/mitchellh/mapstructure"
 	"github.com/porter-dev/switchboard/pkg/drivers"
 	"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 {
-	Length uint
+	Length int
+	Lower  bool
 }
 
 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) {
-	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
 }
@@ -58,10 +63,12 @@ func (d *RandomStringDriver) Output() (map[string]interface{}, error) {
 	return d.output, nil
 }
 
-func randomString(length uint) string {
+func randomString(length int, charset string) string {
+	ll := len(charset)
 	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)
 }

+ 15 - 2
cli/cmd/run.go

@@ -498,8 +498,21 @@ func checkForPodDeletionCronJob(config *PorterRunSharedConfig) error {
 			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 {
-			if cronJob.Name == "porter-ephemeral-pod-deletion-cronjob" {
+			if namespace.Name == "default" && cronJob.Name == "porter-ephemeral-pod-deletion-cronjob" {
 				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{},
 	)
 	if err != nil {

+ 1 - 1
cmd/migrate/main.go

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

+ 1 - 1
cmd/ready/main.go

@@ -6,7 +6,7 @@ import (
 	"os"
 
 	"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() {

+ 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 loading from "assets/loading.gif";
-import MultiSelect from "./porter-form/field-components/MultiSelect";
 import Description from "./Description";
 
 type MultiSelectOption = {
@@ -27,12 +26,11 @@ type Props = {
   // Provide the classname to modify styles from other components
   className?: string;
   successText?: string;
+  expandTo?: OptionsWrapperProps["expandTo"];
 };
 
 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);
 
@@ -86,6 +84,7 @@ const MultiSaveButton: React.FC<Props> = (props) => {
         <>
           <DropdownOverlay onClick={() => setIsDropdownExpanded(false)} />
           <OptionWrapper
+            expandTo={props.expandTo || "right"}
             dropdownWidth="400px"
             dropdownMaxHeight="300px"
             onClick={() => setIsDropdownExpanded(false)}
@@ -102,8 +101,8 @@ const MultiSaveButton: React.FC<Props> = (props) => {
       return (
         <Option
           key={i}
-          selected={option.text === currOption.text}
-          onClick={() => setCurrOption(option)}
+          selected={option.text === originalArray[currOptionIndex]?.text}
+          onClick={() => setCurrOptionIndex(i)}
           lastItem={i === originalArray.length - 1}
         >
           {option.text}
@@ -126,10 +125,10 @@ const MultiSaveButton: React.FC<Props> = (props) => {
         <Button
           rounded={props.rounded}
           disabled={props.disabled}
-          onClick={currOption.onClick}
+          onClick={props.options[currOptionIndex]?.onClick}
           color={props.color || "#5561C0"}
         >
-          {currOption.text}
+          {props.options[currOptionIndex]?.text}
         </Button>
         <DropdownButton
           disabled={props.disabled}
@@ -165,12 +164,13 @@ const StatusTextWrapper = styled.p`
   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;
   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;
   align-items: center;
   font-family: "Work Sans", sans-serif;
@@ -239,11 +239,13 @@ const ButtonWrapper = styled.div`
   }}
 `;
 
-const Button = styled.button<{
+type ButtonProps = {
   disabled: boolean;
   color: string;
   rounded: boolean;
-}>`
+};
+
+const Button = styled.button<ButtonProps>`
   height: 35px;
   font-size: 13px;
   font-weight: 500;
@@ -321,15 +323,19 @@ const DropdownOverlay = styled.div`
   cursor: default;
 `;
 
-const OptionWrapper = styled.div`
+type OptionsWrapperProps = {
+  expandTo: "left" | "right";
+  dropdownWidth: string;
+  dropdownMaxHeight: string;
+};
+
+const OptionWrapper = styled.div<OptionsWrapperProps>`
   position: absolute;
-  left: 0;
+  ${(props) => (props.expandTo === "right" ? "left: 0" : "right: 0")};
   top: calc(100% + 10px);
   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;
   z-index: 999;
   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();
                   this.props.setValues(obj);
                 }}
-                disabled={this.props.disabled || value.includes("PORTERSECRET")}
+                disabled={
+                  this.props.disabled || value?.includes("PORTERSECRET")
+                }
                 spellCheck={false}
               />
               <Spacer />
@@ -145,12 +147,14 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
                   let obj = this.valuesToObject();
                   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}
               />
               {this.renderDeleteButton(i)}
-              {this.renderHiddenOption(value.includes("PORTERSECRET"), i)}
+              {this.renderHiddenOption(value?.includes("PORTERSECRET"), i)}
             </InputWrapper>
           );
         })}
@@ -176,6 +180,7 @@ export default class KeyValueArray extends Component<PropsType, StateType> {
               this.props.setValues(newValues);
               this.setState({ values: this.objectToValues(newValues) });
             }}
+            normalEnvVarsOnly
           />
         </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";
 
 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(
     id,
@@ -35,6 +35,7 @@ const CronInput: React.FC<CronField> = (props) => {
         label={label}
         placeholder={placeholder}
         value={variables[variable]}
+        disabled={isReadOnly}
         setValue={(x: string) => {
           setVars((vars) => {
             return {

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

@@ -31,7 +31,7 @@ const KeyValueArray: React.FC<Props> = (props) => {
       initState: () => {
         let values = props.value[0];
         const normalValues = Object.entries(values?.normal || {});
-        values = omit(values, ["normal", "synced"]);
+        values = omit(values, ["normal", "synced", "build"]);
         return {
           values: hasSetValue(props)
             ? ([...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) {
       obj.synced = state.synced_env_groups.map((envGroup) => ({
         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 }) => {
   const [buildpackUrl, setBuildpackUrl] = useState("");
@@ -324,6 +324,7 @@ const AddCustomBuildpackForm: React.FC<{
       name: buildpackUrl,
       config: null,
     };
+    setBuildpackUrl("");
     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 { onlyInLeft } from "shared/array_utils";
 import IncidentsTab from "./incidents/IncidentsTab";
+import BuildSettingsTab from "./BuildSettingsTab";
 
 type Props = {
   namespace: string;
@@ -515,6 +516,8 @@ const ExpandedChart: React.FC<Props> = (props) => {
             disabled={!isAuthorized("application", "", ["get", "update"])}
           />
         );
+      case "build-settings":
+        return <BuildSettingsTab chart={chart} isPreviousVersion={isPreview} />;
       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
     if (isAuthorized("application", "", ["get", "delete"])) {
       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 = () => {
     if (url) {
@@ -836,7 +846,6 @@ const ExpandedChart: React.FC<Props> = (props) => {
                 setRevision={setRevision}
                 forceRefreshRevisions={forceRefreshRevisions}
                 refreshRevisionsOff={() => setForceRefreshRevisions(false)}
-                status={chartStatus}
                 shouldUpdate={
                   currentChart.latest_version &&
                   currentChart.latest_version !==
@@ -855,6 +864,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
                     }}
                     renderTabContents={renderTabContents}
                     isReadOnly={
+                      isPreview ||
                       imageIsPlaceholder ||
                       !isAuthorized("application", "", ["get", "update"])
                     }
@@ -939,7 +949,6 @@ const LineBreak = styled.div`
 
 const BodyWrapper = styled.div`
   position: relative;
-  overflow: hidden;
   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 CronParser from "cron-parser";
 import CronPrettifier from "cronstrue";
+import BuildSettingsTab from "./BuildSettingsTab";
 
 const readableDate = (s: string) => {
   let ts = new Date(s);
@@ -84,6 +85,13 @@ export const ExpandedJobChartFC: React.FC<{
     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 processValuesToUpdateChart = (newConfig?: any) => (
@@ -152,6 +160,17 @@ export const ExpandedJobChartFC: React.FC<{
       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") {
       return (
         <TabWrapper>
@@ -186,11 +205,7 @@ export const ExpandedJobChartFC: React.FC<{
           {chart?.config?.schedule?.enabled ? (
             <RunsDescription>
               <i className="material-icons">access_time</i>
-              Runs{" "}
-              {CronPrettifier.toString(
-                chart?.config?.schedule.value
-              ).toLowerCase()}{" "}
-              UTC
+              {runDescription}
               <Dot
                 style={{
                   color: "#ffffff88",
@@ -234,6 +249,10 @@ export const ExpandedJobChartFC: React.FC<{
       );
     }
 
+    if (currentTab === "build-settings") {
+      return <BuildSettingsTab chart={chart} />;
+    }
+
     if (
       currentTab === "settings" &&
       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 NotificationSettingsSection from "./NotificationSettingsSection";
 import { Link } from "react-router-dom";
+import { isDeployedFromGithub } from "shared/release/utils";
 
 type PropsType = {
   currentChart: ChartType;
@@ -212,33 +213,35 @@ const SettingsSection: React.FC<PropsType> = ({
 
     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>

+ 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,
           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(() => {

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

@@ -30,6 +30,7 @@ type PropsType = {
   enableSyncedEnvGroups?: boolean;
   syncedEnvGroups?: PopulatedEnvGroup[];
   setSyncedEnvGroups?: (values: PopulatedEnvGroup) => void;
+  normalEnvVarsOnly?: boolean;
 };
 
 type StateType = {
@@ -132,6 +133,9 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
     } else {
       return this.state.envGroups
         .filter((envGroup) => {
+          if (!Array.isArray(this.props.syncedEnvGroups)) {
+            return true;
+          }
           return !this.props.syncedEnvGroups.find(
             (syncedEnvGroup) => syncedEnvGroup.name === envGroup.name
           );
@@ -265,11 +269,11 @@ export default class LoadEnvGroupModal extends Component<PropsType, StateType> {
                   />
                 </IconWrapper>
               </>
-            ) : (
+            ) : !this.props.normalEnvVarsOnly ? (
               <Helper color="#f5cb42">
                 Upgrade the job template to enable sync env groups
               </Helper>
-            )}
+            ) : null}
           </AbsoluteWrapper>
         </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 { baseApi } from "./baseApi";
 
-import { FullActionConfigType, StorageType } from "./types";
+import { BuildConfig, FullActionConfigType, StorageType } from "./types";
 
 /**
  * Generic api call format
@@ -367,21 +367,15 @@ const deletePRDeployment = baseApi<
   {
     cluster_id: number;
     project_id: number;
-    environment_id: number;
-    repo_owner: string;
-    repo_name: string;
-    pr_number: number;
+    deployment_id: number;
   }
 >("DELETE", (pathParams) => {
   const {
     cluster_id,
     project_id,
-    environment_id,
-    repo_owner,
-    repo_name,
-    pr_number,
+    deployment_id,
   } = 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<
@@ -1699,6 +1693,20 @@ const upgradePorterAgent = baseApi<
     `/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<
   {},
   {
@@ -1707,12 +1715,37 @@ const reRunGHWorkflow = baseApi<
     git_installation_id: number;
     owner: string;
     name: string;
-    filename: string;
+    branch?: string;
+    filename?: string;
+    release_name?: string;
   }
 >(
   "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<
@@ -1885,6 +1918,7 @@ export default {
   getIncidentLogsByLogId,
   upgradePorterAgent,
   deletePRDeployment,
+  updateBuildConfig,
   reRunGHWorkflow,
   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 {
   image_repo_uri: string;
   git_action_config: any;
+  build_config: BuildConfig;
   name: string;
   info: {
     last_deployed: string;
@@ -72,6 +73,9 @@ export interface ChartTypeWithExtendedConfig extends ChartType {
         normal: {
           [key: string]: string;
         };
+        build: {
+          [key: string]: string;
+        };
         synced: any;
       };
       lifecycle: { postStart: string; preStop: string };
@@ -540,3 +544,11 @@ export type InfraCredentialOptions =
 export type InfraCredentials = {
   [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 /scripts ./scripts
 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/grpc/cmd/protoc-gen-go-grpc@v1.1

+ 1 - 0
docker/cli.Dockerfile

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

+ 1 - 0
ee/docker/ee.Dockerfile

@@ -14,6 +14,7 @@ COPY /api ./api
 COPY /ee ./ee
 COPY /scripts ./scripts
 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/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 /scripts ./scripts
 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/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/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // 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/tcell/v2 v2.4.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/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
 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/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=

+ 1 - 1
internal/analytics/segment.go

@@ -1,7 +1,7 @@
 package analytics
 
 import (
-	"github.com/porter-dev/porter/internal/logger"
+	"github.com/porter-dev/porter/pkg/logger"
 	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/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/release"

+ 1 - 1
internal/helm/config.go

@@ -5,9 +5,9 @@ import (
 	"io/ioutil"
 
 	"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/repository"
+	"github.com/porter-dev/porter/pkg/logger"
 	"golang.org/x/oauth2"
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/chartutil"

+ 1 - 1
internal/helm/storage.go

@@ -14,7 +14,7 @@ package helm
 // TODO -- include support for SQL storage...
 
 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/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/kubernetes"
-	"github.com/porter-dev/porter/internal/logger"
+	"github.com/porter-dev/porter/pkg/logger"
 	"helm.sh/helm/v3/pkg/storage"
 	"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,
 		GitBranch:      r.GitBranch,
 		ImageRepoURI:   r.ImageRepoURI,
-		GitRepoID:      r.GithubInstallationID,
+		GitRepoID:      r.GitRepoID,
 		DockerfilePath: r.DockerfilePath,
 		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"
 	"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/credentials"
 	"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/k8s"
 	"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 /api ./api
 COPY /ee ./ee
+COPY /pkg ./pkg
 
 RUN --mount=type=cache,target=$GOPATH/pkg/mod \
     go mod download

+ 1 - 0
services/porter_cli_container/dev.Dockerfile

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