浏览代码

Merge branch 'master' into nafees/pr-env-toggle

Mohammed Nafees 3 年之前
父节点
当前提交
b549bbffd4
共有 76 个文件被更改,包括 3653 次插入699 次删除
  1. 15 1
      .github/workflows/release.yaml
  2. 37 6
      api/client/environment.go
  3. 131 0
      api/server/handlers/cluster_integration/aws/get_cluster_info.go
  4. 1 1
      api/server/handlers/environment/create_deployment.go
  5. 41 5
      api/server/handlers/environment/finalize_deployment.go
  6. 166 0
      api/server/handlers/environment/finalize_deployment_with_errors.go
  7. 16 9
      api/server/handlers/environment/get_deployment_by_env.go
  8. 1 1
      api/server/handlers/environment/list.go
  9. 2 0
      api/server/handlers/environment/list_deployments_by_cluster.go
  10. 6 0
      api/server/handlers/environment/update_deployment_status.go
  11. 54 0
      api/server/handlers/infra/forms.go
  12. 57 0
      api/server/handlers/release/update_git_action_config.go
  13. 17 0
      api/server/handlers/release/update_rollback.go
  14. 49 0
      api/server/handlers/status/github.go
  15. 135 35
      api/server/handlers/webhook/github_incoming.go
  16. 3 3
      api/server/router/cluster.go
  17. 86 0
      api/server/router/cluster_integration.go
  18. 37 1
      api/server/router/git_installation.go
  19. 31 0
      api/server/router/release.go
  20. 4 2
      api/server/router/router.go
  21. 83 0
      api/server/router/status.go
  22. 16 0
      api/types/cluster_integration.go
  23. 16 2
      api/types/environment.go
  24. 2 0
      api/types/infra.go
  25. 10 0
      api/types/release.go
  26. 9 6
      api/types/stacks.go
  27. 123 29
      cli/cmd/apply.go
  28. 18 2
      cli/cmd/docker/builder.go
  29. 51 16
      cli/cmd/list.go
  30. 52 25
      dashboard/src/components/image-selector/ImageList.tsx
  31. 51 10
      dashboard/src/components/image-selector/ImageSelector.tsx
  32. 17 7
      dashboard/src/components/image-selector/TagList.tsx
  33. 16 5
      dashboard/src/components/repo-selector/BranchList.tsx
  34. 39 2
      dashboard/src/components/repo-selector/RepoList.tsx
  35. 54 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/BuildSettingsTab.tsx
  36. 5 12
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  37. 1 7
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/useJobs.ts
  38. 80 10
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/PreviewEnvironmentsHeader.tsx
  39. 1 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentDetail.tsx
  40. 0 153
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack.tsx
  41. 243 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx
  42. 293 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_RevisionList.tsx
  43. 180 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_SourceConfig.tsx
  44. 264 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/Select.tsx
  45. 328 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/SourceEditorDocker.tsx
  46. 25 9
      dashboard/src/main/home/cluster-dashboard/stacks/_StackList.tsx
  47. 33 0
      dashboard/src/main/home/cluster-dashboard/stacks/components/styles.ts
  48. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/routes.tsx
  49. 7 4
      dashboard/src/main/home/cluster-dashboard/stacks/types.ts
  50. 4 4
      dashboard/src/main/home/infrastructure/InfrastructureList.tsx
  51. 34 62
      dashboard/src/main/home/sidebar/Sidebar.tsx
  52. 57 8
      dashboard/src/shared/api.tsx
  53. 9 0
      dashboard/src/shared/common.tsx
  54. 1 0
      dashboard/src/shared/types.tsx
  55. 98 71
      go.mod
  56. 308 0
      go.sum
  57. 17 12
      internal/helm/agent.go
  58. 1 1
      internal/helm/config.go
  59. 3 2
      internal/integrations/ci/actions/actions.go
  60. 6 131
      internal/integrations/ci/actions/preview.go
  61. 0 16
      internal/integrations/ci/actions/steps.go
  62. 8 4
      internal/kubernetes/config.go
  63. 8 1
      internal/kubernetes/local/kubeconfig.go
  64. 37 0
      internal/models/infra.go
  65. 1 0
      internal/models/stack.go
  66. 1 0
      internal/repository/git_action_config.go
  67. 62 19
      internal/repository/gorm/environment.go
  68. 4 0
      internal/repository/gorm/git_action_config.go
  69. 1 0
      internal/repository/gorm/helpers_test.go
  70. 32 0
      internal/repository/gorm/infra.go
  71. 16 0
      internal/repository/gorm/stack.go
  72. 1 0
      internal/repository/stack.go
  73. 14 0
      internal/repository/test/git_action_config.go
  74. 4 0
      internal/repository/test/stack.go
  75. 18 2
      internal/stacks/hooks.go
  76. 1 1
      provisioner/server/config/config.go

+ 15 - 1
.github/workflows/release.yaml

@@ -3,6 +3,20 @@ on:
     types: [released]
 name: Update binaries 
 jobs:
+  update-self-hosted-helm-registry:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Get tag name
+        id: tag_name
+        run: |
+          tag=${GITHUB_TAG/refs\/tags\//}
+          echo ::set-output name=tag::$tag
+        env:
+          GITHUB_TAG: ${{ github.ref }}
+      - name: Run workflow
+        run: gh workflow run release.yaml --repo porter-dev/porter-self-hosted -f version=${{steps.tag_name.outputs.tag}}
+        env:
+          GITHUB_TOKEN: ${{ secrets.PORTER_DEV_GITHUB_TOKEN }}  
   push-docker-server-latest:
     runs-on: ubuntu-latest
     steps:
@@ -95,4 +109,4 @@ jobs:
 
           git add Formula
           git commit -m "Update to version ${{steps.tag_name.outputs.tag}}"
-          git push origin main
+          git push origin main

+ 37 - 6
api/client/environment.go

@@ -7,6 +7,21 @@ import (
 	"github.com/porter-dev/porter/api/types"
 )
 
+func (c *Client) ListEnvironments(
+	ctx context.Context,
+	projID, clusterID uint,
+) (*types.ListEnvironmentsResponse, error) {
+	resp := &types.ListEnvironmentsResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf("/projects/%d/clusters/%d/environments", projID, clusterID),
+		nil,
+		resp,
+	)
+
+	return resp, err
+}
+
 func (c *Client) CreateDeployment(
 	ctx context.Context,
 	projID, gitInstallationID, clusterID uint,
@@ -29,17 +44,13 @@ func (c *Client) CreateDeployment(
 
 func (c *Client) GetDeployment(
 	ctx context.Context,
-	projID, gitInstallationID, clusterID uint,
-	gitRepoOwner, gitRepoName string,
+	projID, clusterID, envID uint,
 	req *types.GetDeploymentRequest,
 ) (*types.Deployment, error) {
 	resp := &types.Deployment{}
 
 	err := c.getRequest(
-		fmt.Sprintf(
-			"/projects/%d/gitrepos/%d/%s/%s/clusters/%d/deployment",
-			projID, gitInstallationID, gitRepoOwner, gitRepoName, clusterID,
-		),
+		fmt.Sprintf("/projects/%d/clusters/%d/environments/%d/deployment", projID, clusterID, envID),
 		req,
 		resp,
 	)
@@ -107,6 +118,26 @@ func (c *Client) FinalizeDeployment(
 	return resp, err
 }
 
+func (c *Client) FinalizeDeploymentWithErrors(
+	ctx context.Context,
+	projID, gitInstallationID, clusterID uint,
+	gitRepoOwner, gitRepoName string,
+	req *types.FinalizeDeploymentWithErrorsRequest,
+) (*types.Deployment, error) {
+	resp := &types.Deployment{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/gitrepos/%d/%s/%s/clusters/%d/deployment/finalize_errors",
+			projID, gitInstallationID, gitRepoOwner, gitRepoName, clusterID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
 func (c *Client) DeleteDeployment(
 	ctx context.Context,
 	projID, clusterID, deploymentID uint,

+ 131 - 0
api/server/handlers/cluster_integration/aws/get_cluster_info.go

@@ -0,0 +1,131 @@
+package aws
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/service/ec2"
+	"github.com/aws/aws-sdk-go/service/eks"
+	"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/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type GetClusterInfoHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewGetClusterInfoHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetClusterInfoHandler {
+	return &GetClusterInfoHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *GetClusterInfoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	if cluster.AWSIntegrationID == 0 {
+		c.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("no AWS cluster found with cluster ID: %d", cluster.ID)))
+		return
+	}
+
+	awsInt, err := c.Repo().AWSIntegration().ReadAWSIntegration(proj.ID, cluster.AWSIntegrationID)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no AWS integration found with project ID: %d and "+
+				"integration ID: %d", proj.ID, cluster.AWSIntegrationID)))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error fetching AWS integration with project ID: %d and "+
+			"integration ID: %d. Error: %w", proj.ID, cluster.AWSIntegrationID, err)))
+		return
+	}
+
+	awsSession, err := awsInt.GetSession()
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("error fetching new session for AWS with "+
+			"project ID: %d and integration ID: %d. Error: %w", proj.ID, cluster.AWSIntegrationID, err), http.StatusConflict))
+		return
+	}
+
+	clusterName := cluster.Name
+
+	if strings.HasPrefix(clusterName, "arn:aws:eks:") {
+		parts := strings.Split(clusterName, "/")
+		clusterName = parts[len(parts)-1]
+	}
+
+	awsConf := aws.NewConfig()
+
+	if awsInt.AWSRegion != "" {
+		awsConf = awsConf.WithRegion(awsInt.AWSRegion)
+	}
+
+	eksSvc := eks.New(awsSession, awsConf)
+
+	clusterInfo, err := eksSvc.DescribeCluster(&eks.DescribeClusterInput{
+		Name: &clusterName,
+	})
+
+	if err != nil || clusterInfo.Cluster == nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	ec2Svc := ec2.New(awsSession, awsConf)
+
+	res := &types.GetAWSClusterInfoResponse{
+		Name:       clusterName,
+		ARN:        *clusterInfo.Cluster.Arn,
+		Status:     *clusterInfo.Cluster.Status,
+		K8sVersion: *clusterInfo.Cluster.Version,
+		EKSVersion: *clusterInfo.Cluster.PlatformVersion,
+	}
+
+	err = ec2Svc.DescribeSubnetsPages(&ec2.DescribeSubnetsInput{
+		Filters: []*ec2.Filter{
+			{
+				Name: aws.String("vpc-id"),
+				Values: []*string{
+					clusterInfo.Cluster.ResourcesVpcConfig.VpcId,
+				},
+			},
+		},
+	}, func(page *ec2.DescribeSubnetsOutput, lastPage bool) bool {
+		if page == nil {
+			return false
+		}
+
+		for _, subnet := range page.Subnets {
+			res.Subnets = append(res.Subnets, &types.AWSSubnet{
+				SubnetID:                *subnet.SubnetId,
+				AvailabilityZone:        *subnet.AvailabilityZone,
+				AvailableIPAddressCount: *subnet.AvailableIpAddressCount,
+			})
+		}
+
+		return !lastPage
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 1 - 1
api/server/handlers/environment/create_deployment.go

@@ -142,7 +142,7 @@ func createDeployment(
 	// Create Deployment Status to indicate it's in progress
 
 	state := "in_progress"
-	log_url := fmt.Sprintf("https://github.com/%s/%s/runs/%d", env.GitRepoOwner, env.GitRepoName, actionID)
+	log_url := fmt.Sprintf("https://github.com/%s/%s/actions/runs/%d", env.GitRepoOwner, env.GitRepoName, actionID)
 
 	deploymentStatusRequest := github.DeploymentStatusRequest{
 		State:  &state,

+ 41 - 5
api/server/handlers/environment/finalize_deployment.go

@@ -113,12 +113,46 @@ func (c *FinalizeDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 	//        - if the above fails, try creating a new comment and save the new comment ID in the DB
 	//      - when a Porter deployment does not have a Github comment ID saved in the DB
 	//        - create a new comment and save the Github comment ID in the DB
+	workflowRun, err := commonutils.GetLatestWorkflowRun(client, depl.RepoOwner, depl.RepoName,
+		fmt.Sprintf("porter_%s_env.yml", env.Name), depl.PRBranchFrom)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if depl.Subdomain == "" {
+		depl.Subdomain = "*Ingress is disabled for this deployment*"
+	}
 
 	// write comment in PR
-	commentBody := fmt.Sprintf("Porter has deployed this pull request to the following URL:\n%s", depl.Subdomain)
-	prComment := github.IssueComment{
-		Body: &commentBody,
-		User: &github.User{},
+	commentBody := fmt.Sprintf(
+		"## Porter Preview Environments\n"+
+			"✅ All changes deployed successfully\n"+
+			"||Deployment Information|\n"+
+			"|-|-|\n"+
+			"| Latest SHA | [`%s`](https://github.com/%s/%s/commit/%s) |\n"+
+			"| Live URL | %s |\n"+
+			"| Build Logs | %s |\n"+
+			"| Porter Deployments URL | %s/preview-environments/details/%s?environment_id=%d |",
+		depl.CommitSHA, depl.RepoOwner, depl.RepoName, depl.CommitSHA, depl.Subdomain, workflowRun.GetHTMLURL(),
+		c.Config().ServerConf.ServerURL, depl.Namespace, depl.EnvironmentID,
+	)
+
+	if len(request.SuccessfulResources) > 0 {
+		commentBody += "\n#### Successfully deployed resources\n"
+
+		for _, res := range request.SuccessfulResources {
+			if res.ReleaseType == "job" {
+				commentBody += fmt.Sprintf("- [`%s`](%s/jobs/%s/%s/%s?project_id=%d)\n",
+					res.ReleaseName, c.Config().ServerConf.ServerURL, cluster.Name, depl.Namespace,
+					res.ReleaseName, project.ID)
+			} else {
+				commentBody += fmt.Sprintf("- [`%s`](%s/applications/%s/%s/%s?project_id=%d)\n",
+					res.ReleaseName, c.Config().ServerConf.ServerURL, cluster.Name, depl.Namespace,
+					res.ReleaseName, project.ID)
+			}
+		}
 	}
 
 	_, _, err = client.Issues.CreateComment(
@@ -126,7 +160,9 @@ func (c *FinalizeDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 		env.GitRepoOwner,
 		env.GitRepoName,
 		int(depl.PullRequestID),
-		&prComment,
+		&github.IssueComment{
+			Body: github.String(commentBody),
+		},
 	)
 
 	if err != nil {

+ 166 - 0
api/server/handlers/environment/finalize_deployment_with_errors.go

@@ -0,0 +1,166 @@
+package environment
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"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"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"gorm.io/gorm"
+)
+
+type FinalizeDeploymentWithErrorsHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewFinalizeDeploymentWithErrorsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *FinalizeDeploymentWithErrorsHandler {
+	return &FinalizeDeploymentWithErrorsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *FinalizeDeploymentWithErrorsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ga, _ := r.Context().Value(types.GitInstallationScope).(*integrations.GithubAppInstallation)
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	owner, name, ok := commonutils.GetOwnerAndNameParams(c, w, r)
+
+	if !ok {
+		return
+	}
+
+	request := &types.FinalizeDeploymentWithErrorsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	if len(request.Errors) == 0 {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("at least one error is required to report"), http.StatusPreconditionFailed,
+		))
+		return
+	}
+
+	// read the environment to get the environment id
+	env, err := c.Repo().Environment().ReadEnvironment(project.ID, cluster.ID, uint(ga.InstallationID), owner, name)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no environment found")))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// read the deployment
+	depl, err := c.Repo().Environment().ReadDeployment(env.ID, request.Namespace)
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no deployment found for environment ID: %d, namespace: %s", env.ID, request.Namespace)))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	depl.Status = types.DeploymentStatusFailed
+
+	// we do not care of the error in this case because the list deployments endpoint
+	// talks to the github API to fetch the deployment status correctly
+	c.Repo().Environment().UpdateDeployment(depl)
+
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("unable to get github client: %w", err), http.StatusConflict,
+		))
+		return
+	}
+
+	// FIXME: ignore the status of thie API call for now
+	client.Repositories.CreateDeploymentStatus(
+		context.Background(), owner, name, depl.GHDeploymentID, &github.DeploymentStatusRequest{
+			State:       github.String("failure"),
+			Description: github.String("one or more resources failed to build"),
+		},
+	)
+
+	workflowRun, err := commonutils.GetLatestWorkflowRun(client, depl.RepoOwner, depl.RepoName,
+		fmt.Sprintf("porter_%s_env.yml", env.Name), depl.PRBranchFrom)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	commentBody := fmt.Sprintf(
+		"## Porter Preview Environments\n"+
+			"❌ Errors encountered while deploying the changes\n"+
+			"||Deployment Information|\n"+
+			"|-|-|\n"+
+			"| Latest SHA | [`%s`](https://github.com/%s/%s/commit/%s) |\n"+
+			"| Build Logs | %s |\n",
+		depl.CommitSHA, depl.RepoOwner, depl.RepoName, depl.CommitSHA, workflowRun.GetHTMLURL(),
+	)
+
+	if len(request.SuccessfulResources) > 0 {
+		commentBody += "#### Successfully deployed resources\n"
+
+		for _, res := range request.SuccessfulResources {
+			if res.ReleaseType == "job" {
+				commentBody += fmt.Sprintf("- [`%s`](%s/jobs/%s/%s/%s?project_id=%d)\n",
+					res.ReleaseName, c.Config().ServerConf.ServerURL, cluster.Name, depl.Namespace,
+					res.ReleaseName, project.ID)
+			} else {
+				commentBody += fmt.Sprintf("- [`%s`](%s/applications/%s/%s/%s?project_id=%d)\n",
+					res.ReleaseName, c.Config().ServerConf.ServerURL, cluster.Name, depl.Namespace,
+					res.ReleaseName, project.ID)
+			}
+		}
+	}
+
+	commentBody += "#### Failed resources\n"
+
+	for res, err := range request.Errors {
+		commentBody += fmt.Sprintf("<details>\n  <summary><code>%s</code></summary>\n\n  **Error:** %s\n</details>\n", res, err)
+	}
+
+	_, _, err = client.Issues.CreateComment(
+		context.Background(),
+		env.GitRepoOwner,
+		env.GitRepoName,
+		int(depl.PullRequestID),
+		&github.IssueComment{
+			Body: github.String(commentBody),
+		},
+	)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("error creating github comment: %w", err), http.StatusConflict,
+		))
+		return
+	}
+
+	c.WriteResult(w, r, depl.ToDeploymentType())
+}

+ 16 - 9
api/server/handlers/environment/get_deployment_by_env.go

@@ -15,21 +15,21 @@ import (
 	"gorm.io/gorm"
 )
 
-type GetDeploymentByClusterHandler struct {
+type GetDeploymentByEnvironmentHandler struct {
 	handlers.PorterHandlerReadWriter
 }
 
-func NewGetDeploymentByClusterHandler(
+func NewGetDeploymentByEnvironmentHandler(
 	config *config.Config,
 	decoderValidator shared.RequestDecoderValidator,
 	writer shared.ResultWriter,
-) *GetDeploymentByClusterHandler {
-	return &GetDeploymentByClusterHandler{
+) *GetDeploymentByEnvironmentHandler {
+	return &GetDeploymentByEnvironmentHandler{
 		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
 	}
 }
 
-func (c *GetDeploymentByClusterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+func (c *GetDeploymentByEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
@@ -48,10 +48,12 @@ func (c *GetDeploymentByClusterHandler) ServeHTTP(w http.ResponseWriter, r *http
 
 	_, err := c.Repo().Environment().ReadEnvironmentByID(project.ID, cluster.ID, envID)
 
-	if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
-		c.HandleAPIError(w, r, apierrors.NewErrForbidden(fmt.Errorf("environment with id %d not found", envID)))
-		return
-	} else if err != nil {
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("environment with id %d not found", envID)))
+			return
+		}
+
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
@@ -59,6 +61,11 @@ func (c *GetDeploymentByClusterHandler) ServeHTTP(w http.ResponseWriter, r *http
 	depl, err := c.Repo().Environment().ReadDeployment(envID, request.Namespace)
 
 	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("deployment not found for namespace: %s", request.Namespace)))
+			return
+		}
+
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}

+ 1 - 1
api/server/handlers/environment/list.go

@@ -35,7 +35,7 @@ func (c *ListEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		return
 	}
 
-	res := make([]*types.Environment, 0)
+	var res types.ListEnvironmentsResponse
 
 	for _, env := range envs {
 		environment := env.ToEnvironmentType()

+ 2 - 0
api/server/handlers/environment/list_deployments_by_cluster.go

@@ -221,6 +221,8 @@ func updateDeploymentWithGithubWorkflowRunStatus(
 				deployment.Status = types.DeploymentStatusFailed
 			} else if latestWorkflowRun.GetConclusion() == "timed_out" {
 				deployment.Status = types.DeploymentStatusTimedOut
+			} else if latestWorkflowRun.GetConclusion() == "success" {
+				deployment.Status = types.DeploymentStatusCreated
 			}
 		}
 	}

+ 6 - 0
api/server/handlers/environment/update_deployment_status.go

@@ -63,6 +63,12 @@ func (c *UpdateDeploymentStatusHandler) ServeHTTP(w http.ResponseWriter, r *http
 		return
 	}
 
+	if depl.Status == types.DeploymentStatusInactive && request.Status != string(types.DeploymentStatusCreating) {
+		// a deployment from "inactive" state can only transition to "creating"
+		c.WriteResult(w, r, depl.ToDeploymentType())
+		return
+	}
+
 	depl.Status = types.DeploymentStatus(request.Status)
 
 	// create the deployment

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

@@ -408,6 +408,18 @@ tabs:
       required: true
       placeholder: my-cluster
       variable: cluster_name
+    - type: select
+      label: EKS control plane version
+      variable: cluster_version
+      settings:
+        default: "1.20"
+        options:
+        - label: "1.20"
+          value: "1.20"
+        - label: "1.21"
+          value: "1.21"
+        - label: "1.22"
+          value: "1.22"
     - type: number-input
       label: Minimum number of EC2 instances to create in the application autoscaling group.
       variable: min_instances
@@ -488,6 +500,43 @@ tabs:
       placeholder: "ex: 10"
       settings:
         default: 10
+- name: iam
+  label: IAM
+  sections:
+  - name: toggle_aws_auth
+    contents:
+    - type: heading
+      label: Configure IAM Access
+    - type: checkbox
+      variable: manage_aws_auth_configmap
+      label: Allow Porter to manage AWS authentication for the cluster.
+      settings:
+        default: true
+  - name: aws_auth_warning
+    show_if: 
+      not: manage_aws_auth_configmap
+    contents:
+    - type: subtitle
+      label: "WARNING - turning this value off will result in the aws-auth configmap getting removed from the cluster, and will take existing AWS nodes offline until the configmap is re-added with the node's IAM role ARN. Make sure you know what you are doing."
+  - name: arns
+    show_if: manage_aws_auth_configmap
+    contents:
+    - type: heading
+      label: Users
+    - type: subtitle
+      label: "Add AWS users to the cluster. The left input should be a valid AWS user ARN, and the right side should be a group on the cluster. For example, arn:aws:iam::66666666666:user/user1: system:masters."
+    - type: key-value-array
+      variable: aws_auth_users
+      settings:
+        default: {}
+    - type: heading
+      label: Roles
+    - type: subtitle
+      label: "Add AWS roles to the cluster. The left input should be a valid AWS role ARN, and the right side should be a group on the cluster. For example, arn:aws:iam::66666666666:role/role1: system:masters."
+    - type: key-value-array
+      variable: aws_auth_roles
+      settings:
+        default: {}
 - name: advanced
   label: Advanced
   sections:
@@ -545,6 +594,11 @@ tabs:
       placeholder: "ex: 10.99"
       settings:
         default: "10.99"
+    - type: checkbox
+      label: "Add additional private subnets to the cluster in each AZ."
+      variable: additional_private_subnets
+      settings:
+        default: false
   - name: nginx_settings
     contents:
     - type: heading

+ 57 - 0
api/server/handlers/release/update_git_action_config.go

@@ -0,0 +1,57 @@
+package release
+
+import (
+	"net/http"
+
+	"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/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type UpdateGitActionConfigHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewUpdateGitActionConfigHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateGitActionConfigHandler {
+	return &UpdateGitActionConfigHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *UpdateGitActionConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	release, _ := r.Context().Value(types.ReleaseScope).(*models.Release)
+
+	request := &types.UpdateGitActionConfigRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	actionConfig, err := c.Repo().GitActionConfig().ReadGitActionConfig(release.GitActionConfig.ID)
+
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			w.WriteHeader(http.StatusNotFound)
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	}
+
+	actionConfig.GitBranch = request.GitActionConfig.GitBranch
+
+	if err := c.Repo().GitActionConfig().UpdateGitActionConfig(actionConfig); err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}

+ 17 - 0
api/server/handlers/release/update_rollback.go

@@ -127,6 +127,23 @@ func UpdateReleaseRepo(config *config.Config, release *models.Release, helmRelea
 		if err != nil {
 			return err
 		}
+
+		// determine if the git action config is set, and propagate update to that as well
+		if release.GitActionConfig != nil && release.GitActionConfig.ID != 0 {
+			gitActionConfig, err := config.Repo.GitActionConfig().ReadGitActionConfig(release.GitActionConfig.ID)
+
+			if err != nil {
+				return err
+			}
+
+			gitActionConfig.ImageRepoURI = repoStr
+
+			err = config.Repo.GitActionConfig().UpdateGitActionConfig(gitActionConfig)
+
+			if err != nil {
+				return err
+			}
+		}
 	}
 
 	return nil

+ 49 - 0
api/server/handlers/status/github.go

@@ -0,0 +1,49 @@
+package status
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/mmcdole/gofeed"
+	"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/config"
+)
+
+type GetGithubStatusHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewGetGithubStatusHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *GetGithubStatusHandler {
+	return &GetGithubStatusHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (c *GetGithubStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	fp := gofeed.NewParser()
+	feed, err := fp.ParseURL("https://www.githubstatus.com/history.rss")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("error fetching github status RSS: %w", err)))
+		return
+	}
+
+	if len(feed.Items) > 0 {
+		description := feed.Items[0].Description
+		link := feed.Items[0].Link
+
+		if !strings.Contains(description, "This incident has been resolved") {
+			// ongoing incident
+			c.WriteResult(w, r, link)
+			return
+		}
+	}
+
+	c.WriteResult(w, r, "no active incidents")
+}

+ 135 - 35
api/server/handlers/webhook/github_incoming.go

@@ -6,6 +6,7 @@ import (
 	"net/http"
 	"strconv"
 	"strings"
+	"sync"
 
 	"github.com/bradleyfalzon/ghinstallation/v2"
 	"github.com/google/go-github/v41/github"
@@ -77,31 +78,79 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 		return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s] error reading environment: %w", webhookID, owner, repo, err)
 	}
 
+	if event.GetPullRequest() == nil {
+		return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s] incoming webhook does not have pull request information: %w",
+			webhookID, owner, repo, err)
+	}
+
 	// create deployment on GitHub API
 	client, err := getGithubClientFromEnvironment(c.Config(), env)
 
 	if err != nil {
-		return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d] error getting github client: %w",
-			webhookID, owner, repo, env.ID, err)
+		return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, prNumber: %d] "+
+			"error getting github client: %w", webhookID, owner, repo, env.ID, event.GetPullRequest().GetNumber(), err)
 	}
 
 	if env.Mode == "auto" && event.GetAction() == "opened" {
-		_, err := client.Actions.CreateWorkflowDispatchEventByFileName(
+		depl := &models.Deployment{
+			EnvironmentID: env.ID,
+			Namespace: fmt.Sprintf("pr-%d-%s", event.GetPullRequest().GetNumber(),
+				strings.ToLower(strings.ReplaceAll(repo, "_", "-"))),
+			Status:        types.DeploymentStatusCreating,
+			PullRequestID: uint(event.GetPullRequest().GetNumber()),
+			PRName:        event.GetPullRequest().GetTitle(),
+			RepoName:      repo,
+			RepoOwner:     owner,
+			CommitSHA:     event.GetPullRequest().GetHead().GetSHA()[:7],
+			PRBranchFrom:  event.GetPullRequest().GetHead().GetRef(),
+			PRBranchInto:  event.GetPullRequest().GetBase().GetRef(),
+		}
+
+		_, err = c.Repo().Environment().CreateDeployment(depl)
+
+		if err != nil {
+			return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, prNumber: %d] "+
+				"error creating new deployment: %w", webhookID, owner, repo, env.ID, event.GetPullRequest().GetNumber(), err)
+		}
+
+		cluster, err := c.Repo().Cluster().ReadCluster(env.ProjectID, env.ClusterID)
+
+		if err != nil {
+			return fmt.Errorf("[projectID: %d, clusterID: %d] error reading cluster when creating new deployment: %w",
+				env.ProjectID, env.ClusterID, err)
+		}
+
+		// create the backing namespace
+		agent, err := c.GetAgent(r, cluster, "")
+
+		if err != nil {
+			return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, prNumber: %d] "+
+				"error getting k8s agent: %w", webhookID, owner, repo, env.ID, event.GetPullRequest().GetNumber(), err)
+		}
+
+		_, err = agent.CreateNamespace(depl.Namespace)
+
+		if err != nil {
+			return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, prNumber: %d] "+
+				"error creating k8s namespace: %w", webhookID, owner, repo, env.ID, event.GetPullRequest().GetNumber(), err)
+		}
+
+		_, err = client.Actions.CreateWorkflowDispatchEventByFileName(
 			r.Context(), owner, repo, fmt.Sprintf("porter_%s_env.yml", env.Name),
 			github.CreateWorkflowDispatchEventRequest{
-				Ref: event.PullRequest.GetHead().GetRef(),
+				Ref: event.GetPullRequest().GetHead().GetRef(),
 				Inputs: map[string]interface{}{
-					"pr_number":      strconv.FormatUint(uint64(event.PullRequest.GetNumber()), 10),
-					"pr_title":       event.PullRequest.GetTitle(),
-					"pr_branch_from": event.PullRequest.GetHead().GetRef(),
-					"pr_branch_into": event.PullRequest.GetBase().GetRef(),
+					"pr_number":      strconv.FormatUint(uint64(event.GetPullRequest().GetNumber()), 10),
+					"pr_title":       event.GetPullRequest().GetTitle(),
+					"pr_branch_from": event.GetPullRequest().GetHead().GetRef(),
+					"pr_branch_into": event.GetPullRequest().GetBase().GetRef(),
 				},
 			},
 		)
 
 		if err != nil {
-			return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d] error creating workflow dispatch event: %w",
-				webhookID, owner, repo, env.ID, err)
+			return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, prNumber: %d] "+
+				"error creating workflow dispatch event: %w", webhookID, owner, repo, env.ID, event.GetPullRequest().GetNumber(), err)
 		}
 	} else if event.GetAction() == "synchronize" || event.GetAction() == "closed" {
 		depl, err := c.Repo().Environment().ReadDeploymentByGitDetails(
@@ -109,36 +158,86 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 		)
 
 		if err != nil {
-			return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d] error reading deployment: %w",
-				webhookID, owner, repo, env.ID, err)
+			return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, prNumber: %d] "+
+				"error reading deployment: %w", webhookID, owner, repo, env.ID, event.GetPullRequest().GetNumber(), err)
 		}
 
-		if depl.Status != types.DeploymentStatusInactive {
-			if event.GetAction() == "synchronize" {
-				_, err := client.Actions.CreateWorkflowDispatchEventByFileName(
-					r.Context(), owner, repo, fmt.Sprintf("porter_%s_env.yml", env.Name),
-					github.CreateWorkflowDispatchEventRequest{
-						Ref: event.PullRequest.GetHead().GetRef(),
-						Inputs: map[string]interface{}{
-							"pr_number":      strconv.FormatUint(uint64(event.PullRequest.GetNumber()), 10),
-							"pr_title":       event.PullRequest.GetTitle(),
-							"pr_branch_from": event.PullRequest.GetHead().GetRef(),
-							"pr_branch_into": event.PullRequest.GetBase().GetRef(),
-						},
+		if depl.Status == types.DeploymentStatusInactive {
+			return nil
+		}
+
+		if event.GetAction() == "synchronize" {
+			_, err := client.Actions.CreateWorkflowDispatchEventByFileName(
+				r.Context(), owner, repo, fmt.Sprintf("porter_%s_env.yml", env.Name),
+				github.CreateWorkflowDispatchEventRequest{
+					Ref: event.GetPullRequest().GetHead().GetRef(),
+					Inputs: map[string]interface{}{
+						"pr_number":      strconv.FormatUint(uint64(event.GetPullRequest().GetNumber()), 10),
+						"pr_title":       event.GetPullRequest().GetTitle(),
+						"pr_branch_from": event.GetPullRequest().GetHead().GetRef(),
+						"pr_branch_into": event.GetPullRequest().GetBase().GetRef(),
 					},
-				)
+				},
+			)
 
-				if err != nil {
-					return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, deploymentID: %d] error creating workflow dispatch event: %w",
-						webhookID, owner, repo, env.ID, depl.ID, err)
-				}
-			} else {
-				err = c.deleteDeployment(r, depl, env, client)
+			if err != nil {
+				return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, deploymentID: %d, prNumber: %d] "+
+					"error creating workflow dispatch event: %w", webhookID, owner, repo, env.ID, depl.ID,
+					event.GetPullRequest().GetNumber(), err)
+			}
+		} else {
+			// check for already running workflows we should be cancelling
+			var wg sync.WaitGroup
+			statuses := []string{"in_progress", "queued", "requested", "waiting"}
+			chanErr := fmt.Errorf("")
+
+			wg.Add(len(statuses))
+
+			for _, status := range statuses {
+				go func(status string) {
+					defer wg.Done()
+
+					runs, _, err := client.Actions.ListWorkflowRunsByFileName(
+						context.Background(), owner, repo, fmt.Sprintf("porter_%s_env.yml", env.Name),
+						&github.ListWorkflowRunsOptions{
+							Branch: event.GetPullRequest().GetHead().GetRef(),
+							Status: status,
+						},
+					)
+
+					if err == nil {
+						for _, run := range runs.WorkflowRuns {
+							resp, err := client.Actions.CancelWorkflowRunByID(context.Background(), owner, repo, run.GetID())
+
+							if err != nil && resp.StatusCode != http.StatusAccepted {
+								// the go library we are using returns a 202 Accepted status as an error
+								// in this case, we should rule this out as an error
+								chanErr = fmt.Errorf("%s: error cancelling %s: %w", chanErr.Error(), run.GetHTMLURL(), err)
+							}
+						}
+					} else {
+						chanErr = fmt.Errorf("%s: error listing workflows for status %s: %w", chanErr.Error(), status, err)
+					}
+				}(status)
+			}
 
-				if err != nil {
-					return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, deploymentID: %d] error deleting deployment: %w",
-						webhookID, owner, repo, env.ID, depl.ID, err)
+			wg.Wait()
+
+			err = c.deleteDeployment(r, depl, env, client)
+
+			if err != nil {
+				deleteErr := fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, deploymentID: %d, prNumber: %d] "+
+					"error deleting deployment: %w", webhookID, owner, repo, env.ID, depl.ID, event.GetPullRequest().GetNumber(), err)
+
+				if chanErr.Error() != "" {
+					deleteErr = fmt.Errorf("%s. errors found while trying to cancel active workflow runs %w", deleteErr.Error(), chanErr)
 				}
+
+				return deleteErr
+			} else if chanErr.Error() != "" {
+				return fmt.Errorf("[webhookID: %s, owner: %s, repo: %s, environmentID: %d, deploymentID: %d, prNumber: %d] "+
+					"deployment deleted but errors found while trying to cancel active workflow runs %w", webhookID, owner, repo, env.ID, depl.ID,
+					event.GetPullRequest().GetNumber(), chanErr)
 			}
 		}
 	}
@@ -155,7 +254,8 @@ func (c *GithubIncomingWebhookHandler) deleteDeployment(
 	cluster, err := c.Repo().Cluster().ReadCluster(env.ProjectID, env.ClusterID)
 
 	if err != nil {
-		return fmt.Errorf("[projectID: %d, clusterID: %d] error reading cluster: %w", env.ProjectID, env.ClusterID, err)
+		return fmt.Errorf("[projectID: %d, clusterID: %d] error reading cluster when deleting existing deployment: %w",
+			env.ProjectID, env.ClusterID, err)
 	}
 
 	agent, err := c.GetAgent(r, cluster, "")

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

@@ -348,14 +348,14 @@ func getClusterRoutes(
 			Router:   r,
 		})
 
-		// GET /api/projects/{project_id}/clusters/{cluster_id}/{environment_id}/deployment -> environment.NewGetDeploymentByClusterHandler
+		// GET /api/projects/{project_id}/clusters/{cluster_id}/environments/{environment_id}/deployment -> environment.NewGetDeploymentByClusterHandler
 		getDeploymentEndpoint := factory.NewAPIEndpoint(
 			&types.APIRequestMetadata{
 				Verb:   types.APIVerbGet,
 				Method: types.HTTPVerbGet,
 				Path: &types.Path{
 					Parent:       basePath,
-					RelativePath: relPath + "/{environment_id}/deployment",
+					RelativePath: relPath + "/environments/{environment_id}/deployment",
 				},
 				Scopes: []types.PermissionScope{
 					types.UserScope,
@@ -365,7 +365,7 @@ func getClusterRoutes(
 			},
 		)
 
-		getDeploymentHandler := environment.NewGetDeploymentByClusterHandler(
+		getDeploymentHandler := environment.NewGetDeploymentByEnvironmentHandler(
 			config,
 			factory.GetDecoderValidator(),
 			factory.GetResultWriter(),

+ 86 - 0
api/server/router/cluster_integration.go

@@ -0,0 +1,86 @@
+package router
+
+import (
+	"github.com/go-chi/chi"
+	awsClusterInt "github.com/porter-dev/porter/api/server/handlers/cluster_integration/aws"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
+	"github.com/porter-dev/porter/api/types"
+)
+
+func NewClusterIntegrationScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
+		GetRoutes: GetClusterIntegrationScopedRoutes,
+		Children:  children,
+	}
+}
+
+func GetClusterIntegrationScopedRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+	children ...*router.Registerer,
+) []*router.Route {
+	routes, projPath := getClusterIntegrationRoutes(r, config, basePath, factory)
+
+	if len(children) > 0 {
+		r.Route(projPath.RelativePath, func(r chi.Router) {
+			for _, child := range children {
+				childRoutes := child.GetRoutes(r, config, basePath, factory, child.Children...)
+
+				routes = append(routes, childRoutes...)
+			}
+		})
+	}
+
+	return routes
+}
+
+func getClusterIntegrationRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+) ([]*router.Route, *types.Path) {
+	relPath := "/integrations"
+
+	newPath := &types.Path{
+		Parent:       basePath,
+		RelativePath: relPath,
+	}
+
+	routes := make([]*router.Route, 0)
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/integrations/aws/info -> awsClusterInt.NewGetClusterInfoHandler
+	getAWSClusterInfoEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/aws/info",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getAWSClusterInfoHandler := awsClusterInt.NewGetClusterInfoHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getAWSClusterInfoEndpoint,
+		Handler:  getAWSClusterInfoHandler,
+		Router:   r,
+	})
+
+	return routes, newPath
+}

+ 37 - 1
api/server/router/git_installation.go

@@ -188,7 +188,7 @@ func getGitInstallationRoutes(
 		})
 
 		// GET /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment ->
-		// environment.NewCreateDeploymentHandler
+		// environment.NewGetDeploymentHandler
 		getDeploymentEndpoint := factory.NewAPIEndpoint(
 			&types.APIRequestMetadata{
 				Verb:   types.APIVerbGet,
@@ -367,6 +367,42 @@ func getGitInstallationRoutes(
 			Router:   r,
 		})
 
+		// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment/finalize_errors ->
+		// environment.NewFinalizeDeploymentWithErrorsHandler
+		finalizeDeploymentWithErrorsEndpoint := factory.NewAPIEndpoint(
+			&types.APIRequestMetadata{
+				Verb:   types.APIVerbUpdate,
+				Method: types.HTTPVerbPost,
+				Path: &types.Path{
+					Parent: basePath,
+					RelativePath: fmt.Sprintf(
+						"%s/{%s}/{%s}/clusters/{cluster_id}/deployment/finalize_errors",
+						relPath,
+						types.URLParamGitRepoOwner,
+						types.URLParamGitRepoName,
+					),
+				},
+				Scopes: []types.PermissionScope{
+					types.UserScope,
+					types.ProjectScope,
+					types.GitInstallationScope,
+					types.ClusterScope,
+				},
+			},
+		)
+
+		finalizeDeploymentWithErrorsHandler := environment.NewFinalizeDeploymentWithErrorsHandler(
+			config,
+			factory.GetDecoderValidator(),
+			factory.GetResultWriter(),
+		)
+
+		routes = append(routes, &router.Route{
+			Endpoint: finalizeDeploymentWithErrorsEndpoint,
+			Handler:  finalizeDeploymentWithErrorsHandler,
+			Router:   r,
+		})
+
 		// DELETE /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/environment ->
 		// environment.NewDeleteEnvironmentHandler
 		deleteEnvironmentEndpoint := factory.NewAPIEndpoint(

+ 31 - 0
api/server/router/release.go

@@ -815,5 +815,36 @@ func getReleaseRoutes(
 		Router:   r,
 	})
 
+	// PATCH /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/{version}/git_action_config -> release.NewUpdateGitActionConfigHandler
+	updateGitActionConfigEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPatch,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/releases/{name}/{version}/git_action_config",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.ReleaseScope,
+			},
+		},
+	)
+
+	updateGitActionConfigHandler := release.NewUpdateGitActionConfigHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: updateGitActionConfigEndpoint,
+		Handler:  updateGitActionConfigHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 4 - 2
api/server/router/router.go

@@ -29,7 +29,8 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 
 	releaseRegisterer := NewReleaseScopedRegisterer()
 	namespaceRegisterer := NewNamespaceScopedRegisterer(releaseRegisterer)
-	clusterRegisterer := NewClusterScopedRegisterer(namespaceRegisterer)
+	clusterIntegrationRegisterer := NewClusterIntegrationScopedRegisterer()
+	clusterRegisterer := NewClusterScopedRegisterer(namespaceRegisterer, clusterIntegrationRegisterer)
 	infraRegisterer := NewInfraScopedRegisterer()
 	gitInstallationRegisterer := NewGitInstallationScopedRegisterer()
 	registryRegisterer := NewRegistryScopedRegisterer()
@@ -49,8 +50,9 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 		projectOAuthRegisterer,
 		slackIntegrationRegisterer,
 	)
+	statusRegisterer := NewStatusScopedRegisterer()
 
-	userRegisterer := NewUserScopedRegisterer(projRegisterer)
+	userRegisterer := NewUserScopedRegisterer(projRegisterer, statusRegisterer)
 	panicMW := middleware.NewPanicMiddleware(config)
 
 	if config.ServerConf.PprofEnabled {

+ 83 - 0
api/server/router/status.go

@@ -0,0 +1,83 @@
+package router
+
+import (
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/handlers/status"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/router"
+	"github.com/porter-dev/porter/api/types"
+)
+
+func NewStatusScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
+		GetRoutes: GetStatusScopedRoutes,
+		Children:  children,
+	}
+}
+
+func GetStatusScopedRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+	children ...*router.Registerer,
+) []*router.Route {
+	routes, projPath := getStatusRoutes(r, config, basePath, factory)
+
+	if len(children) > 0 {
+		r.Route(projPath.RelativePath, func(r chi.Router) {
+			for _, child := range children {
+				childRoutes := child.GetRoutes(r, config, basePath, factory, child.Children...)
+
+				routes = append(routes, childRoutes...)
+			}
+		})
+	}
+
+	return routes
+}
+
+func getStatusRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+) ([]*router.Route, *types.Path) {
+	relPath := "/status"
+
+	newPath := &types.Path{
+		Parent:       basePath,
+		RelativePath: relPath,
+	}
+
+	routes := make([]*router.Route, 0)
+
+	// GET /api/status/github -> status.NewGetGithubStatusHandler
+	getGithubStatusEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/github",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+			},
+		},
+	)
+
+	getGithubStatusHandler := status.NewGetGithubStatusHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getGithubStatusEndpoint,
+		Handler:  getGithubStatusHandler,
+		Router:   r,
+	})
+
+	return routes, newPath
+}

+ 16 - 0
api/types/cluster_integration.go

@@ -0,0 +1,16 @@
+package types
+
+type AWSSubnet struct {
+	SubnetID                string `json:"subnet_id"`
+	AvailabilityZone        string `json:"availability_zone"`
+	AvailableIPAddressCount int64  `json:"available_ip_address_count"`
+}
+
+type GetAWSClusterInfoResponse struct {
+	Name       string       `json:"name"`
+	ARN        string       `json:"arn"`
+	K8sVersion string       `json:"kubernetes_server_version"`
+	EKSVersion string       `json:"eks_version"`
+	Status     string       `json:"status"`
+	Subnets    []*AWSSubnet `json:"subnets"`
+}

+ 16 - 2
api/types/environment.go

@@ -71,9 +71,21 @@ type CreateDeploymentRequest struct {
 	PullRequestID uint   `json:"pull_request_id" form:"required"`
 }
 
+type SuccessfullyDeployedResource struct {
+	ReleaseName string `json:"release_name" form:"required"`
+	ReleaseType string `json:"release_type"`
+}
+
 type FinalizeDeploymentRequest struct {
-	Namespace string `json:"namespace" form:"required"`
-	Subdomain string `json:"subdomain" form:"required"`
+	Namespace           string                          `json:"namespace" form:"required"`
+	SuccessfulResources []*SuccessfullyDeployedResource `json:"successful_resources"`
+	Subdomain           string                          `json:"subdomain"`
+}
+
+type FinalizeDeploymentWithErrorsRequest struct {
+	Namespace           string                          `json:"namespace" form:"required"`
+	SuccessfulResources []*SuccessfullyDeployedResource `json:"successful_resources"`
+	Errors              map[string]string               `json:"errors" form:"required"`
 }
 
 type UpdateDeploymentRequest struct {
@@ -116,3 +128,5 @@ type PullRequest struct {
 type ToggleNewCommentRequest struct {
 	Enable bool `json:"enable" form:"required"`
 }
+
+type ListEnvironmentsResponse []*Environment

+ 2 - 0
api/types/infra.go

@@ -42,6 +42,8 @@ type Infra struct {
 	// The project that this integration belongs to
 	ProjectID uint `json:"project_id"`
 
+	Name string `json:"name"`
+
 	APIVersion    string `json:"api_version,omitempty"`
 	SourceLink    string `json:"source_link,omitempty"`
 	SourceVersion string `json:"source_version,omitempty"`

+ 10 - 0
api/types/release.go

@@ -193,3 +193,13 @@ type GetReleaseAllPodsResponse []v1.Pod
 type PatchUpdateReleaseTags struct {
 	Tags []string `json:"tags"`
 }
+
+type PartialGitActionConfig struct {
+	// The branch to use for the git repository
+	// required: true
+	GitBranch string `json:"branch" form:"required"`
+}
+
+type UpdateGitActionConfigRequest struct {
+	GitActionConfig *PartialGitActionConfig `json:"git_action_config"`
+}

+ 9 - 6
api/types/stacks.go

@@ -71,6 +71,9 @@ type Stack struct {
 	// The display name of the stack
 	Name string `json:"name"`
 
+	// The namespace that the stack was deployed to
+	Namespace string `json:"namespace"`
+
 	// A unique id for the stack
 	ID string `json:"id"`
 
@@ -190,13 +193,13 @@ type StackSourceConfig struct {
 // swagger:model
 type CreateStackSourceConfigRequest struct {
 	// required: true
-	Name string `json:"name"`
+	Name string `json:"name" form:"required"`
 
 	// required: true
-	ImageRepoURI string `json:"image_repo_uri"`
+	ImageRepoURI string `json:"image_repo_uri" form:"required"`
 
 	// required: true
-	ImageTag string `json:"image_tag"`
+	ImageTag string `json:"image_tag" form:"required"`
 
 	// If this field is empty, the resource is deployed directly from the image repo uri
 	StackSourceConfigBuild *StackSourceConfigBuild `json:"build,omitempty"`
@@ -205,13 +208,13 @@ type CreateStackSourceConfigRequest struct {
 // swagger:model
 type UpdateStackSourceConfigRequest struct {
 	// required: true
-	Name string `json:"name"`
+	Name string `json:"name" form:"required"`
 
 	// required: true
-	ImageRepoURI string `json:"image_repo_uri"`
+	ImageRepoURI string `json:"image_repo_uri" form:"required"`
 
 	// required: true
-	ImageTag string `json:"image_tag"`
+	ImageTag string `json:"image_tag" form:"required"`
 }
 
 type StackSourceConfigBuild struct {

+ 123 - 29
cli/cmd/apply.go

@@ -405,22 +405,20 @@ func (d *Driver) applyApplication(resource *models.Resource, client *api.Client,
 			Name:      resource.Name,
 		})
 
-		if err != nil {
-			if appConfig.OnlyCreate {
-				err = client.DeleteRelease(
-					context.Background(),
-					d.target.Project,
-					d.target.Cluster,
-					d.target.Namespace,
-					resource.Name,
-				)
+		if err != nil && appConfig.OnlyCreate {
+			deleteJobErr := 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)
-				}
+			if deleteJobErr != nil {
+				return nil, fmt.Errorf("error deleting job %s with waitForJob and onlyCreate set to true: %w",
+					resource.Name, deleteJobErr)
 			}
-
+		} else if err != nil {
 			return nil, fmt.Errorf("error waiting for job %s: %w", resource.Name, err)
 		}
 	}
@@ -638,7 +636,7 @@ func (d *Driver) getAddonConfig(resource *models.Resource) (map[string]interface
 type DeploymentHook struct {
 	client                                                                    *api.Client
 	resourceGroup                                                             *switchboardTypes.ResourceGroup
-	gitInstallationID, projectID, clusterID, prID, actionID                   uint
+	gitInstallationID, projectID, clusterID, prID, actionID, envID            uint
 	branchFrom, branchInto, namespace, repoName, repoOwner, prName, commitSHA string
 }
 
@@ -715,18 +713,37 @@ func NewDeploymentHook(client *api.Client, resourceGroup *switchboardTypes.Resou
 }
 
 func (t *DeploymentHook) PreApply() error {
+	envList, err := t.client.ListEnvironments(
+		context.Background(), t.projectID, t.clusterID,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	envs := *envList
+
+	for _, env := range envs {
+		if env.GitRepoOwner == t.repoOwner && env.GitRepoName == t.repoName && env.GitInstallationID == t.gitInstallationID {
+			t.envID = env.ID
+			break
+		}
+	}
+
+	if t.envID == 0 {
+		return fmt.Errorf("could not find environment for deployment")
+	}
+
 	// attempt to read the deployment -- if it doesn't exist, create it
-	_, err := t.client.GetDeployment(
+	_, err = t.client.GetDeployment(
 		context.Background(),
-		t.projectID, t.gitInstallationID, t.clusterID,
-		t.repoOwner, t.repoName,
+		t.projectID, t.clusterID, t.envID,
 		&types.GetDeploymentRequest{
 			Namespace: t.namespace,
 		},
 	)
 
-	// TODO: case this on the response status code rather than text
-	if err != nil && strings.Contains(err.Error(), "deployment not found") {
+	if err != nil && strings.Contains(err.Error(), "not found") {
 		// in this case, create the deployment
 		_, err = t.client.CreateDeployment(
 			context.Background(),
@@ -842,15 +859,28 @@ func (t *DeploymentHook) PostApply(populatedData map[string]interface{}) error {
 		}
 	}
 
+	req := &types.FinalizeDeploymentRequest{
+		Namespace: t.namespace,
+		Subdomain: strings.Join(subdomains, ", "),
+	}
+
+	for _, res := range t.resourceGroup.Resources {
+		releaseType := getReleaseType(res)
+		releaseName := getReleaseName(res)
+
+		if releaseType != "" && releaseName != "" {
+			req.SuccessfulResources = append(req.SuccessfulResources, &types.SuccessfullyDeployedResource{
+				ReleaseName: releaseName,
+				ReleaseType: releaseType,
+			})
+		}
+	}
+
 	// finalize the deployment
 	_, err := t.client.FinalizeDeployment(
 		context.Background(),
 		t.projectID, t.gitInstallationID, t.clusterID,
-		t.repoOwner, t.repoName,
-		&types.FinalizeDeploymentRequest{
-			Namespace: t.namespace,
-			Subdomain: strings.Join(subdomains, ","),
-		},
+		t.repoOwner, t.repoName, req,
 	)
 
 	return err
@@ -860,8 +890,7 @@ func (t *DeploymentHook) OnError(err error) {
 	// if the deployment exists, throw an error for that deployment
 	_, getDeplErr := t.client.GetDeployment(
 		context.Background(),
-		t.projectID, t.gitInstallationID, t.clusterID,
-		t.repoOwner, t.repoName,
+		t.projectID, t.clusterID, t.envID,
 		&types.GetDeploymentRequest{
 			Namespace: t.namespace,
 		},
@@ -884,6 +913,45 @@ func (t *DeploymentHook) OnError(err error) {
 	}
 }
 
+func (t *DeploymentHook) OnConsolidatedErrors(allErrors map[string]error) {
+	// if the deployment exists, throw an error for that deployment
+	_, getDeplErr := t.client.GetDeployment(
+		context.Background(),
+		t.projectID, t.clusterID, t.envID,
+		&types.GetDeploymentRequest{
+			Namespace: t.namespace,
+		},
+	)
+
+	if getDeplErr == nil {
+		req := &types.FinalizeDeploymentWithErrorsRequest{
+			Namespace: t.namespace,
+			Errors:    make(map[string]string),
+		}
+
+		for _, res := range t.resourceGroup.Resources {
+			if _, ok := allErrors[res.Name]; !ok {
+				req.SuccessfulResources = append(req.SuccessfulResources, &types.SuccessfullyDeployedResource{
+					ReleaseName: getReleaseName(res),
+					ReleaseType: getReleaseType(res),
+				})
+			}
+		}
+
+		for res, err := range allErrors {
+			req.Errors[res] = err.Error()
+		}
+
+		// FIXME: handle the error
+		t.client.FinalizeDeploymentWithErrors(
+			context.Background(),
+			t.projectID, t.gitInstallationID, t.clusterID,
+			t.repoOwner, t.repoName,
+			req,
+		)
+	}
+}
+
 type CloneEnvGroupHook struct {
 	client   *api.Client
 	resGroup *switchboardTypes.ResourceGroup
@@ -968,8 +1036,34 @@ func (t *CloneEnvGroupHook) DataQueries() map[string]interface{} {
 	return nil
 }
 
-func (t *CloneEnvGroupHook) PostApply(populatedData map[string]interface{}) error {
+func (t *CloneEnvGroupHook) PostApply(map[string]interface{}) error {
 	return nil
 }
 
-func (t *CloneEnvGroupHook) OnError(err error) {}
+func (t *CloneEnvGroupHook) OnError(error) {}
+
+func (t *CloneEnvGroupHook) OnConsolidatedErrors(map[string]error) {}
+
+func getReleaseName(res *switchboardTypes.Resource) string {
+	// can ignore the error because this method is called once
+	// GetTarget has alrealy been called and validated previously
+	target, _ := preview.GetTarget(res.Target)
+
+	if target.AppName != "" {
+		return target.AppName
+	}
+
+	return res.Name
+}
+
+func getReleaseType(res *switchboardTypes.Resource) string {
+	// can ignore the error because this method is called once
+	// GetSource has alrealy been called and validated previously
+	source, _ := preview.GetSource(res.Source)
+
+	if source != nil && source.Name != "" {
+		return source.Name
+	}
+
+	return ""
+}

+ 18 - 2
cli/cmd/docker/builder.go

@@ -12,6 +12,7 @@ import (
 
 	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/pkg/archive"
+	"github.com/moby/buildkit/frontend/dockerfile/dockerignore"
 	"github.com/moby/moby/pkg/jsonmessage"
 	"github.com/moby/moby/pkg/stringid"
 	"github.com/moby/term"
@@ -31,9 +32,24 @@ type BuildOpts struct {
 }
 
 // BuildLocal
-func (a *Agent) BuildLocal(opts *BuildOpts) error {
+func (a *Agent) BuildLocal(opts *BuildOpts) (err error) {
 	dockerfilePath := opts.DockerfilePath
-	tar, err := archive.TarWithOptions(opts.BuildContext, &archive.TarOptions{})
+
+	// attempt to read dockerignore file and paths
+	dockerIgnoreBytes, _ := ioutil.ReadFile(".dockerignore")
+	var excludes []string
+
+	if len(dockerIgnoreBytes) != 0 {
+		excludes, err = dockerignore.ReadAll(bytes.NewBuffer(dockerIgnoreBytes))
+
+		if err != nil {
+			return err
+		}
+	}
+
+	tar, err := archive.TarWithOptions(opts.BuildContext, &archive.TarOptions{
+		ExcludePatterns: excludes,
+	})
 
 	if err != nil {
 		return err

+ 51 - 16
cli/cmd/list.go

@@ -10,8 +10,11 @@ import (
 	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"
 )
 
+var allNamespaces bool
+
 // listCmd represents the "porter list" base command and "porter list all" subcommand
 var listCmd = &cobra.Command{
 	Use:   "list",
@@ -76,6 +79,13 @@ func init() {
 		"the namespace of the release",
 	)
 
+	listCmd.PersistentFlags().BoolVar(
+		&allNamespaces,
+		"all-namespaces",
+		false,
+		"list resources for all namespaces",
+	)
+
 	listCmd.AddCommand(listAppsCmd)
 	listCmd.AddCommand(listJobsCmd)
 	listCmd.AddCommand(listAddonsCmd)
@@ -124,24 +134,49 @@ func listAddons(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 }
 
 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,
-			Skip:  0,
-			StatusFilter: []string{
-				"deployed",
-				"uninstalled",
-				"pending",
-				"pending-install",
-				"pending-upgrade",
-				"pending-rollback",
-				"failed",
+	var namespaces []string
+	var releases []*release.Release
+
+	if allNamespaces {
+		resp, err := client.GetK8sNamespaces(context.Background(), cliConf.Project, cliConf.Cluster)
+
+		if err != nil {
+			return err
+		}
+
+		namespaceResp := *resp
+
+		for _, ns := range namespaceResp {
+			namespaces = append(namespaces, ns.Name)
+		}
+	} else {
+		namespaces = append(namespaces, namespace)
+	}
+
+	for _, ns := range namespaces {
+		resp, err := client.ListReleases(context.Background(), cliConf.Project, cliConf.Cluster, ns,
+			&types.ListReleasesRequest{
+				ReleaseListFilter: &types.ReleaseListFilter{
+					Limit: 50,
+					Skip:  0,
+					StatusFilter: []string{
+						"deployed",
+						"uninstalled",
+						"pending",
+						"pending-install",
+						"pending-upgrade",
+						"pending-rollback",
+						"failed",
+					},
+				},
 			},
-		},
-	})
+		)
 
-	if err != nil {
-		return err
+		if err != nil {
+			return err
+		}
+
+		releases = append(releases, resp...)
 	}
 
 	w := new(tabwriter.Writer)

+ 52 - 25
dashboard/src/components/image-selector/ImageList.tsx

@@ -9,17 +9,31 @@ import { ImageType } from "shared/types";
 import Loading from "../Loading";
 import TagList from "./TagList";
 
-type PropsType = {
-  selectedImageUrl: string | null;
-  selectedTag: string | null;
-  clickedImage: ImageType | null;
-  registry?: any;
-  noTagSelection?: boolean;
-  setSelectedImageUrl: (x: string) => void;
-  setSelectedTag: (x: string) => void;
-  setClickedImage: (x: ImageType) => void;
-  disableImageSelect?: boolean;
-};
+type PropsType =
+  | {
+      selectedImageUrl: string | null;
+      selectedTag: string | null;
+      clickedImage: ImageType | null;
+      registry?: any;
+      noTagSelection?: boolean;
+      setSelectedImageUrl: (x: string) => void;
+      setSelectedTag: (x: string) => void;
+      setClickedImage: (x: ImageType) => void;
+      disableImageSelect?: boolean;
+      readOnly?: boolean;
+    }
+  | {
+      selectedImageUrl: string | null;
+      selectedTag: string | null;
+      clickedImage: ImageType | null;
+      registry?: any;
+      noTagSelection?: boolean;
+      setSelectedImageUrl?: (x: string) => void;
+      setSelectedTag?: (x: string) => void;
+      setClickedImage?: (x: ImageType) => void;
+      disableImageSelect?: boolean;
+      readOnly: true;
+    };
 
 type StateType = {
   loading: boolean;
@@ -222,28 +236,41 @@ export default class ImageList extends Component<PropsType, StateType> {
   renderExpanded = () => {
     let { selectedTag, selectedImageUrl, setSelectedTag } = this.props;
 
-    if (!this.props.clickedImage || this.props.noTagSelection) {
+    if (this.props.readOnly && this.props.clickedImage) {
       return (
-        <div>
-          <ExpandedWrapper>{this.renderImageList()}</ExpandedWrapper>
-          {this.renderBackButton()}
-        </div>
+        <ExpandedWrapper>
+          <TagList
+            selectedTag={selectedTag}
+            selectedImageUrl={selectedImageUrl}
+            registryId={this.props.clickedImage.registryId}
+            readOnly
+          />
+        </ExpandedWrapper>
       );
-    } else {
+    }
+
+    if (!this.props.clickedImage || this.props.noTagSelection) {
       return (
         <div>
-          <ExpandedWrapper>
-            <TagList
-              selectedTag={selectedTag}
-              selectedImageUrl={selectedImageUrl}
-              setSelectedTag={setSelectedTag}
-              registryId={this.props.clickedImage.registryId}
-            />
-          </ExpandedWrapper>
+          <ExpandedWrapper>{this.renderImageList()}</ExpandedWrapper>
           {this.renderBackButton()}
         </div>
       );
     }
+
+    return (
+      <div>
+        <ExpandedWrapper>
+          <TagList
+            selectedTag={selectedTag}
+            selectedImageUrl={selectedImageUrl}
+            setSelectedTag={setSelectedTag}
+            registryId={this.props.clickedImage.registryId}
+          />
+        </ExpandedWrapper>
+        {this.renderBackButton()}
+      </div>
+    );
   };
 
   render() {

+ 51 - 10
dashboard/src/components/image-selector/ImageSelector.tsx

@@ -10,15 +10,27 @@ import { ImageType } from "shared/types";
 import Loading from "../Loading";
 import ImageList from "./ImageList";
 
-type PropsType = {
-  forceExpanded?: boolean;
-  selectedImageUrl: string | null;
-  selectedTag: string | null;
-  setSelectedImageUrl: (x: string) => void;
-  setSelectedTag: (x: string) => void;
-  noTagSelection?: boolean;
-  disableImageSelect?: boolean;
-};
+type PropsType =
+  | {
+      forceExpanded?: boolean;
+      selectedImageUrl: string | null;
+      selectedTag: string | null;
+      setSelectedImageUrl: (x: string) => void;
+      setSelectedTag: (x: string) => void;
+      noTagSelection?: boolean;
+      disableImageSelect?: boolean;
+      readOnly?: boolean;
+    }
+  | {
+      forceExpanded?: boolean;
+      selectedImageUrl: string | null;
+      selectedTag: string | null;
+      setSelectedImageUrl?: (x: string) => void;
+      setSelectedTag?: (x: string) => void;
+      noTagSelection?: boolean;
+      disableImageSelect?: boolean;
+      readOnly: true;
+    };
 
 type StateType = {
   isExpanded: boolean;
@@ -94,7 +106,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
       <Label>
         <img src={icon} />
         <Input
-          disabled={this.props.disableImageSelect}
+          disabled={this.props.readOnly || this.props.disableImageSelect}
           onClick={(e: any) => e.stopPropagation()}
           value={selectedImageUrl}
           onChange={(e: any) => {
@@ -118,6 +130,35 @@ export default class ImageSelector extends Component<PropsType, StateType> {
   };
 
   render() {
+    if (this.props.readOnly) {
+      return (
+        <>
+          <StyledImageSelector isExpanded={true} forceExpanded={true}>
+            {this.renderSelected()}
+            {this.props.forceExpanded ? null : (
+              <i className="material-icons">
+                {this.state.isExpanded ? "close" : "build"}
+              </i>
+            )}
+          </StyledImageSelector>
+
+          <ImageList
+            disableImageSelect={true}
+            selectedImageUrl={this.props.selectedImageUrl}
+            selectedTag={this.props.selectedTag}
+            clickedImage={this.state.clickedImage}
+            noTagSelection={this.props.noTagSelection}
+            setSelectedImageUrl={this.props.setSelectedImageUrl}
+            setSelectedTag={this.props.setSelectedTag}
+            setClickedImage={(x: ImageType) =>
+              this.setState({ clickedImage: x })
+            }
+            readOnly
+          />
+        </>
+      );
+    }
+
     return (
       <div>
         <StyledImageSelector

+ 17 - 7
dashboard/src/components/image-selector/TagList.tsx

@@ -10,12 +10,21 @@ import Loading from "../Loading";
 
 var ecrRepoRegex = /(^[a-zA-Z0-9][a-zA-Z0-9-_]*)\.dkr\.ecr(\-fips)?\.([a-zA-Z0-9][a-zA-Z0-9-_]*)\.amazonaws\.com(\.cn)?/gim;
 
-type PropsType = {
-  setSelectedTag: (x: string) => void;
-  selectedTag: string;
-  selectedImageUrl: string;
-  registryId: number;
-};
+type PropsType =
+  | {
+      setSelectedTag: (x: string) => void;
+      selectedTag: string;
+      selectedImageUrl: string;
+      registryId: number;
+      readOnly?: boolean;
+    }
+  | {
+      setSelectedTag?: (x: string) => void;
+      selectedTag: string;
+      selectedImageUrl: string;
+      registryId: number;
+      readOnly: true;
+    };
 
 type StateType = {
   loading: boolean;
@@ -123,7 +132,8 @@ export default class TagList extends Component<PropsType, StateType> {
       <>
         <TagNameAlt>
           <Label>
-            <img src={info} /> Select Image Tag
+            <img src={info} />
+            {this.props.readOnly ? "Current image tag" : "Select Image Tag"}
           </Label>
           <Refresh onClick={this.refreshTagList}>
             <i className="material-icons">autorenew</i> Refresh

+ 16 - 5
dashboard/src/components/repo-selector/BranchList.tsx

@@ -12,9 +12,14 @@ import SearchBar from "../SearchBar";
 type Props = {
   actionConfig: ActionConfigType;
   setBranch: (x: string) => void;
+  currentBranch?: string;
 };
 
-const BranchList: React.FC<Props> = ({ setBranch, actionConfig }) => {
+const BranchList: React.FC<Props> = ({
+  setBranch,
+  actionConfig,
+  currentBranch,
+}) => {
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState(false);
   const [branches, setBranches] = useState<string[]>([]);
@@ -108,6 +113,9 @@ const BranchList: React.FC<Props> = ({ setBranch, actionConfig }) => {
         >
           <img src={branch_icon} alt={"branch icon"} />
           {branch}
+          {currentBranch === branch && (
+            <i className="material-icons-outlined">check</i>
+          )}
         </BranchName>
       );
     });
@@ -144,10 +152,6 @@ const BranchName = styled.div`
   background: #ffffff11;
   :hover {
     background: #ffffff22;
-
-    > i {
-      background: #ffffff22;
-    }
   }
 
   > img {
@@ -156,6 +160,13 @@ const BranchName = styled.div`
     margin-left: 12px;
     margin-right: 12px;
   }
+
+  > i {
+    margin-left: auto;
+    margin-right: 15px;
+    font-size: 18px;
+    color: #03b503;
+  }
 `;
 
 const LoadingWrapper = styled.div`

+ 39 - 2
dashboard/src/components/repo-selector/RepoList.tsx

@@ -19,6 +19,42 @@ type Props = {
   filteredRepos?: string[];
 };
 
+type Provider =
+  | {
+      provider: "github";
+      name: string;
+      installation_id: number;
+    }
+  | {
+      provider: "gitlab";
+      instance_url: string;
+      integration_id: number;
+    };
+
+// Sort provider by name if it's github or instance url if it's gitlab
+const sortProviders = (providers: Provider[]) => {
+  const githubProviders = providers.filter(
+    (provider) => provider.provider === "github"
+  );
+
+  const gitlabProviders = providers.filter(
+    (provider) => provider.provider === "gitlab"
+  );
+
+  const githubSortedProviders = githubProviders.sort((a, b) => {
+    if (a.provider === "github" && b.provider === "github") {
+      return a.name.localeCompare(b.name);
+    }
+  });
+
+  const gitlabSortedProviders = gitlabProviders.sort((a, b) => {
+    if (a.provider === "gitlab" && b.provider === "gitlab") {
+      return a.instance_url.localeCompare(b.instance_url);
+    }
+  });
+  return [...gitlabSortedProviders, ...githubSortedProviders];
+};
+
 const RepoList: React.FC<Props> = ({
   actionConfig,
   setActionConfig,
@@ -51,8 +87,9 @@ const RepoList: React.FC<Props> = ({
           return;
         }
 
-        setProviders(data);
-        setCurrentProvider(data[0]);
+        const sortedProviders = sortProviders(data);
+        setProviders(sortedProviders);
+        setCurrentProvider(sortedProviders[0]);
       })
       .catch((err) => {
         setHasProviders(false);

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

@@ -18,6 +18,9 @@ import yaml from "js-yaml";
 import { AxiosError } from "axios";
 import { AddCustomBuildpackForm } from "components/repo-selector/BuildpackSelection";
 import { DeviconsNameList } from "assets/devicons-name-list";
+import Selector from "components/Selector";
+import BranchList from "components/repo-selector/BranchList";
+import Banner from "components/Banner";
 
 type Buildpack = {
   name: string;
@@ -72,6 +75,42 @@ const BuildSettingsTab: React.FC<Props> = ({ chart, isPreviousVersion }) => {
     "loading" | "successful" | string
   >("");
 
+  const [currentBranch, setCurrentBranch] = useState(
+    () => chart?.git_action_config?.git_branch
+  );
+
+  const saveNewBranch = async (newBranch: string) => {
+    if (!newBranch?.length) {
+      return;
+    }
+
+    if (newBranch === chart?.git_action_config?.git_branch) {
+      return;
+    }
+
+    const newGitActionConfig: FullActionConfigType = {
+      ...chart.git_action_config,
+      git_branch: newBranch,
+    };
+
+    try {
+      api.updateGitActionConfig(
+        "<token>",
+        {
+          git_action_config: newGitActionConfig,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          release_name: chart.name,
+          namespace: chart.namespace,
+        }
+      );
+    } catch (error) {
+      throw error;
+    }
+  };
+
   const saveBuildConfig = async (config: BuildConfig) => {
     if (config === null) {
       return;
@@ -206,6 +245,7 @@ const BuildSettingsTab: React.FC<Props> = ({ chart, isPreviousVersion }) => {
     setButtonStatus("loading");
     try {
       await saveBuildConfig(buildConfig);
+      await saveNewBranch(currentBranch);
       await saveEnvVariables(envVariables);
       setButtonStatus("successful");
     } catch (error) {
@@ -220,6 +260,7 @@ const BuildSettingsTab: React.FC<Props> = ({ chart, isPreviousVersion }) => {
     setButtonStatus("loading");
     try {
       await saveBuildConfig(buildConfig);
+      await saveNewBranch(currentBranch);
       await saveEnvVariables(envVariables);
       await triggerWorkflow();
       setButtonStatus("successful");
@@ -296,6 +337,19 @@ const BuildSettingsTab: React.FC<Props> = ({ chart, isPreviousVersion }) => {
           }}
         ></KeyValueArray>
 
+        <Heading>Select Default Branch</Heading>
+        <Helper>
+          Change the default branch the deployments will be made from.
+        </Helper>
+        <Banner type="warning">
+          You must also update the deploy branch in your GitHub Action file.
+        </Banner>
+        <BranchList
+          actionConfig={currentActionConfig}
+          setBranch={setCurrentBranch}
+          currentBranch={currentBranch}
+        />
+
         {!chart.git_action_config.dockerfile_path ? (
           <>
             <Heading>Buildpack Settings</Heading>

+ 5 - 12
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -18,6 +18,7 @@ import NotificationSettingsSection from "./NotificationSettingsSection";
 import { Link } from "react-router-dom";
 import { isDeployedFromGithub } from "shared/release/utils";
 import TagSelector from "./TagSelector";
+import { PORTER_IMAGE_TEMPLATES } from "shared/common";
 
 type PropsType = {
   currentChart: ChartType;
@@ -45,13 +46,6 @@ const SettingsSection: React.FC<PropsType> = ({
   ] = useState<string>("");
   const [loadingWebhookToken, setLoadingWebhookToken] = useState<boolean>(true);
 
-  const [action, setAction] = useState<ActionConfigType>({
-    git_repo: "",
-    image_repo_uri: "",
-    git_repo_id: 0,
-    git_branch: "",
-  });
-
   const { currentCluster, currentProject, setCurrentError } = useContext(
     Context
   );
@@ -81,7 +75,6 @@ const SettingsSection: React.FC<PropsType> = ({
           return;
         }
 
-        setAction(res.data.git_action_config);
         setWebhookToken(res.data.webhook_token);
       })
       .catch(console.log)
@@ -162,7 +155,6 @@ const SettingsSection: React.FC<PropsType> = ({
       );
       setCreateWebhookButtonStatus("successful");
       setTimeout(() => {
-        setAction(res.data.git_action_config);
         setWebhookToken(res.data.webhook_token);
       }, 500);
     } catch (err) {
@@ -214,10 +206,11 @@ const SettingsSection: React.FC<PropsType> = ({
     if (!isAuthorizedToCreateWebhook) {
       buttonStatus = "Unauthorized to create webhook token";
     }
-
+    console.log(PORTER_IMAGE_TEMPLATES.includes(selectedImageUrl));
     return (
       <>
-        {!currentChart.is_stack ? (
+        {!currentChart.is_stack &&
+        !PORTER_IMAGE_TEMPLATES.includes(selectedImageUrl) ? (
           <>
             <Heading>Source Settings</Heading>
             <Helper>Specify an image tag to use.</Helper>
@@ -227,7 +220,7 @@ const SettingsSection: React.FC<PropsType> = ({
               setSelectedImageUrl={(x: string) => setSelectedImageUrl(x)}
               setSelectedTag={(x: string) => setSelectedTag(x)}
               forceExpanded={true}
-              disableImageSelect={isDeployedFromGithub(currentChart)}
+              disableImageSelect={false}
             />
             {!loadingWebhookToken && (
               <>

+ 1 - 7
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/useJobs.ts

@@ -7,13 +7,7 @@ import { ChartType, ChartTypeWithExtendedConfig } from "shared/types";
 import yaml from "js-yaml";
 import { usePrevious } from "shared/hooks/usePrevious";
 import { useRouting } from "shared/routing";
-
-const PORTER_IMAGE_TEMPLATES = [
-  "porterdev/hello-porter-job",
-  "porterdev/hello-porter-job:latest",
-  "public.ecr.aws/o1j4x7p4/hello-porter-job",
-  "public.ecr.aws/o1j4x7p4/hello-porter-job:latest",
-];
+import { PORTER_IMAGE_TEMPLATES } from "shared/common";
 
 export const useJobs = (chart: ChartType) => {
   const { currentProject, currentCluster, setCurrentError } = useContext(

+ 80 - 10
dashboard/src/main/home/cluster-dashboard/preview-environments/components/PreviewEnvironmentsHeader.tsx

@@ -1,14 +1,84 @@
-import React from "react";
+import React, { useEffect, useState } from "react";
 import styled from "styled-components";
 import DashboardHeader from "../../DashboardHeader";
 import PullRequestIcon from "assets/pull_request_icon.svg";
+import api from "shared/api";
 
-export const PreviewEnvironmentsHeader = () => (
-  <>
-    <DashboardHeader
-      image={PullRequestIcon}
-      title="Preview Environments"
-      description="Create full-stack preview environments for your pull requests."
-    />
-  </>
-);
+export const PreviewEnvironmentsHeader = () => {
+  const [githubStatus, setGithubStatus] = useState<string>(
+    "no active incidents"
+  );
+
+  useEffect(() => {
+    api
+      .getGithubStatus("<token>", {}, {})
+      .then(({ data }) => {
+        setGithubStatus(data);
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  }, []);
+
+  return (
+    <>
+      <DashboardHeader
+        image={PullRequestIcon}
+        title="Preview Environments"
+        description="Create full-stack preview environments for your pull requests."
+      />
+      {githubStatus != "no active incidents" ? (
+        <AlertCard>
+          <AlertCardIcon className="material-icons">error</AlertCardIcon>
+          <AlertCardContent className="content">
+            <AlertCardTitle className="title">
+              Github has an ongoing incident
+            </AlertCardTitle>
+            Active incident:{" "}
+            <a href={`${githubStatus}`} target="_blank">
+              {githubStatus}
+            </a>
+          </AlertCardContent>
+        </AlertCard>
+      ) : null}
+    </>
+  );
+};
+
+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;
+  margin-bottom: 20px;
+`;
+
+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;
+`;

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

@@ -31,7 +31,7 @@ const DeploymentDetail = () => {
     let environment_id = parseInt(searchParams.get("environment_id"));
     setEnvironmentId(searchParams.get("environment_id"));
     api
-      .getPRDeploymentByCluster(
+      .getPRDeploymentByEnvironment(
         "<token>",
         {
           namespace: params.namespace,

+ 0 - 153
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack.tsx

@@ -1,153 +0,0 @@
-import Loading from "components/Loading";
-import TitleSection from "components/TitleSection";
-import React, { useContext, useEffect, useState } from "react";
-import { useParams } from "react-router";
-import api from "shared/api";
-import { Context } from "shared/Context";
-import { readableDate } from "shared/string_utils";
-import styled from "styled-components";
-import ChartList from "../chart/ChartList";
-import SortSelector from "../SortSelector";
-import Status from "./components/Status";
-import {
-  Br,
-  InfoWrapper,
-  LastDeployed,
-  LineBreak,
-  SepDot,
-  Text,
-} from "./components/styles";
-import { getStackStatus, getStackStatusMessage } from "./shared";
-import { Stack } from "./types";
-
-const ExpandedStack = () => {
-  const { namespace, stack_id } = useParams<{
-    namespace: string;
-    stack_id: string;
-  }>();
-  const { currentProject, currentCluster } = useContext(Context);
-
-  const [stack, setStack] = useState<Stack>();
-  const [sortType, setSortType] = useState("Alphabetical");
-  const [isLoading, setIsLoading] = useState(true);
-
-  useEffect(() => {
-    console.log(stack_id);
-    let isSubscribed = true;
-
-    api
-      .getStack(
-        "<token>",
-        {},
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-          stack_id: stack_id,
-          namespace,
-        }
-      )
-      .then((res) => {
-        if (isSubscribed) {
-          setStack(res.data);
-        }
-      })
-      .finally(() => {
-        if (isSubscribed) {
-          setIsLoading(false);
-        }
-      });
-  }, [stack_id]);
-
-  if (isLoading) {
-    return <Loading />;
-  }
-
-  return (
-    <div>
-      <TitleSection
-        materialIconClass="material-icons-outlined"
-        icon={"lan"}
-        capitalize
-      >
-        {stack.name}
-      </TitleSection>
-      <Br />
-      <InfoWrapper>
-        <LastDeployed>
-          <Status
-            status={getStackStatus(stack)}
-            message={getStackStatusMessage(stack)}
-          />
-          <SepDot>•</SepDot>
-          <Text color="#aaaabb">
-            {!stack.latest_revision?.id
-              ? `No version found`
-              : `v${stack.latest_revision.id}`}
-          </Text>
-          <SepDot>•</SepDot>
-          Last updated {readableDate(stack.updated_at)}
-        </LastDeployed>
-      </InfoWrapper>
-
-      {/* Stack error message */}
-      {stack.latest_revision &&
-      stack.latest_revision.status === "failed" &&
-      stack.latest_revision.message?.length > 0 ? (
-        <StackErrorMessageStyles.Wrapper>
-          <StackErrorMessageStyles.Title color="#b7b7c9">
-            Error reason:
-          </StackErrorMessageStyles.Title>
-          <StackErrorMessageStyles.Text color="#aaaabb">
-            {stack.latest_revision.message}
-          </StackErrorMessageStyles.Text>
-        </StackErrorMessageStyles.Wrapper>
-      ) : null}
-
-      <LineBreak />
-
-      <SortSelector
-        setSortType={setSortType}
-        sortType={sortType}
-        currentView="stacks"
-      />
-
-      <ChartListWrapper>
-        <ChartList
-          currentCluster={currentCluster}
-          currentView="stacks"
-          namespace={namespace}
-          sortType="Alphabetical"
-          appFilters={
-            stack?.latest_revision?.resources?.map((res) => res.name) || []
-          }
-          closeChartRedirectUrl={`${window.location.pathname}${window.location.search}`}
-        />
-      </ChartListWrapper>
-    </div>
-  );
-};
-
-export default ExpandedStack;
-
-const ChartListWrapper = styled.div`
-  width: 100%;
-  margin: auto;
-  margin-top: 20px;
-  padding-bottom: 125px;
-`;
-
-const StackErrorMessageStyles = {
-  Text: styled(Text)`
-    font-size: 14px;
-    margin-bottom: 10px;
-  `,
-  Wrapper: styled.div`
-    display: flex;
-    flex-direction: column;
-    margin-top: 5px;
-  `,
-  Title: styled(Text)`
-    font-size: 16px;
-    font-weight: bold;
-  `,
-};

+ 243 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx

@@ -0,0 +1,243 @@
+import Loading from "components/Loading";
+import Placeholder from "components/Placeholder";
+import TabSelector from "components/TabSelector";
+import TitleSection from "components/TitleSection";
+import React, { useContext, useEffect, useState } from "react";
+import { useParams } from "react-router";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import { readableDate } from "shared/string_utils";
+import styled from "styled-components";
+import ChartList from "../../chart/ChartList";
+import SortSelector from "../../SortSelector";
+import Status from "../components/Status";
+import {
+  Br,
+  InfoWrapper,
+  LastDeployed,
+  LineBreak,
+  NamespaceTag,
+  SepDot,
+  Text,
+} from "../components/styles";
+import { getStackStatus, getStackStatusMessage } from "../shared";
+import { FullStackRevision, Stack, StackRevision } from "../types";
+import RevisionList from "./_RevisionList";
+import SourceConfig from "./_SourceConfig";
+
+const ExpandedStack = () => {
+  const { namespace, stack_id } = useParams<{
+    namespace: string;
+    stack_id: string;
+  }>();
+
+  const { pushFiltered } = useRouting();
+
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+
+  const [stack, setStack] = useState<Stack>();
+  const [sortType, setSortType] = useState("Alphabetical");
+  const [isLoading, setIsLoading] = useState(true);
+  const [currentTab, setCurrentTab] = useState("apps");
+
+  const [currentRevision, setCurrentRevision] = useState<FullStackRevision>();
+
+  const getStack = async () => {
+    setIsLoading(true);
+    try {
+      const newStack = await api
+        .getStack<Stack>(
+          "<token>",
+          {},
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+            stack_id: stack_id,
+            namespace,
+          }
+        )
+        .then((res) => res.data);
+
+      setStack(newStack);
+      setCurrentRevision(newStack.latest_revision);
+      setIsLoading(false);
+    } catch (error) {
+      setCurrentError(error);
+      pushFiltered("/stacks", []);
+    }
+  };
+
+  useEffect(() => {
+    getStack();
+  }, [stack_id]);
+
+  if (isLoading) {
+    return <Loading />;
+  }
+
+  return (
+    <div>
+      <StackTitleWrapper>
+        <TitleSection
+          materialIconClass="material-icons-outlined"
+          icon={"lan"}
+          capitalize
+        >
+          {stack.name}
+        </TitleSection>
+        <NamespaceTag.Wrapper>
+          Namespace
+          <NamespaceTag.Tag>{stack.namespace}</NamespaceTag.Tag>
+        </NamespaceTag.Wrapper>
+      </StackTitleWrapper>
+      <RevisionList
+        revisions={stack.revisions}
+        currentRevision={currentRevision}
+        latestRevision={stack.latest_revision}
+        stackId={stack.id}
+        stackNamespace={namespace}
+        onRevisionClick={(revision) => setCurrentRevision(revision)}
+        onRollback={() => getStack()}
+      ></RevisionList>
+      <Br />
+      <InfoWrapper>
+        <LastDeployed>
+          <Status
+            status={getStackStatus(stack)}
+            message={getStackStatusMessage(stack)}
+          />
+          <SepDot>•</SepDot>
+          <Text color="#aaaabb">
+            {!stack.latest_revision?.id
+              ? `No version found`
+              : `v${stack.latest_revision.id}`}
+          </Text>
+          <SepDot>•</SepDot>
+          Last updated {readableDate(stack.updated_at)}
+        </LastDeployed>
+      </InfoWrapper>
+
+      {/* Stack error message */}
+      {stack.latest_revision &&
+      stack.latest_revision.status === "failed" &&
+      stack.latest_revision.message?.length > 0 ? (
+        <StackErrorMessageStyles.Wrapper>
+          <StackErrorMessageStyles.Title color="#b7b7c9">
+            Error reason:
+          </StackErrorMessageStyles.Title>
+          <StackErrorMessageStyles.Text color="#aaaabb">
+            {stack.latest_revision.message}
+          </StackErrorMessageStyles.Text>
+        </StackErrorMessageStyles.Wrapper>
+      ) : null}
+
+      <TabSelector
+        currentTab={currentTab}
+        options={[
+          {
+            label: "Apps",
+            value: "apps",
+            component: (
+              <>
+                <Gap></Gap>
+                {currentRevision.id !== stack.latest_revision.id ? (
+                  <ChartListWrapper>
+                    <Placeholder>
+                      Not available when previewing versions
+                    </Placeholder>
+                  </ChartListWrapper>
+                ) : (
+                  <>
+                    <SortSelector
+                      setSortType={setSortType}
+                      sortType={sortType}
+                      currentView="stacks"
+                    />
+
+                    <ChartListWrapper>
+                      <ChartList
+                        currentCluster={currentCluster}
+                        currentView="stacks"
+                        namespace={namespace}
+                        sortType="Alphabetical"
+                        appFilters={
+                          stack?.latest_revision?.resources?.map(
+                            (res) => res.name
+                          ) || []
+                        }
+                        closeChartRedirectUrl={`${window.location.pathname}${window.location.search}`}
+                      />
+                    </ChartListWrapper>
+                  </>
+                )}
+              </>
+            ),
+          },
+          {
+            label: "Source Config",
+            value: "source_config",
+            component: (
+              <>
+                <SourceConfig
+                  namespace={namespace}
+                  revision={currentRevision}
+                  readOnly={stack.latest_revision.id !== currentRevision.id}
+                  onSourceConfigUpdate={() => getStack()}
+                ></SourceConfig>
+              </>
+            ),
+          },
+        ]}
+        setCurrentTab={(tab) => {
+          setCurrentTab(tab);
+        }}
+      ></TabSelector>
+    </div>
+  );
+};
+
+export default ExpandedStack;
+
+const ChartListWrapper = styled.div`
+  width: 100%;
+  margin: auto;
+  margin-top: 20px;
+  padding-bottom: 125px;
+`;
+
+const Gap = styled.div`
+  width: 100%;
+  background: none;
+  height: 30px;
+`;
+
+const StackErrorMessageStyles = {
+  Text: styled(Text)`
+    font-size: 14px;
+    margin-bottom: 10px;
+  `,
+  Wrapper: styled.div`
+    display: flex;
+    flex-direction: column;
+    margin-top: 5px;
+  `,
+  Title: styled(Text)`
+    font-size: 16px;
+    font-weight: bold;
+  `,
+};
+
+const StackTitleWrapper = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+
+  // Hotfix to make sure the title section and the namespace tag are aligned
+  ${NamespaceTag.Wrapper} {
+    margin-bottom: 15px;
+  }
+`;

+ 293 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_RevisionList.tsx

@@ -0,0 +1,293 @@
+import Loading from "components/Loading";
+import React, { useContext, useRef, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { readableDate } from "shared/string_utils";
+import styled from "styled-components";
+import { FullStackRevision, Stack, StackRevision } from "../types";
+
+type RevisionListProps = {
+  revisions: StackRevision[];
+  currentRevision: StackRevision;
+  latestRevision: StackRevision;
+  stackNamespace: string;
+  stackId: string;
+  onRevisionClick: (revision: FullStackRevision) => void;
+  onRollback: () => void;
+};
+
+const _RevisionList = ({
+  revisions,
+  currentRevision,
+  latestRevision,
+  stackNamespace,
+  stackId,
+  onRevisionClick,
+  onRollback,
+}: RevisionListProps) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [isLoading, setIsLoading] = useState(false);
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  const revisionCache = useRef<{ [id: number]: FullStackRevision }>({});
+
+  const handleRevisionPreview = (revision: StackRevision) => {
+    setIsLoading(true);
+
+    if (revisionCache.current[revision.id]) {
+      onRevisionClick(revisionCache.current[revision.id]);
+      setIsLoading(false);
+      return;
+    }
+
+    api
+      .getStackRevision<FullStackRevision>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: stackNamespace,
+          revision_id: revision.id,
+          stack_id: stackId,
+        }
+      )
+      .then((res) => {
+        const newRevision = res.data;
+        revisionCache.current = {
+          ...revisionCache.current,
+          [newRevision.id]: newRevision,
+        };
+        onRevisionClick(newRevision);
+      })
+      .catch((err) => {
+        setCurrentError(err);
+      })
+      .finally(() => {
+        setIsLoading(false);
+      });
+  };
+
+  const handleRevisionRollback = (revision: StackRevision) => {
+    setIsLoading(true);
+
+    api
+      .rollbackStack(
+        "<token>",
+        {
+          target_revision: revision.id,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: stackNamespace,
+          stack_id: stackId,
+        }
+      )
+      .then(() => {
+        onRollback();
+      })
+      .catch((err) => {
+        setCurrentError(err);
+      })
+      .finally(() => {
+        setIsLoading(false);
+      });
+  };
+
+  const revisionList = () => {
+    if (revisions.length === 0) {
+      return <div>No revisions</div>;
+    }
+
+    return revisions.map((revision, i) => {
+      let isCurrent = latestRevision.id === revision.id;
+      return (
+        <Tr
+          key={i}
+          onClick={() => handleRevisionPreview(revision)}
+          selected={currentRevision.id === revision.id}
+        >
+          <Td>{revision.id}</Td>
+          <Td>{readableDate(revision.created_at)}</Td>
+          <Td>
+            <RollbackButton
+              disabled={isCurrent}
+              onClick={(e) => {
+                e.stopPropagation();
+                handleRevisionRollback(revision);
+              }}
+            >
+              {isCurrent ? "Current" : "Revert"}
+            </RollbackButton>
+          </Td>
+        </Tr>
+      );
+    });
+  };
+
+  return (
+    <>
+      <StyledRevisionSection showRevisions={isExpanded}>
+        {isLoading ? (
+          <LoadingOverlay>
+            <Loading />
+          </LoadingOverlay>
+        ) : null}
+        <RevisionHeader
+          showRevisions={isExpanded}
+          isCurrent={currentRevision.id === latestRevision.id}
+          onClick={() => setIsExpanded((prev) => !prev)}
+        >
+          <RevisionPreview>
+            {currentRevision.id === latestRevision.id
+              ? `Current Revision v${currentRevision.id}`
+              : `Previewing Revision (Not Deployed) v${currentRevision.id}`}
+            <i className="material-icons">arrow_drop_down</i>
+          </RevisionPreview>
+        </RevisionHeader>
+        <TableWrapper>
+          <RevisionsTable>
+            <tbody>
+              <Tr disableHover={true}>
+                <Th>Revision No.</Th>
+                <Th>Timestamp</Th>
+                <Th>Rollback</Th>
+              </Tr>
+              {revisionList()}
+            </tbody>
+          </RevisionsTable>
+        </TableWrapper>
+      </StyledRevisionSection>
+    </>
+  );
+};
+
+export default _RevisionList;
+
+const StyledRevisionSection = styled.div`
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  width: 100%;
+  max-height: ${(props: { showRevisions: boolean }) =>
+    props.showRevisions ? "255px" : "40px"};
+  background: #ffffff11;
+  margin: 25px 0px 18px;
+  overflow: hidden;
+  border-radius: 8px;
+  animation: ${(props: { showRevisions: boolean }) =>
+    props.showRevisions ? "expandRevisions 0.3s " : ""};
+  animation-timing-function: "ease-out";
+  @keyframes expandRevisions {
+    from {
+      max-height: 40px;
+    }
+    to {
+      max-height: 250px;
+    }
+  }
+`;
+
+const RevisionHeader = styled.div`
+  color: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
+    props.isCurrent ? "#ffffff66" : "#f5cb42"};
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  min-height: 40px;
+  font-size: 13px;
+  width: 100%;
+  padding-left: 15px;
+  cursor: pointer;
+  background: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
+    props.showRevisions ? "#ffffff11" : ""};
+  :hover {
+    background: #ffffff18;
+    > div > i {
+      background: #ffffff22;
+    }
+  }
+
+  > div > i {
+    margin-left: 12px;
+    font-size: 20px;
+    cursor: pointer;
+    border-radius: 20px;
+    background: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
+      props.showRevisions ? "#ffffff18" : ""};
+    transform: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
+      props.showRevisions ? "rotate(180deg)" : ""};
+  }
+`;
+
+const RevisionPreview = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const TableWrapper = styled.div`
+  padding-bottom: 20px;
+  overflow-y: auto;
+`;
+
+const RevisionsTable = styled.table`
+  width: 100%;
+  margin-top: 5px;
+  padding-left: 32px;
+  padding-bottom: 20px;
+  min-width: 500px;
+  border-collapse: collapse;
+`;
+const Tr = styled.tr`
+  line-height: 2.2em;
+  cursor: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+    props.disableHover ? "" : "pointer"};
+  background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+    props.selected ? "#ffffff11" : ""};
+  :hover {
+    background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+      props.disableHover ? "" : "#ffffff22"};
+  }
+`;
+
+const Td = styled.td`
+  font-size: 13px;
+  color: #ffffff;
+  padding-left: 32px;
+`;
+
+const Th = styled.td`
+  font-size: 13px;
+  font-weight: 500;
+  color: #aaaabb;
+  padding-left: 32px;
+`;
+
+const RollbackButton = styled.div`
+  cursor: ${(props: { disabled: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+  display: flex;
+  border-radius: 3px;
+  align-items: center;
+  justify-content: center;
+  font-weight: 500;
+  height: 21px;
+  font-size: 13px;
+  width: 70px;
+  background: ${(props: { disabled: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled: boolean }) =>
+      props.disabled ? "" : "#405eddbb"};
+  }
+`;
+
+const LoadingOverlay = styled.div`
+  background: #43454b90;
+  width: 100%;
+  height: 100%;
+  position: absolute;
+`;

+ 180 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_SourceConfig.tsx

@@ -0,0 +1,180 @@
+import { Tooltip } from "@material-ui/core";
+import ImageSelector from "components/image-selector/ImageSelector";
+import SaveButton from "components/SaveButton";
+import React, { useContext, useMemo, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+import { AppResource, FullStackRevision, SourceConfig, Stack } from "../types";
+import SourceEditorDocker from "./components/SourceEditorDocker";
+
+const _SourceConfig = ({
+  namespace,
+  revision,
+  readOnly,
+  onSourceConfigUpdate,
+}: {
+  namespace: string;
+  revision: FullStackRevision;
+  readOnly: boolean;
+  onSourceConfigUpdate: () => void;
+}) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [sourceConfigArrayCopy, setSourceConfigArrayCopy] = useState<
+    SourceConfig[]
+  >(() => revision.source_configs);
+  const [buttonStatus, setButtonStatus] = useState("");
+
+  const handleChange = (sourceConfig: SourceConfig) => {
+    const newSourceConfigArray = [...sourceConfigArrayCopy];
+    const index = newSourceConfigArray.findIndex(
+      (sc) => sc.id === sourceConfig.id
+    );
+    newSourceConfigArray[index] = sourceConfig;
+    setSourceConfigArrayCopy(newSourceConfigArray);
+  };
+
+  const handleSave = () => {
+    setButtonStatus("loading");
+    api
+      .updateStackSourceConfig(
+        "<token>",
+        {
+          source_configs: sourceConfigArrayCopy,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: namespace,
+          stack_id: revision.stack_id,
+        }
+      )
+      .then(() => {
+        setButtonStatus("successful");
+        onSourceConfigUpdate();
+      })
+      .catch((err) => {
+        setButtonStatus("Something went wrong");
+        setCurrentError(err);
+      });
+  };
+
+  return (
+    <SourceConfigStyles.Wrapper>
+      {revision.source_configs.map((sourceConfig) => {
+        const apps = getAppsFromSourceConfig(revision.resources, sourceConfig);
+
+        const appList = formatAppList(apps, 2);
+        return (
+          <SourceConfigStyles.ItemContainer>
+            {appList.hiddenApps?.length ? (
+              <Tooltip
+                title={
+                  <>
+                    {appList.hiddenApps.map((appName) => (
+                      <SourceConfigStyles.TooltipItem>
+                        {appName}
+                      </SourceConfigStyles.TooltipItem>
+                    ))}
+                  </>
+                }
+                placement={"bottom-end"}
+              >
+                <SourceConfigStyles.ItemTitle>
+                  Used by {appList.value}
+                </SourceConfigStyles.ItemTitle>
+              </Tooltip>
+            ) : (
+              <SourceConfigStyles.ItemTitle>
+                Used by {appList.value}
+              </SourceConfigStyles.ItemTitle>
+            )}
+            <SourceEditorDocker
+              sourceConfig={sourceConfig}
+              onChange={handleChange}
+              readOnly={readOnly || buttonStatus === "loading"}
+            />
+          </SourceConfigStyles.ItemContainer>
+        );
+      })}
+      {readOnly ? null : (
+        <SourceConfigStyles.SaveButtonRow>
+          <SourceConfigStyles.SaveButton
+            onClick={handleSave}
+            text="Save"
+            clearPosition={true}
+            makeFlush={true}
+            status={buttonStatus}
+            statusPosition="left"
+          />
+        </SourceConfigStyles.SaveButtonRow>
+      )}
+    </SourceConfigStyles.Wrapper>
+  );
+};
+
+export default _SourceConfig;
+
+const getAppsFromSourceConfig = (
+  apps: AppResource[],
+  sourceConfig: SourceConfig
+) => {
+  return apps.filter((app) => {
+    return app.stack_source_config.id === sourceConfig.id;
+  });
+};
+
+const formatAppList = (apps: AppResource[], limit: number = 3) => {
+  if (apps.length <= limit) {
+    const formatter = new Intl.ListFormat("en", {
+      style: "long",
+      type: "conjunction",
+    });
+    return {
+      value: formatter.format(apps.map((app) => app.name)),
+      hiddenApps: [],
+    };
+  }
+
+  const hiddenApps = [...apps]
+    .splice(limit, apps.length)
+    .map((app) => app.name);
+
+  return {
+    value: apps
+      .map((app) => app.name)
+      .splice(0, limit)
+      .join(", ")
+      .concat(` and ${apps.length - limit} more`),
+    hiddenApps,
+  };
+};
+
+const SourceConfigStyles = {
+  Wrapper: styled.div`
+    margin-top: 30px;
+    position: relative;
+  `,
+  ItemContainer: styled.div`
+    background: #ffffff11;
+    border-radius: 15px;
+    padding: 20px 15px;
+  `,
+  ItemTitle: styled.div`
+    font-size: 16px;
+    width: fit-content;
+  `,
+  TooltipItem: styled.div`
+    font-size: 14px;
+  `,
+  SaveButtonRow: styled.div`
+    margin-top: 15px;
+    display: flex;
+    justify-content: flex-end;
+  `,
+  SaveButton: styled(SaveButton)`
+    z-index: unset;
+  `,
+};

+ 264 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/Select.tsx

@@ -0,0 +1,264 @@
+import Loading from "components/Loading";
+import React, { useRef, useState } from "react";
+import { useOutsideAlerter } from "shared/hooks/useOutsideAlerter";
+import styled from "styled-components";
+
+export type SelectProps<T> = {
+  value: T;
+  options: T[];
+  accessor: (option: T) => string | React.ReactNode;
+  onChange: (value: T) => void;
+  isOptionEqualToValue?: (option: T, value: T) => boolean;
+  label: string;
+  isLoading?: boolean;
+  dropdown?: {
+    maxH?: string;
+    width?: string;
+    label?: string;
+    option?: {
+      height?: string;
+    };
+  };
+  placeholder: string;
+  className?: string;
+  readOnly?: boolean;
+};
+
+const Select = <T extends unknown>({
+  value,
+  options,
+  accessor,
+  onChange,
+  isOptionEqualToValue,
+  label,
+  isLoading,
+  placeholder,
+  dropdown,
+  className,
+  readOnly,
+}: SelectProps<T>) => {
+  const wrapperRef = useRef();
+  const [expanded, setExpanded] = useState(false);
+
+  useOutsideAlerter(wrapperRef, () => {
+    setExpanded(false);
+  });
+
+  const handleOptionClick = (value: T) => {
+    setExpanded(false);
+    onChange(value);
+  };
+
+  const getLabel = () => {
+    if (label) {
+      return <SelectStyles.Label> {label} </SelectStyles.Label>;
+    }
+    return null;
+  };
+
+  if (isLoading) {
+    return (
+      <div>
+        {getLabel()}
+        <SelectStyles.Wrapper>
+          <SelectStyles.Selector
+            className={className}
+            expanded={false}
+            readOnly={readOnly}
+          >
+            <SelectStyles.Loading>
+              <Loading />
+            </SelectStyles.Loading>
+          </SelectStyles.Selector>
+        </SelectStyles.Wrapper>
+      </div>
+    );
+  }
+
+  const isSelected = (option: T, value: T) => {
+    if (!value) {
+      return false;
+    }
+
+    if (isOptionEqualToValue) {
+      return isOptionEqualToValue(option, value);
+    }
+  };
+
+  return (
+    <div>
+      {getLabel()}
+      <SelectStyles.Wrapper ref={wrapperRef}>
+        <SelectStyles.Selector
+          className={className}
+          onClick={() => setExpanded(!expanded)}
+          expanded={expanded}
+          readOnly={readOnly}
+        >
+          <SelectStyles.CurrentValue>
+            <span>{value ? accessor(value) : placeholder}</span>
+          </SelectStyles.CurrentValue>
+          {readOnly ? null : <i className="material-icons">arrow_drop_down</i>}
+        </SelectStyles.Selector>
+        {expanded && !readOnly ? (
+          <SelectStyles.Dropdown.Wrapper
+            width={dropdown?.width}
+            maxH={dropdown?.maxH}
+          >
+            {dropdown?.label && (
+              <SelectStyles.Dropdown.Label>
+                {dropdown?.label}
+              </SelectStyles.Dropdown.Label>
+            )}
+            {options.length > 0 ? (
+              <>
+                {options.map((option, i) => (
+                  <SelectStyles.Dropdown.Option
+                    key={i}
+                    onClick={() => !readOnly && handleOptionClick(option)}
+                    lastItem={i === options.length - 1}
+                    selected={isSelected(option, value)}
+                    height={dropdown?.option?.height}
+                  >
+                    {accessor(option)}
+                  </SelectStyles.Dropdown.Option>
+                ))}
+              </>
+            ) : (
+              <SelectStyles.Dropdown.NoOptions>
+                No options available
+              </SelectStyles.Dropdown.NoOptions>
+            )}
+          </SelectStyles.Dropdown.Wrapper>
+        ) : null}
+      </SelectStyles.Wrapper>
+    </div>
+  );
+};
+
+export default Select;
+
+export const SelectStyles = {
+  Wrapper: styled.div`
+    position: relative;
+  `,
+  Label: styled.div`
+    color: #ffffff;
+    margin-bottom: 10px;
+    margin-top: 20px;
+    font-size: 13px;
+  `,
+
+  Selector: styled.div<{ expanded: boolean; readOnly: boolean }>`
+    height: 35px;
+    border: 1px solid #ffffff55;
+    font-size: 13px;
+    padding: 5px 10px;
+    padding-left: 15px;
+    border-radius: 3px;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    cursor: ${(props) => (props.readOnly ? "normal" : "pointer")};
+    background: ${(props) => {
+      if (props.readOnly) {
+        return "#ffffff55";
+      }
+
+      if (props.expanded) {
+        return "#ffffff33";
+      }
+      return "#ffffff11";
+    }};
+
+    :hover {
+      background: ${(props) => {
+        if (props.readOnly) {
+          return "#ffffff55";
+        }
+
+        if (props.expanded) {
+          return "#ffffff33";
+        }
+        return "#ffffff22";
+      }};
+    }
+
+    > i {
+      font-size: 20px;
+      transform: ${(props) => (props.expanded ? "rotate(180deg)" : "")};
+    }
+  `,
+
+  Loading: styled.div`
+    width: 100%;
+  `,
+
+  CurrentValue: styled.div`
+    display: flex;
+    align-items: center;
+    width: 85%;
+
+    > span {
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      z-index: 0;
+    }
+  `,
+
+  Dropdown: {
+    Wrapper: styled.div<{ width: string; maxH?: string }>`
+      background: #26282f;
+      width: ${(props) => props.width || "100%"};
+      max-height: ${(props) => props.maxH || "300px"};
+      border-radius: 3px;
+      z-index: 999;
+      overflow-y: auto;
+      margin-bottom: 20px;
+      box-shadow: 0 8px 20px 0px #00000088;
+      position: absolute;
+    `,
+    Option: styled.div<{
+      selected: boolean;
+      lastItem: boolean;
+      height?: string;
+    }>`
+      width: 100%;
+      border-top: 1px solid #00000000;
+      border-bottom: 1px solid
+        ${(props) => (props.lastItem ? "#ffffff00" : "#ffffff15")};
+      height: ${(props) => props.height || "37px"};
+      font-size: 13px;
+      align-items: center;
+      display: flex;
+      align-items: center;
+      padding-left: 15px;
+      cursor: pointer;
+      padding-right: 10px;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      background: ${(props) => (props.selected ? "#ffffff11" : "")};
+
+      :hover {
+        background: #ffffff22;
+      }
+    `,
+    Label: styled.div`
+      font-size: 13px;
+      color: #ffffff44;
+      font-weight: 500;
+      margin: 10px 13px;
+    `,
+    NoOptions: styled.div`
+      font-size: 13px;
+      color: #ffffff44;
+      font-weight: 500;
+      margin: 10px 13px;
+      :not(:first-child) {
+        border-top: 1px solid #ffffff15;
+      }
+    `,
+  },
+};

+ 328 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/SourceEditorDocker.tsx

@@ -0,0 +1,328 @@
+import SelectRow from "components/form-components/SelectRow";
+import SearchSelector from "components/SearchSelector";
+import Selector from "components/Selector";
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useOutsideAlerter } from "shared/hooks/useOutsideAlerter";
+import styled from "styled-components";
+import { proxy, useSnapshot } from "valtio";
+import { SourceConfig } from "../../types";
+import Select from "./Select";
+
+const SourceEditorDocker = ({
+  sourceConfig,
+  onChange,
+  readOnly = false,
+}: {
+  readOnly: boolean;
+  sourceConfig: SourceConfig;
+  onChange: (sourceConfig: SourceConfig) => void;
+}) => {
+  const [registry, setRegistry] = useState<DockerRegistry | null>(null);
+  const [image, setImage] = useState<string | null>(
+    () => sourceConfig.image_repo_uri
+  );
+  const [tag, setTag] = useState<string | null>(() => sourceConfig.image_tag);
+
+  const imageName = useMemo(() => {
+    if (!registry) {
+      return "";
+    }
+
+    if (!image) {
+      return "";
+    }
+
+    return image.replace(registry.url + "/", "");
+  }, [image, registry]);
+
+  useEffect(() => {
+    if (sourceConfig.image_repo_uri) {
+      setImage(sourceConfig.image_repo_uri);
+      setTag(sourceConfig.image_tag);
+    }
+  }, [sourceConfig]);
+
+  useEffect(() => {
+    const newSourceConfig: SourceConfig = {
+      ...sourceConfig,
+      image_repo_uri: image,
+      image_tag: tag,
+    };
+
+    onChange(newSourceConfig);
+  }, [image, tag]);
+
+  return (
+    <>
+      <SourceEditorDockerStlyes.RegistryWrapper>
+        <_DockerRepositorySelector
+          currentImageUrl={sourceConfig.image_repo_uri}
+          value={registry}
+          onChange={setRegistry}
+          readOnly={readOnly}
+        />
+      </SourceEditorDockerStlyes.RegistryWrapper>
+      {registry && (
+        <SourceEditorDockerStlyes.ImageAndTagWrapper>
+          <_ImageSelector
+            registry={registry}
+            value={image}
+            onChange={setImage}
+            readOnly={readOnly}
+          />
+
+          {registry && imageName && (
+            <_TagSelector
+              registry={registry}
+              imageName={imageName}
+              value={tag}
+              onChange={setTag}
+              readOnly={readOnly}
+            />
+          )}
+        </SourceEditorDockerStlyes.ImageAndTagWrapper>
+      )}
+    </>
+  );
+};
+
+type DockerRegistry = {
+  id: number;
+  project_id: number;
+  name: string;
+  url: string;
+  service: string;
+  infra_id: number;
+  aws_integration_id: number;
+};
+
+const _DockerRepositorySelector = ({
+  currentImageUrl,
+  value,
+  onChange,
+  readOnly,
+}: {
+  currentImageUrl: string;
+  value: DockerRegistry;
+  onChange: (newRegistry: DockerRegistry) => void;
+  readOnly: boolean;
+}) => {
+  const { currentProject } = useContext(Context);
+
+  const [registries, setRegistries] = useState<DockerRegistry[]>([]);
+  const [isLoading, setIsLoading] = useState(true);
+
+  useEffect(() => {
+    api
+      .getProjectRegistries<DockerRegistry[]>(
+        "<token>",
+        {},
+        {
+          id: currentProject.id,
+        }
+      )
+      .then(({ data }) => {
+        setRegistries(data);
+        if (!value) {
+          const currentRegistry = data.find((r) =>
+            currentImageUrl.includes(r.url)
+          );
+          onChange(currentRegistry);
+        }
+        setIsLoading(false);
+      });
+  }, [currentImageUrl]);
+
+  const handleChange = (newRegistry: DockerRegistry) => {
+    onChange(newRegistry);
+  };
+
+  return (
+    <>
+      <Select
+        value={value}
+        options={registries}
+        onChange={handleChange}
+        accessor={(val) => val.name}
+        label="Docker Registry"
+        placeholder="Select a registry"
+        isOptionEqualToValue={(a, b) => a?.url === b?.url}
+        readOnly={readOnly}
+        isLoading={isLoading}
+        dropdown={{
+          maxH: "200px",
+        }}
+      />
+    </>
+  );
+};
+
+type ImageRepo = {
+  name: string;
+  created_at: string;
+  uri: string;
+};
+
+const _ImageSelector = ({
+  registry,
+  value,
+  onChange,
+  readOnly,
+}: {
+  registry: DockerRegistry;
+  value: string;
+  onChange: (newValue: string) => void;
+  readOnly: boolean;
+}) => {
+  const { currentProject } = useContext(Context);
+
+  const [images, setImages] = useState<ImageRepo[]>([]);
+  const [isLoading, setIsLoading] = useState(true);
+
+  useEffect(() => {
+    setIsLoading(true);
+    api
+      .getImageRepos<ImageRepo[]>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          registry_id: registry.id,
+        }
+      )
+      .then(({ data }) => {
+        setImages(data);
+
+        if (!value) {
+          onChange(data[0].uri);
+        }
+        setIsLoading(false);
+      });
+  }, []);
+
+  const handleChange = (image: string) => {
+    onChange(image);
+  };
+
+  const displayName = (imageUrl: string) => {
+    const image = images.find((i) => i.uri === imageUrl);
+    if (!image) {
+      return imageUrl;
+    }
+    return image.name;
+  };
+
+  return (
+    <Select
+      value={value}
+      options={images.map((image) => image.uri)}
+      accessor={displayName}
+      label="Image"
+      placeholder="Select an image"
+      onChange={handleChange}
+      isOptionEqualToValue={(a, b) => a === b}
+      readOnly={readOnly}
+      isLoading={isLoading}
+      dropdown={{
+        maxH: "200px",
+      }}
+    />
+  );
+};
+
+type DockerImageTag = {
+  digest: string;
+  tag: string;
+  manifest: string;
+  repository_name: string;
+  pushed_at: string;
+};
+
+const _TagSelector = ({
+  registry,
+  imageName,
+  value,
+  onChange,
+  readOnly,
+}: {
+  registry: DockerRegistry;
+  imageName: string;
+  value: string;
+  onChange: (newTag: string) => void;
+  readOnly: boolean;
+}) => {
+  const { currentProject } = useContext(Context);
+  const [imageTags, setImageTags] = useState<string[]>([]);
+  const [isLoading, setIsLoading] = useState(true);
+
+  useEffect(() => {
+    setIsLoading(true);
+    api
+      .getImageTags<DockerImageTag[]>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          registry_id: registry?.id,
+          repo_name: imageName,
+        }
+      )
+      .then(({ data }) => {
+        if (!data?.length) {
+          setImageTags([]);
+          onChange("");
+          setIsLoading(false);
+          return;
+        }
+
+        const sortedTags = data.sort((a, b) => {
+          const aDate = new Date(a.pushed_at);
+          const bDate = new Date(b.pushed_at);
+          return bDate.getTime() - aDate.getTime();
+        });
+        setImageTags(sortedTags.map((tag) => tag.tag));
+
+        if (sortedTags.map((tag) => tag.tag).includes(value)) {
+          onChange(value);
+        } else {
+          onChange(sortedTags[0].tag);
+        }
+
+        setIsLoading(false);
+      });
+  }, [registry, imageName]);
+
+  const handleChange = (tag: string) => {
+    onChange(tag);
+  };
+
+  return (
+    <Select
+      value={value}
+      options={imageTags}
+      accessor={(tag) => tag}
+      label="Tag"
+      placeholder="Select a tag"
+      onChange={handleChange}
+      readOnly={readOnly}
+      isLoading={isLoading}
+      dropdown={{
+        maxH: "200px",
+      }}
+    />
+  );
+};
+
+export default SourceEditorDocker;
+
+const SourceEditorDockerStlyes = {
+  RegistryWrapper: styled.div``,
+  ImageAndTagWrapper: styled.div`
+    display: grid;
+    grid-template-columns: 3fr 1fr;
+    grid-gap: 10px;
+    align-items: center;
+  `,
+};

+ 25 - 9
dashboard/src/main/home/cluster-dashboard/stacks/_StackList.tsx

@@ -13,6 +13,7 @@ import {
   Flex,
   InfoWrapper,
   LastDeployed,
+  NamespaceTag,
   SepDot,
   Text,
 } from "./components/styles";
@@ -80,6 +81,10 @@ const StackList = ({ namespace }: { namespace: string }) => {
           setIsLoading(false);
         }
       });
+
+    return () => {
+      isSubscribed = false;
+    };
   }, [namespace]);
 
   if (isLoading) {
@@ -104,16 +109,22 @@ const StackList = ({ namespace }: { namespace: string }) => {
           <StackCard
             as={DynamicLink}
             key={stack?.id}
-            to={`/stacks/${namespace}/${stack?.id}`}
+            to={`/stacks/${stack?.namespace}/${stack?.id}`}
           >
             <DataContainer>
-              <StackName>
-                <StackIcon>
-                  <i className="material-icons-outlined">lan</i>
-                </StackIcon>
-                <span>{stack.name}</span>
-              </StackName>
-
+              <Top>
+                <StackName>
+                  <StackIcon>
+                    <i className="material-icons-outlined">lan</i>
+                  </StackIcon>
+                  <span>{stack.name}</span>
+                </StackName>
+                <SepDot>•</SepDot>
+                <NamespaceTag.Wrapper>
+                  Namespace
+                  <NamespaceTag.Tag>{stack.namespace}</NamespaceTag.Tag>
+                </NamespaceTag.Wrapper>
+              </Top>
               <InfoWrapper>
                 <LastDeployed>
                   <Status
@@ -200,7 +211,6 @@ const StackName = styled.div`
   display: flex;
   font-size: 14px;
   align-items: center;
-  margin-bottom: 10px;
 `;
 
 const DataContainer = styled.div`
@@ -215,3 +225,9 @@ const StackCard = styled(Card)`
   font-size: 13px;
   font-weight: 500;
 `;
+
+const Top = styled.div`
+  display: flex;
+  align-items: center;
+  margin-bottom: 10px;
+`;

+ 33 - 0
dashboard/src/main/home/cluster-dashboard/stacks/components/styles.ts

@@ -96,3 +96,36 @@ export const Flex = styled.div`
   display: flex;
   align-items: center;
 `;
+
+export const NamespaceTag = {
+  Wrapper: styled.div`
+    height: 20px;
+    font-size: 12px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    color: #ffffff44;
+    border: 1px solid #ffffff44;
+    border-radius: 3px;
+    padding-left: 5px;
+  `,
+
+  Tag: styled.div`
+    height: 20px;
+    margin-left: 6px;
+    color: #aaaabb;
+    background: #ffffff22;
+    border-radius: 3px;
+    font-size: 12px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 0px 6px;
+    padding-left: 7px;
+    border-top-left-radius: 0px;
+    border-bottom-left-radius: 0px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  `,
+};

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/stacks/routes.tsx

@@ -8,7 +8,7 @@ import {
 } from "react-router";
 import { Context } from "shared/Context";
 import Dashboard from "./Dashboard";
-import ExpandedStack from "./ExpandedStack";
+import ExpandedStack from "./ExpandedStack/ExpandedStack";
 import LaunchRoutes from "./launch";
 
 const routes = () => {

+ 7 - 4
dashboard/src/main/home/cluster-dashboard/stacks/types.ts

@@ -31,13 +31,16 @@ export type Stack = {
   name: string;
   created_at: string;
   updated_at: string;
+  namespace: string;
 
   revisions: StackRevision[];
 
-  latest_revision: StackRevision & {
-    resources: AppResource[];
-    source_configs: SourceConfig[];
-  };
+  latest_revision: FullStackRevision;
+};
+
+export type FullStackRevision = StackRevision & {
+  resources: AppResource[];
+  source_configs: SourceConfig[];
 };
 
 export type StackRevision = {

+ 4 - 4
dashboard/src/main/home/infrastructure/InfrastructureList.tsx

@@ -92,6 +92,10 @@ const InfrastructureList = () => {
           );
         },
       },
+      {
+        Header: "Name",
+        accessor: "name",
+      },
       {
         Header: "Status",
         accessor: "status",
@@ -131,10 +135,6 @@ const InfrastructureList = () => {
           return readableDate(row.original.updated_at);
         },
       },
-      {
-        Header: "Source",
-        accessor: "source_link",
-      },
       {
         Header: "Version",
         accessor: "source_version",

+ 34 - 62
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -103,6 +103,34 @@ class Sidebar extends Component<PropsType, StateType> {
     }
   };
 
+  /**
+   * Helper function that will keep the query params before redirect the user to a new page
+   *
+   * @param location
+   * @param path Path to redirect to
+   * @returns React router `to` object
+   */
+  withQueryParams = (location: any, path: string) => {
+    let { currentCluster, currentProject } = this.context;
+    let params = this.props.match.params as any;
+    let pathNamespace = params.namespace;
+    let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
+
+    if (!pathNamespace) {
+      pathNamespace = getQueryParam(this.props, "namespace");
+    }
+
+    if (pathNamespace) {
+      search = search.concat(`&namespace=${pathNamespace}`);
+    }
+
+    return {
+      ...location,
+      pathname: path,
+      search,
+    };
+  };
+
   renderClusterContent = () => {
     let { currentCluster, currentProject } = this.context;
 
@@ -110,74 +138,16 @@ class Sidebar extends Component<PropsType, StateType> {
       return (
         <>
           <NavButton
-            to={(location) => {
-              let params = this.props.match.params as any;
-              let pathNamespace = params.namespace;
-              let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
-
-              if (!pathNamespace) {
-                pathNamespace = getQueryParam(this.props, "namespace");
-              }
-
-              if (pathNamespace) {
-                search = search.concat(`&namespace=${pathNamespace}`);
-              }
-
-              return {
-                ...location,
-                pathname: "/applications",
-                search,
-              };
-            }}
+            to={(location) => this.withQueryParams(location, "/applications")}
           >
             <Img src={monoweb} />
             Applications
           </NavButton>
-          <NavButton
-            to={() => {
-              let params = this.props.match.params as any;
-              let pathNamespace = params.namespace;
-              let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
-
-              if (!pathNamespace) {
-                pathNamespace = getQueryParam(this.props, "namespace");
-              }
-
-              if (pathNamespace) {
-                search = search.concat(`&namespace=${pathNamespace}`);
-              }
-
-              return {
-                ...location,
-                pathname: "/jobs",
-                search,
-              };
-            }}
-          >
+          <NavButton to={() => this.withQueryParams(location, "/jobs")}>
             <Img src={monojob} />
             Jobs
           </NavButton>
-          <NavButton
-            to={() => {
-              let params = this.props.match.params as any;
-              let pathNamespace = params.namespace;
-              let search = `?cluster=${currentCluster.name}&project_id=${currentProject.id}`;
-
-              if (!pathNamespace) {
-                pathNamespace = getQueryParam(this.props, "namespace");
-              }
-
-              if (pathNamespace) {
-                search = search.concat(`&namespace=${pathNamespace}`);
-              }
-
-              return {
-                ...location,
-                pathname: "/env-groups",
-                search,
-              };
-            }}
-          >
+          <NavButton to={() => this.withQueryParams(location, "/env-groups")}>
             <Img src={sliders} />
             Env Groups
           </NavButton>
@@ -224,7 +194,9 @@ class Sidebar extends Component<PropsType, StateType> {
             </NavButton>
           )}
           {currentProject?.stacks_enabled ? (
-            <NavButton to="/stacks">
+            <NavButton
+              to={(location) => this.withQueryParams(location, "/stacks")}
+            >
               <Icon className="material-icons-outlined">lan</Icon>
               Stacks
             </NavButton>

+ 57 - 8
dashboard/src/shared/api.tsx

@@ -1,10 +1,12 @@
 import { PolicyDocType } from "./auth/types";
 import { PullRequest } from "main/home/cluster-dashboard/preview-environments/types";
-import { release } from "process";
 import { baseApi } from "./baseApi";
 
-import { BuildConfig, FullActionConfigType, StorageType } from "./types";
-import { CreateStackBody } from "main/home/cluster-dashboard/stacks/types";
+import { BuildConfig, FullActionConfigType } from "./types";
+import {
+  CreateStackBody,
+  SourceConfig,
+} from "main/home/cluster-dashboard/stacks/types";
 
 /**
  * Generic api call format
@@ -372,7 +374,7 @@ const getPRDeploymentList = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/deployments`;
 });
 
-const getPRDeploymentByCluster = baseApi<
+const getPRDeploymentByEnvironment = baseApi<
   {
     namespace: string;
   },
@@ -384,7 +386,7 @@ const getPRDeploymentByCluster = baseApi<
 >("GET", (pathParams) => {
   const { cluster_id, project_id, environment_id } = pathParams;
 
-  return `/api/projects/${project_id}/clusters/${cluster_id}/${environment_id}/deployment`;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/environments/${environment_id}/deployment`;
 });
 
 const getPRDeployment = baseApi<
@@ -1819,6 +1821,25 @@ const updateBuildConfig = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${release_name}/buildconfig`
 );
 
+const updateGitActionConfig = baseApi<
+  {
+    git_action_config: {
+      git_branch: string;
+    };
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    release_name: string;
+    revision?: 0; // Always update latest
+  }
+>(
+  "PATCH",
+  ({ project_id, cluster_id, namespace, release_name, revision = 0 }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/releases/${release_name}/${revision}/git_action_config`
+);
+
 const reRunGHWorkflow = baseApi<
   {},
   {
@@ -1985,7 +2006,7 @@ const getStackRevision = baseApi<
     cluster_id: number;
     namespace: string;
     stack_id: string;
-    revision_id: string;
+    revision_id: number;
   }
 >(
   "GET",
@@ -1994,7 +2015,9 @@ const getStackRevision = baseApi<
 );
 
 const rollbackStack = baseApi<
-  {},
+  {
+    target_revision: number;
+  },
   {
     project_id: number;
     cluster_id: number;
@@ -2021,6 +2044,27 @@ const deleteStack = baseApi<
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}`
 );
 
+const updateStackSourceConfig = baseApi<
+  {
+    source_configs: SourceConfig[];
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+  }
+>(
+  "PUT",
+  ({ project_id, cluster_id, namespace, stack_id }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/source`
+);
+
+const getGithubStatus = baseApi<{}, {}>(
+  "GET",
+  ({}) => `/api/status/github`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -2082,7 +2126,7 @@ export default {
   getClusterNode,
   getConfigMap,
   getPRDeploymentList,
-  getPRDeploymentByCluster,
+  getPRDeploymentByEnvironment,
   getPRDeployment,
   getGHAWorkflowTemplate,
   getGitRepoList,
@@ -2195,6 +2239,7 @@ export default {
   upgradePorterAgent,
   deletePRDeployment,
   updateBuildConfig,
+  updateGitActionConfig,
   reRunGHWorkflow,
   triggerPreviewEnvWorkflow,
   getTagsByProjectId,
@@ -2212,4 +2257,8 @@ export default {
   createStack,
   rollbackStack,
   deleteStack,
+  updateStackSourceConfig,
+
+  // STATUS
+  getGithubStatus,
 };

+ 9 - 0
dashboard/src/shared/common.tsx

@@ -135,3 +135,12 @@ export const getIgnoreCase = (object: any, key: string) => {
     Object.keys(object).find((k) => k.toLowerCase() === key.toLowerCase())
   ];
 };
+
+export const PORTER_IMAGE_TEMPLATES = [
+  "porterdev/hello-porter-job",
+  "porterdev/hello-porter-job:latest",
+  "public.ecr.aws/o1j4x7p4/hello-porter-job",
+  "public.ecr.aws/o1j4x7p4/hello-porter-job:latest",
+  "public.ecr.aws/o1j4x7p4/hello-porter",
+  "public.ecr.aws/o1j4x7p4/hello-porter:latest",
+];

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

@@ -426,6 +426,7 @@ export type OperationType =
 
 export type Infrastructure = {
   id: number;
+  name?: string;
   api_version: string;
   created_at: string;
   updated_at: string;

+ 98 - 71
go.mod

@@ -6,15 +6,15 @@ require (
 	cloud.google.com/go v0.99.0
 	github.com/AlecAivazis/survey/v2 v2.2.9
 	github.com/Masterminds/semver/v3 v3.1.1
-	github.com/aws/aws-sdk-go v1.35.4
+	github.com/aws/aws-sdk-go v1.43.28
 	github.com/bradleyfalzon/ghinstallation/v2 v2.0.3
-	github.com/buildpacks/pack v0.26.0
+	github.com/buildpacks/pack v0.27.0
 	github.com/cli/cli v1.11.0
 	github.com/dgrijalva/jwt-go v3.2.0+incompatible
 	github.com/digitalocean/godo v1.75.0
-	github.com/docker/cli v20.10.14+incompatible
+	github.com/docker/cli v20.10.17+incompatible
 	github.com/docker/distribution v2.8.1+incompatible
-	github.com/docker/docker v20.10.14+incompatible
+	github.com/docker/docker v20.10.17+incompatible
 	github.com/docker/docker-credential-helpers v0.6.4
 	github.com/docker/go-connections v0.4.0
 	github.com/fatih/color v1.13.0
@@ -23,7 +23,7 @@ require (
 	github.com/go-playground/validator/v10 v10.3.0
 	github.com/go-redis/redis/v8 v8.11.0
 	github.com/go-test/deep v1.0.7
-	github.com/golang-jwt/jwt/v4 v4.2.0 // indirect
+	github.com/golang-jwt/jwt/v4 v4.4.1 // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
 	github.com/google/go-github/v39 v39.2.0 // indirect
 	github.com/google/go-github/v41 v41.0.0
@@ -37,32 +37,32 @@ require (
 	github.com/mitchellh/mapstructure v1.4.3
 	github.com/moby/moby v20.10.6+incompatible
 	github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6
-	github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799
+	github.com/opencontainers/image-spec v1.0.3-0.20220114050600-8b9d41f48198
 	github.com/pkg/errors v0.9.1
-	github.com/porter-dev/switchboard v0.0.0-20220416181342-416fc450addb
+	github.com/porter-dev/switchboard v0.0.0-20220628112428-7665a0121e4f
 	github.com/rs/zerolog v1.26.0
 	github.com/sendgrid/sendgrid-go v3.8.0+incompatible
-	github.com/spf13/cobra v1.4.0
+	github.com/spf13/cobra v1.5.0
 	github.com/spf13/pflag v1.0.5
 	github.com/spf13/viper v1.10.0
-	github.com/stretchr/testify v1.7.1
-	golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4
-	golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2
-	golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5
+	github.com/stretchr/testify v1.8.0
+	golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d
+	golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e
+	golang.org/x/oauth2 v0.0.0-20220628200809-02e64fa58f26
 	google.golang.org/api v0.62.0
-	google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731
-	google.golang.org/grpc v1.46.0
+	google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03
+	google.golang.org/grpc v1.47.0
 	google.golang.org/protobuf v1.28.0
 	gorm.io/driver/sqlite v1.1.3
 	gorm.io/gorm v1.22.3
-	helm.sh/helm/v3 v3.8.0
-	k8s.io/api v0.23.1
-	k8s.io/apimachinery v0.23.5
-	k8s.io/cli-runtime v0.23.1
-	k8s.io/client-go v0.23.1
+	helm.sh/helm/v3 v3.9.0
+	k8s.io/api v0.24.2
+	k8s.io/apimachinery v0.24.2
+	k8s.io/cli-runtime v0.24.2
+	k8s.io/client-go v0.24.2
 	k8s.io/helm v2.17.0+incompatible
-	k8s.io/kubectl v0.23.1
-	sigs.k8s.io/aws-iam-authenticator v0.5.2
+	k8s.io/kubectl v0.24.1
+	sigs.k8s.io/aws-iam-authenticator v0.5.8
 	sigs.k8s.io/yaml v1.3.0
 )
 
@@ -74,16 +74,42 @@ require (
 )
 
 require (
-	github.com/Azure/azure-sdk-for-go v63.4.0+incompatible // indirect
+	github.com/Azure/azure-sdk-for-go v65.0.0+incompatible // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/azcore v0.23.1 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v0.9.1 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry v0.5.0 // indirect
+	github.com/Azure/go-autorest/autorest/azure/auth v0.5.11 // indirect
+	github.com/Azure/go-autorest/autorest/azure/cli v0.4.5 // indirect
 	github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0 // indirect
+	github.com/PuerkitoBio/goquery v1.5.1 // indirect
+	github.com/andybalholm/cascadia v1.1.0 // indirect
+	github.com/aws/aws-sdk-go-v2 v1.16.4 // indirect
+	github.com/aws/aws-sdk-go-v2/config v1.15.9 // indirect
+	github.com/aws/aws-sdk-go-v2/credentials v1.12.4 // indirect
+	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.5 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.11 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.5 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/ini v1.3.12 // indirect
+	github.com/aws/aws-sdk-go-v2/service/ecr v1.17.5 // indirect
+	github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.13.5 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.5 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sso v1.11.7 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sts v1.16.6 // indirect
+	github.com/aws/smithy-go v1.11.2 // indirect
+	github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.0.0-20220517224237-e6f29200ae04 // indirect
+	github.com/chrismellard/docker-credential-acr-env v0.0.0-20220327082430-c57b701bfc08 // indirect
 	github.com/cosmtrek/air v1.30.0 // indirect
+	github.com/dimchansky/utfbom v1.1.1 // indirect
+	github.com/emicklei/go-restful/v3 v3.8.0 // indirect
+	github.com/go-gorp/gorp/v3 v3.0.2 // indirect
 	github.com/golang-jwt/jwt v3.2.1+incompatible // indirect
+	github.com/google/gnostic v0.6.9 // indirect
 	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
 	github.com/hashicorp/go-retryablehttp v0.7.1 // indirect
 	github.com/kylelemons/godebug v1.1.0 // indirect
+	github.com/mmcdole/gofeed v1.1.3 // indirect
+	github.com/mmcdole/goxpp v0.0.0-20181012175147-0068e33feabf // indirect
+	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
 	github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4 // indirect
 	github.com/xanzy/go-gitlab v0.68.0 // indirect
 )
@@ -92,8 +118,8 @@ require (
 	github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.14.0
 	github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
 	github.com/Azure/go-autorest v14.2.0+incompatible // indirect
-	github.com/Azure/go-autorest/autorest v0.11.20 // indirect
-	github.com/Azure/go-autorest/autorest/adal v0.9.15 // indirect
+	github.com/Azure/go-autorest/autorest v0.11.27 // indirect
+	github.com/Azure/go-autorest/autorest/adal v0.9.20 // indirect
 	github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
 	github.com/Azure/go-autorest/logger v0.2.1 // indirect
 	github.com/Azure/go-autorest/tracing v0.6.0 // indirect
@@ -102,16 +128,16 @@ require (
 	github.com/Masterminds/goutils v1.1.1 // indirect
 	github.com/Masterminds/semver v1.5.0 // indirect
 	github.com/Masterminds/sprig/v3 v3.2.2 // indirect
-	github.com/Masterminds/squirrel v1.5.2 // indirect
+	github.com/Masterminds/squirrel v1.5.3 // indirect
 	github.com/Microsoft/go-winio v0.5.2 // indirect
-	github.com/Microsoft/hcsshim v0.9.2 // indirect
+	github.com/Microsoft/hcsshim v0.9.3 // indirect
 	github.com/PuerkitoBio/purell v1.1.1 // indirect
 	github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
 	github.com/apex/log v1.9.0 // indirect
-	github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 // indirect
+	github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
-	github.com/buildpacks/imgutil v0.0.0-20220425182719-2edb52457eb0 // indirect
-	github.com/buildpacks/lifecycle v0.14.0 // indirect
+	github.com/buildpacks/imgutil v0.0.0-20220527150729-7a271a852e31 // indirect
+	github.com/buildpacks/lifecycle v0.14.1 // indirect
 	github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect
 	github.com/cespare/xxhash/v2 v2.1.2 // indirect
 	github.com/chai2010/gettext-go v0.0.0-20160711120539-c6fed771bfd5 // indirect
@@ -119,7 +145,7 @@ require (
 	github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect
 	github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490 // indirect
 	github.com/containerd/cgroups v1.0.3 // indirect
-	github.com/containerd/containerd v1.6.3 // indirect
+	github.com/containerd/containerd v1.6.6 // indirect
 	github.com/containerd/stargz-snapshotter/estargz v0.11.4 // indirect
 	github.com/cyphar/filepath-securejoin v0.2.3 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
@@ -130,36 +156,36 @@ require (
 	github.com/emirpasic/gods v1.18.1 // indirect
 	github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1 // indirect
 	github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect
-	github.com/evanphx/json-patch v4.12.0+incompatible // indirect
-	github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect
+	github.com/evanphx/json-patch v5.6.0+incompatible // indirect
+	github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
 	github.com/fsnotify/fsnotify v1.5.4 // indirect
 	github.com/fvbommel/sortorder v1.0.1 // indirect
 	github.com/gdamore/encoding v1.0.0 // indirect
 	github.com/gdamore/tcell/v2 v2.5.1 // indirect
 	github.com/ghodss/yaml v1.0.0 // indirect
-	github.com/go-errors/errors v1.0.1 // indirect
-	github.com/go-logr/logr v1.2.2 // indirect
+	github.com/go-errors/errors v1.4.2 // indirect
+	github.com/go-logr/logr v1.2.3 // indirect
 	github.com/go-openapi/jsonpointer v0.19.5 // indirect
-	github.com/go-openapi/jsonreference v0.19.5 // indirect
-	github.com/go-openapi/swag v0.19.14 // indirect
+	github.com/go-openapi/jsonreference v0.20.0 // indirect
+	github.com/go-openapi/swag v0.21.1 // indirect
 	github.com/go-playground/locales v0.13.0 // indirect
 	github.com/go-playground/universal-translator v0.17.0 // indirect
 	github.com/gobwas/glob v0.2.3 // indirect
 	github.com/gofrs/flock v0.8.1 // indirect
 	github.com/gogo/protobuf v1.3.2 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
-	github.com/google/btree v1.0.1 // indirect
+	github.com/google/btree v1.1.2 // indirect
 	github.com/google/go-cmp v0.5.8 // indirect
-	github.com/google/go-containerregistry v0.8.0 // indirect
+	github.com/google/go-containerregistry v0.9.0 // indirect
 	github.com/google/go-querystring v1.1.0 // indirect
 	github.com/google/gofuzz v1.2.0 // indirect
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
-	github.com/google/uuid v1.2.0 // indirect
+	github.com/google/uuid v1.3.0 // indirect
 	github.com/googleapis/gax-go/v2 v2.1.1 // indirect
 	github.com/googleapis/gnostic v0.5.5 // indirect
 	github.com/gorilla/mux v1.8.0 // indirect
 	github.com/gosuri/uitable v0.0.4 // indirect
-	github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 // indirect
+	github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect
 	github.com/hashicorp/hcl v1.0.0 // indirect
 	github.com/heroku/color v0.0.6 // indirect
 	github.com/huandu/xstrings v1.3.2 // indirect
@@ -179,21 +205,21 @@ require (
 	github.com/jinzhu/inflection v1.0.0 // indirect
 	github.com/jinzhu/now v1.1.2 // indirect
 	github.com/jmespath/go-jmespath v0.4.0 // indirect
-	github.com/jmoiron/sqlx v1.3.4 // indirect
+	github.com/jmoiron/sqlx v1.3.5 // indirect
 	github.com/josharian/intern v1.0.0 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
 	github.com/kevinburke/ssh_config v1.2.0 // indirect
-	github.com/klauspost/compress v1.15.2 // indirect
+	github.com/klauspost/compress v1.15.7 // indirect
 	github.com/kris-nova/lolgopher v0.0.0-20180921204813-313b3abb0d9b // indirect
 	github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
 	github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
 	github.com/leodido/go-urn v1.2.0 // indirect
-	github.com/lib/pq v1.10.4 // indirect
+	github.com/lib/pq v1.10.6 // indirect
 	github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 	github.com/magiconair/properties v1.8.5 // indirect
-	github.com/mailru/easyjson v0.7.6 // indirect
+	github.com/mailru/easyjson v0.7.7 // indirect
 	github.com/mattn/go-colorable v0.1.12 // indirect
 	github.com/mattn/go-isatty v0.0.14 // indirect
 	github.com/mattn/go-runewidth v0.0.13 // indirect
@@ -202,9 +228,10 @@ require (
 	github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
 	github.com/mitchellh/copystructure v1.2.0 // indirect
 	github.com/mitchellh/go-homedir v1.1.0 // indirect
-	github.com/mitchellh/go-wordwrap v1.0.0 // indirect
+	github.com/mitchellh/go-wordwrap v1.0.1 // indirect
 	github.com/mitchellh/ioprogress v0.0.0-20180201004757-6a23b12fa88e // indirect
 	github.com/mitchellh/reflectwalk v1.0.2 // indirect
+	github.com/moby/buildkit v0.10.3
 	github.com/moby/locker v1.0.1 // indirect
 	github.com/moby/spdystream v0.2.0 // indirect
 	github.com/moby/sys/mount v0.3.2 // indirect
@@ -215,45 +242,45 @@ require (
 	github.com/morikuni/aec v1.0.0 // indirect
 	github.com/onsi/ginkgo v1.16.4 // indirect
 	github.com/opencontainers/go-digest v1.0.0 // indirect
-	github.com/opencontainers/runc v1.1.1 // indirect
+	github.com/opencontainers/runc v1.1.2 // indirect
 	github.com/opencontainers/selinux v1.10.1 // indirect
 	github.com/pelletier/go-toml v1.9.5 // indirect
 	github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
-	github.com/prometheus/client_golang v1.11.1 // indirect
+	github.com/prometheus/client_golang v1.12.2 // indirect
 	github.com/prometheus/client_model v0.2.0 // indirect
-	github.com/prometheus/common v0.30.0 // indirect
+	github.com/prometheus/common v0.35.0 // indirect
 	github.com/prometheus/procfs v0.7.3 // indirect
 	github.com/rivo/tview v0.0.0-20220307222120-9994674d60a8 // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
-	github.com/rubenv/sql-migrate v0.0.0-20210614095031-55d5740dbbcc // indirect
-	github.com/russross/blackfriday v1.5.2 // indirect
+	github.com/rubenv/sql-migrate v1.1.2 // indirect
+	github.com/russross/blackfriday v1.6.0 // indirect
 	github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
 	github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3 // indirect
 	github.com/sendgrid/rest v2.6.3+incompatible // indirect
 	github.com/sergi/go-diff v1.2.0 // indirect
-	github.com/shopspring/decimal v1.2.0 // indirect
+	github.com/shopspring/decimal v1.3.1 // indirect
 	github.com/sirupsen/logrus v1.8.1 // indirect
 	github.com/spf13/afero v1.6.0 // indirect
-	github.com/spf13/cast v1.4.1 // indirect
+	github.com/spf13/cast v1.5.0 // indirect
 	github.com/spf13/jwalterweatherman v1.1.0 // indirect
 	github.com/src-d/gcfg v1.4.0 // indirect
 	github.com/subosito/gotenv v1.2.0 // indirect
 	github.com/vbatts/tar-split v0.11.2 // indirect
 	github.com/xanzy/ssh-agent v0.3.1 // indirect
-	github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
+	github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
 	github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
 	github.com/xeipuuv/gojsonschema v1.2.0 // indirect
-	github.com/xlab/treeprint v0.0.0-20181112141820-a009c3971eca // indirect
+	github.com/xlab/treeprint v1.1.0 // indirect
 	github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect
 	go.opencensus.io v0.23.0 // indirect
-	go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5 // indirect
-	golang.org/x/mod v0.5.1 // indirect
-	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
-	golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect
-	golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 // indirect
+	go.starlark.net v0.0.0-20220328144851-d1966c6b9fcd // indirect
+	golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
+	golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f // indirect
+	golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b // indirect
+	golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect
 	golang.org/x/text v0.3.7 // indirect
-	golang.org/x/time v0.0.0-20220411224347-583f2d630306 // indirect
+	golang.org/x/time v0.0.0-20220609170525-579cf78fd858 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 	gopkg.in/gorp.v1 v1.7.2 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
@@ -261,16 +288,16 @@ require (
 	gopkg.in/src-d/go-billy.v4 v4.3.2 // indirect
 	gopkg.in/src-d/go-git.v4 v4.13.1 // indirect
 	gopkg.in/warnings.v0 v0.1.2 // indirect
-	gopkg.in/yaml.v3 v3.0.0 // indirect
-	k8s.io/apiextensions-apiserver v0.23.1 // indirect
-	k8s.io/apiserver v0.23.1 // indirect
-	k8s.io/component-base v0.23.1 // indirect
-	k8s.io/klog/v2 v2.30.0 // indirect
-	k8s.io/kube-openapi v0.0.0-20211115234752-e816edb12b65 // indirect
-	k8s.io/utils v0.0.0-20211116205334-6203023598ed // indirect
-	oras.land/oras-go v1.1.0 // indirect
-	sigs.k8s.io/json v0.0.0-20211020170558-c049b76a60c6 // indirect
-	sigs.k8s.io/kustomize/api v0.10.1 // indirect
-	sigs.k8s.io/kustomize/kyaml v0.13.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+	k8s.io/apiextensions-apiserver v0.24.2 // indirect
+	k8s.io/apiserver v0.24.2 // indirect
+	k8s.io/component-base v0.24.2 // indirect
+	k8s.io/klog/v2 v2.70.0 // indirect
+	k8s.io/kube-openapi v0.0.0-20220627174259-011e075b9cb8 // indirect
+	k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9 // indirect
+	oras.land/oras-go v1.2.0 // indirect
+	sigs.k8s.io/json v0.0.0-20220525155127-227cbc7cc124 // indirect
+	sigs.k8s.io/kustomize/api v0.11.5 // indirect
+	sigs.k8s.io/kustomize/kyaml v0.13.7 // indirect
 	sigs.k8s.io/structured-merge-diff/v4 v4.2.1 // indirect
 )

文件差异内容过多而无法显示
+ 308 - 0
go.sum


+ 17 - 12
internal/helm/agent.go

@@ -112,21 +112,26 @@ func (a *Agent) GetRelease(
 
 	if getDeps && release.Chart != nil && release.Chart.Metadata != nil {
 		for _, dep := range release.Chart.Metadata.Dependencies {
-			depExists := false
-
-			for _, currDep := range release.Chart.Dependencies() {
-				// we just case on name for now -- there might be edge cases we're missing
-				// but this will cover 99% of cases
-				if dep != nil && currDep != nil && dep.Name == currDep.Name() {
-					depExists = true
-					break
+			// only search for dependency if it passes the condition specified in Chart.yaml
+			if dep.Enabled {
+				depExists := false
+
+				for _, currDep := range release.Chart.Dependencies() {
+					// we just case on name for now -- there might be edge cases we're missing
+					// but this will cover 99% of cases
+					if dep != nil && currDep != nil && dep.Name == currDep.Name() {
+						depExists = true
+						break
+					}
 				}
-			}
 
-			if !depExists {
-				depChart, err := loader.LoadChartPublic(dep.Repository, dep.Name, dep.Version)
+				if !depExists {
+					depChart, err := loader.LoadChartPublic(dep.Repository, dep.Name, dep.Version)
+
+					if err != nil {
+						return nil, fmt.Errorf("Error retrieving chart dependency %s/%s-%s: %s", dep.Repository, dep.Name, dep.Version, err.Error())
+					}
 
-				if err == nil {
 					release.Chart.AddDependency(depChart)
 				}
 			}

+ 1 - 1
internal/helm/config.go

@@ -81,7 +81,7 @@ func GetAgentFromK8sAgent(stg string, ns string, l *logger.Logger, k8sAgent *kub
 // the underlying kubernetes.GetAgentInClusterConfig method
 func GetAgentInClusterConfig(form *Form, l *logger.Logger) (*Agent, error) {
 	// create a kubernetes agent
-	k8sAgent, err := kubernetes.GetAgentInClusterConfig()
+	k8sAgent, err := kubernetes.GetAgentInClusterConfig(form.Namespace)
 
 	if err != nil {
 		return nil, err

+ 3 - 2
internal/integrations/ci/actions/actions.go

@@ -235,8 +235,9 @@ type GithubActionYAMLOnPush struct {
 }
 
 type GithubActionYAMLJob struct {
-	RunsOn string                 `yaml:"runs-on,omitempty"`
-	Steps  []GithubActionYAMLStep `yaml:"steps,omitempty"`
+	RunsOn      string                 `yaml:"runs-on,omitempty"`
+	Steps       []GithubActionYAMLStep `yaml:"steps,omitempty"`
+	Concurrency map[string]string      `yaml:"concurrency,omitempty"`
 }
 
 type GithubActionYAML struct {

+ 6 - 131
internal/integrations/ci/actions/preview.go

@@ -73,12 +73,6 @@ func SetupEnv(opts *EnvOpts) error {
 		return err
 	}
 
-	deleteWorkflowYAML, err := getPreviewDeleteActionYAML(opts)
-
-	if err != nil {
-		return err
-	}
-
 	githubBranch, _, err := opts.Client.Repositories.GetBranch(
 		context.Background(), opts.GitRepoOwner, opts.GitRepoName, defaultBranch, true,
 	)
@@ -95,8 +89,8 @@ func SetupEnv(opts *EnvOpts) error {
 				"Unable to create PR to merge workflow files into protected branch: %s.\n"+
 					"To enable Porter Preview Environment deployments, please create Github workflow "+
 					"files in this branch with the following contents:\n"+
-					"--------\n%s--------\n--------\n%s--------\nERROR: %w",
-				defaultBranch, string(applyWorkflowYAML), string(deleteWorkflowYAML), ErrCreatePRForProtectedBranch,
+					"--------\n%s--------\nERROR: %w",
+				defaultBranch, string(applyWorkflowYAML), ErrCreatePRForProtectedBranch,
 			)
 		}
 
@@ -112,25 +106,8 @@ func SetupEnv(opts *EnvOpts) error {
 				"Unable to create PR to merge workflow files into protected branch: %s.\n"+
 					"To enable Porter Preview Environment deployments, please create Github workflow "+
 					"files in this branch with the following contents:\n"+
-					"--------\n%s--------\n--------\n%s--------\nERROR: %w",
-				defaultBranch, string(applyWorkflowYAML), string(deleteWorkflowYAML), ErrCreatePRForProtectedBranch,
-			)
-		}
-
-		_, err = commitWorkflowFile(
-			opts.Client,
-			fmt.Sprintf("porter_%s_delete_env.yml", strings.ToLower(opts.EnvironmentName)),
-			deleteWorkflowYAML, opts.GitRepoOwner,
-			opts.GitRepoName, "porter-preview", false,
-		)
-
-		if err != nil {
-			return fmt.Errorf(
-				"Unable to create PR to merge workflow files into protected branch: %s.\n"+
-					"To enable Porter Preview Environment deployments, please create a Github workflow "+
-					"file in this branch with the following contents:\n"+
 					"--------\n%s--------\nERROR: %w",
-				defaultBranch, string(deleteWorkflowYAML), ErrCreatePRForProtectedBranch,
+				defaultBranch, string(applyWorkflowYAML), ErrCreatePRForProtectedBranch,
 			)
 		}
 
@@ -160,75 +137,6 @@ func SetupEnv(opts *EnvOpts) error {
 		false,
 	)
 
-	if err != nil {
-		if strings.Contains(err.Error(), "409 Could not create file") {
-			// possibly a write-protected branch
-			err = createNewBranch(opts.Client, opts.GitRepoOwner, opts.GitRepoName, defaultBranch, "porter-preview")
-
-			if err != nil {
-				return fmt.Errorf("write-protected branch %s. Error creating porter-preview branch: %w", defaultBranch, err)
-			}
-
-			_, err = commitWorkflowFile(
-				opts.Client,
-				fmt.Sprintf("porter_%s_env.yml", strings.ToLower(opts.EnvironmentName)),
-				applyWorkflowYAML,
-				opts.GitRepoOwner,
-				opts.GitRepoName,
-				"porter-preview",
-				false,
-			)
-
-			if err != nil {
-				return fmt.Errorf("write-protected branch %s. Error committing to porter-preview branch: %w", defaultBranch, err)
-			}
-
-			_, err = commitWorkflowFile(
-				opts.Client,
-				fmt.Sprintf("porter_%s_delete_env.yml", strings.ToLower(opts.EnvironmentName)),
-				deleteWorkflowYAML,
-				opts.GitRepoOwner,
-				opts.GitRepoName,
-				"porter-preview",
-				false,
-			)
-
-			if err != nil {
-				return fmt.Errorf("write-protected branch %s. Error committing to porter-preview branch: %w", defaultBranch, err)
-			}
-
-			pr, _, err := opts.Client.PullRequests.Create(
-				context.Background(), opts.GitRepoOwner, opts.GitRepoName, &github.NewPullRequest{
-					Title: github.String("Merge Porter preview environment Github Actions workflow files"),
-					Base:  github.String(defaultBranch),
-					Head:  github.String("porter-preview"),
-				},
-			)
-
-			if err != nil {
-				return err
-			}
-
-			return fmt.Errorf("write-protected branch %s. Please merge %s to enable preview environment for your repository", defaultBranch, pr.GetURL())
-		}
-
-		return err
-	}
-
-	_, err = commitWorkflowFile(
-		opts.Client,
-		fmt.Sprintf("porter_%s_delete_env.yml", strings.ToLower(opts.EnvironmentName)),
-		deleteWorkflowYAML,
-		opts.GitRepoOwner,
-		opts.GitRepoName,
-		defaultBranch,
-		false,
-	)
-
-	if err != nil {
-		return err
-	}
-
 	return err
 }
 
@@ -349,43 +257,10 @@ func getPreviewApplyActionYAML(opts *EnvOpts) ([]byte, error) {
 		Jobs: map[string]GithubActionYAMLJob{
 			"porter-preview": {
 				RunsOn: "ubuntu-latest",
-				Steps:  gaSteps,
-			},
-		},
-	}
-
-	return yaml.Marshal(actionYAML)
-}
-
-func getPreviewDeleteActionYAML(opts *EnvOpts) ([]byte, error) {
-	gaSteps := []GithubActionYAMLStep{
-		getDeletePreviewEnvStep(
-			opts.ServerURL,
-			getPorterTokenSecretName(opts.ProjectID),
-			opts.ProjectID,
-			opts.ClusterID,
-			opts.GitRepoName,
-			"v0.2.0",
-		),
-	}
-
-	actionYAML := GithubActionYAML{
-		On: map[string]interface{}{
-			"workflow_dispatch": map[string]interface{}{
-				"inputs": map[string]interface{}{
-					"deployment_id": map[string]interface{}{
-						"description": "Deployment ID",
-						"type":        "number",
-						"required":    true,
-					},
+				Concurrency: map[string]string{
+					"group": "${{ github.workflow }}-${{ github.event.inputs.pr_number }}",
 				},
-			},
-		},
-		Name: "Porter Preview Environment",
-		Jobs: map[string]GithubActionYAMLJob{
-			"porter-delete-preview": {
-				RunsOn: "ubuntu-latest",
-				Steps:  gaSteps,
+				Steps: gaSteps,
 			},
 		},
 	}

+ 0 - 16
internal/integrations/ci/actions/steps.go

@@ -7,7 +7,6 @@ import (
 
 const updateAppActionName = "porter-dev/porter-update-action"
 const createPreviewActionName = "porter-dev/porter-preview-action"
-const deletePreviewActionName = "porter-dev/porter-delete-preview-action"
 
 func getCheckoutCodeStep() GithubActionYAMLStep {
 	return GithubActionYAMLStep{
@@ -68,18 +67,3 @@ func getCreatePreviewEnvStep(
 		Timeout: 30,
 	}
 }
-
-func getDeletePreviewEnvStep(serverURL, porterTokenSecretName string, projectID, clusterID uint, repoName, actionVersion string) GithubActionYAMLStep {
-	return GithubActionYAMLStep{
-		Name: "Delete Porter preview env",
-		Uses: fmt.Sprintf("%s@%s", deletePreviewActionName, actionVersion),
-		With: map[string]string{
-			"cluster":       fmt.Sprintf("%d", clusterID),
-			"host":          serverURL,
-			"project":       fmt.Sprintf("%d", projectID),
-			"token":         fmt.Sprintf("${{ secrets.%s }}", porterTokenSecretName),
-			"deployment_id": "${{ github.event.inputs.deployment_id }}",
-		},
-		Timeout: 30,
-	}
-}

+ 8 - 4
internal/kubernetes/config.go

@@ -59,7 +59,7 @@ func GetDynamicClientOutOfClusterConfig(conf *OutOfClusterConfig) (dynamic.Inter
 // GetAgentOutOfClusterConfig creates a new Agent using the OutOfClusterConfig
 func GetAgentOutOfClusterConfig(conf *OutOfClusterConfig) (*Agent, error) {
 	if conf.AllowInClusterConnections && conf.Cluster.AuthMechanism == models.InCluster {
-		return GetAgentInClusterConfig()
+		return GetAgentInClusterConfig(conf.DefaultNamespace)
 	}
 
 	restConf, err := conf.ToRESTConfig()
@@ -89,14 +89,14 @@ func IsInCluster() bool {
 
 // GetAgentInClusterConfig uses the service account that kubernetes
 // gives to pods to connect
-func GetAgentInClusterConfig() (*Agent, error) {
+func GetAgentInClusterConfig(namespace string) (*Agent, error) {
 	conf, err := rest.InClusterConfig()
 
 	if err != nil {
 		return nil, err
 	}
 
-	restClientGetter := NewRESTClientGetterFromInClusterConfig(conf)
+	restClientGetter := NewRESTClientGetterFromInClusterConfig(conf, namespace)
 	clientset, err := kubernetes.NewForConfig(conf)
 
 	return &Agent{restClientGetter, clientset}, nil
@@ -419,9 +419,13 @@ func (conf *OutOfClusterConfig) setTokenCache(token string, expiry time.Time) er
 
 // NewRESTClientGetterFromInClusterConfig returns a RESTClientGetter using
 // default values set from the *rest.Config
-func NewRESTClientGetterFromInClusterConfig(conf *rest.Config) genericclioptions.RESTClientGetter {
+func NewRESTClientGetterFromInClusterConfig(conf *rest.Config, namespace string) genericclioptions.RESTClientGetter {
 	cfs := genericclioptions.NewConfigFlags(false)
 
+	if namespace != "" {
+		cfs.Namespace = &namespace
+	}
+
 	cfs.ClusterName = &conf.ServerName
 	cfs.Insecure = &conf.Insecure
 	cfs.APIServer = &conf.Host

+ 8 - 1
internal/kubernetes/local/kubeconfig.go

@@ -80,7 +80,14 @@ func GetSelfAgentFromFileConfig(kubeconfigPath string) (*kubernetes.Agent, error
 		return nil, err
 	}
 
-	restClientGetter := kubernetes.NewRESTClientGetterFromInClusterConfig(restConf)
+	var namespace string
+	cmdConfNamespace, _, err := cmdConf.Namespace()
+
+	if err == nil && cmdConfNamespace != "" {
+		namespace = cmdConfNamespace
+	}
+
+	restClientGetter := kubernetes.NewRESTClientGetterFromInClusterConfig(restConf, namespace)
 	clientset, err := k8s.NewForConfig(restConf)
 
 	return &kubernetes.Agent{

+ 37 - 0
internal/models/infra.go

@@ -125,10 +125,47 @@ func GetOperationID() (string, error) {
 	return encryption.GenerateRandomBytes(10)
 }
 
+type getInfraName struct {
+	Name        string `json:"name"`
+	ClusterName string `json:"cluster_name"`
+	DOCRName    string `json:"docr_name"`
+	ECRName     string `json:"ecr_name"`
+	ACRName     string `json:"acr_name"`
+}
+
 // ToInfraType generates an external Infra to be shared over REST
 func (i *Infra) ToInfraType() *types.Infra {
+	// perform best attempt to get infra name
+	var name string
+	infraName := &getInfraName{}
+
+	if err := json.Unmarshal(i.LastApplied, infraName); err == nil {
+		if infraName.DOCRName != "" {
+			name = infraName.DOCRName
+		}
+
+		if infraName.ECRName != "" {
+			name = infraName.ECRName
+		}
+
+		if infraName.ACRName != "" {
+			name = infraName.ACRName
+		}
+
+		if infraName.ClusterName != "" {
+			name = infraName.ClusterName
+		}
+
+		if infraName.Name != "" {
+			name = infraName.Name
+		}
+	} else if err != nil {
+		fmt.Println("ERRWAS", err)
+	}
+
 	return &types.Infra{
 		ID:               i.ID,
+		Name:             name,
 		CreatedAt:        i.CreatedAt,
 		UpdatedAt:        i.UpdatedAt,
 		ProjectID:        i.ProjectID,

+ 1 - 0
internal/models/stack.go

@@ -39,6 +39,7 @@ func (s *Stack) ToStackType() *types.Stack {
 		CreatedAt:      s.CreatedAt,
 		UpdatedAt:      s.UpdatedAt,
 		Name:           s.Name,
+		Namespace:      s.Namespace,
 		ID:             s.UID,
 		LatestRevision: latestRevision,
 		Revisions:      revisions,

+ 1 - 0
internal/repository/git_action_config.go

@@ -7,4 +7,5 @@ import "github.com/porter-dev/porter/internal/models"
 type GitActionConfigRepository interface {
 	CreateGitActionConfig(gr *models.GitActionConfig) (*models.GitActionConfig, error)
 	ReadGitActionConfig(id uint) (*models.GitActionConfig, error)
+	UpdateGitActionConfig(gr *models.GitActionConfig) error
 }

+ 62 - 19
internal/repository/gorm/environment.go

@@ -28,13 +28,24 @@ func (repo *EnvironmentRepository) CreateEnvironment(env *models.Environment) (*
 
 func (repo *EnvironmentRepository) ReadEnvironment(projectID, clusterID, gitInstallationID uint, gitRepoOwner, gitRepoName string) (*models.Environment, error) {
 	env := &models.Environment{}
-	if err := repo.db.Order("id desc").Where(
-		"project_id = ? AND cluster_id = ? AND git_installation_id = ? AND git_repo_owner = ? AND git_repo_name = ?",
-		projectID, clusterID, gitInstallationID,
-		gitRepoOwner, gitRepoName,
-	).First(&env).Error; err != nil {
-		return nil, err
+
+	switch repo.db.Dialector.Name() {
+	case "sqlite":
+		if err := repo.db.Order("id desc").Where(
+			"project_id = ? AND cluster_id = ? AND git_installation_id = ? AND git_repo_owner LIKE ? AND git_repo_name LIKE ?",
+			projectID, clusterID, gitInstallationID, gitRepoOwner, gitRepoName,
+		).First(&env).Error; err != nil {
+			return nil, err
+		}
+	case "postgres":
+		if err := repo.db.Order("id desc").Where(
+			"project_id = ? AND cluster_id = ? AND git_installation_id = ? AND git_repo_owner iLIKE ? AND git_repo_name iLIKE ?",
+			projectID, clusterID, gitInstallationID, gitRepoOwner, gitRepoName,
+		).First(&env).Error; err != nil {
+			return nil, err
+		}
 	}
+
 	return env, nil
 }
 
@@ -56,11 +67,22 @@ func (repo *EnvironmentRepository) ReadEnvironmentByOwnerRepoName(
 	gitRepoOwner, gitRepoName string,
 ) (*models.Environment, error) {
 	env := &models.Environment{}
-	if err := repo.db.Order("id desc").Where("project_id = ? AND cluster_id = ? AND git_repo_owner = ? AND git_repo_name = ?",
-		projectID, clusterID, gitRepoOwner, gitRepoName,
-	).First(&env).Error; err != nil {
-		return nil, err
+
+	switch repo.db.Dialector.Name() {
+	case "sqlite":
+		if err := repo.db.Order("id desc").Where("project_id = ? AND cluster_id = ? AND git_repo_owner LIKE ? AND git_repo_name LIKE ?",
+			projectID, clusterID, gitRepoOwner, gitRepoName,
+		).First(&env).Error; err != nil {
+			return nil, err
+		}
+	case "postgres":
+		if err := repo.db.Order("id desc").Where("project_id = ? AND cluster_id = ? AND git_repo_owner iLIKE ? AND git_repo_name iLIKE ?",
+			projectID, clusterID, gitRepoOwner, gitRepoName,
+		).First(&env).Error; err != nil {
+			return nil, err
+		}
 	}
+
 	return env, nil
 }
 
@@ -68,11 +90,22 @@ func (repo *EnvironmentRepository) ReadEnvironmentByWebhookIDOwnerRepoName(
 	webhookID, gitRepoOwner, gitRepoName string,
 ) (*models.Environment, error) {
 	env := &models.Environment{}
-	if err := repo.db.Order("id desc").Where("webhook_id = ? AND git_repo_owner = ? AND git_repo_name = ?",
-		webhookID, gitRepoOwner, gitRepoName,
-	).First(&env).Error; err != nil {
-		return nil, err
+
+	switch repo.db.Dialector.Name() {
+	case "sqlite":
+		if err := repo.db.Order("id desc").Where("webhook_id = ? AND git_repo_owner LIKE ? AND git_repo_name LIKE ?",
+			webhookID, gitRepoOwner, gitRepoName,
+		).First(&env).Error; err != nil {
+			return nil, err
+		}
+	case "postgres":
+		if err := repo.db.Order("id desc").Where("webhook_id = ? AND git_repo_owner iLIKE ? AND git_repo_name iLIKE ?",
+			webhookID, gitRepoOwner, gitRepoName,
+		).First(&env).Error; err != nil {
+			return nil, err
+		}
 	}
+
 	return env, nil
 }
 
@@ -156,11 +189,21 @@ func (repo *EnvironmentRepository) ReadDeploymentByGitDetails(
 ) (*models.Deployment, error) {
 	depl := &models.Deployment{}
 
-	if err := repo.db.Order("id asc").
-		Where("environment_id = ? AND repo_owner = ? AND repo_name = ? AND pull_request_id = ?",
-			environmentID, gitRepoOwner, gitRepoName, prNumber).
-		First(&depl).Error; err != nil {
-		return nil, err
+	switch repo.db.Dialector.Name() {
+	case "sqlite":
+		if err := repo.db.Order("id asc").
+			Where("environment_id = ? AND repo_owner LIKE ? AND repo_name LIKE ? AND pull_request_id = ?",
+				environmentID, gitRepoOwner, gitRepoName, prNumber).
+			First(&depl).Error; err != nil {
+			return nil, err
+		}
+	case "postgres":
+		if err := repo.db.Order("id asc").
+			Where("environment_id = ? AND repo_owner iLIKE ? AND repo_name iLIKE ? AND pull_request_id = ?",
+				environmentID, gitRepoOwner, gitRepoName, prNumber).
+			First(&depl).Error; err != nil {
+			return nil, err
+		}
 	}
 
 	return depl, nil

+ 4 - 0
internal/repository/gorm/git_action_config.go

@@ -49,3 +49,7 @@ func (repo *GitActionConfigRepository) ReadGitActionConfig(id uint) (*models.Git
 
 	return ga, nil
 }
+
+func (repo *GitActionConfigRepository) UpdateGitActionConfig(ga *models.GitActionConfig) error {
+	return repo.db.Save(ga).Error
+}

+ 1 - 0
internal/repository/gorm/helpers_test.go

@@ -71,6 +71,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
 		&models.Infra{},
+		&models.Operation{},
 		&models.GitActionConfig{},
 		&models.Invite{},
 		&models.KubeEvent{},

+ 32 - 0
internal/repository/gorm/infra.go

@@ -91,8 +91,40 @@ func (repo *InfraRepository) ListInfrasByProjectID(
 		return nil, err
 	}
 
+	infraIDs := make([]uint, 0)
+
 	for _, infra := range infras {
 		repo.DecryptInfraData(infra, repo.key)
+		infraIDs = append(infraIDs, infra.ID)
+	}
+
+	// get the latest operation for each infra and use it to set LastApplied
+	operations := make([]*models.Operation, 0)
+
+	if err := repo.db.Where("operations.infra_id IN (?)", infraIDs).Where(`
+	operations.id IN (
+	  SELECT o2.id FROM (SELECT MAX(operations.id) id FROM operations WHERE operations.infra_id IN (?) GROUP BY operations.infra_id) o2
+	)
+  `, infraIDs).Find(&operations).Error; err != nil {
+		return nil, err
+	}
+
+	// insert operations into a map
+	infraIDToOperationMap := make(map[uint]models.Operation)
+
+	for _, op := range operations {
+		err := repo.DecryptOperationData(op, repo.key)
+
+		if err == nil {
+			infraIDToOperationMap[op.InfraID] = *op
+		}
+	}
+
+	// look up each revision for each stack
+	for _, infra := range infras {
+		if _, exists := infraIDToOperationMap[infra.ID]; exists {
+			infra.LastApplied = infraIDToOperationMap[infra.ID].LastApplied
+		}
 	}
 
 	return infras, nil

+ 16 - 0
internal/repository/gorm/stack.go

@@ -74,6 +74,22 @@ func (repo *StackRepository) ListStacks(projectID, clusterID uint, namespace str
 	return stacks, nil
 }
 
+func (repo *StackRepository) ReadStackByID(projectID, stackID uint) (*models.Stack, error) {
+	stack := &models.Stack{}
+
+	if err := repo.db.
+		Preload("Revisions", func(db *gorm.DB) *gorm.DB {
+			return db.Order("stack_revisions.revision_number DESC").Limit(100)
+		}).
+		Preload("Revisions.Resources").
+		Preload("Revisions.SourceConfigs").
+		Where("stacks.project_id = ? AND stacks.id = ?", projectID, stackID).First(&stack).Error; err != nil {
+		return nil, err
+	}
+
+	return stack, nil
+}
+
 // ReadStack gets a stack specified by its string id
 func (repo *StackRepository) ReadStackByStringID(projectID uint, stackID string) (*models.Stack, error) {
 	stack := &models.Stack{}

+ 1 - 0
internal/repository/stack.go

@@ -5,6 +5,7 @@ import "github.com/porter-dev/porter/internal/models"
 // StackRepository represents the set of queries on the Stack model
 type StackRepository interface {
 	CreateStack(stack *models.Stack) (*models.Stack, error)
+	ReadStackByID(projectID, stackID uint) (*models.Stack, error)
 	ReadStackByStringID(projectID uint, stackID string) (*models.Stack, error)
 	ListStacks(projectID uint, clusterID uint, namespace string) ([]*models.Stack, error)
 	DeleteStack(stack *models.Stack) (*models.Stack, error)

+ 14 - 0
internal/repository/test/git_action_config.go

@@ -46,3 +46,17 @@ func (repo *GitActionConfigRepository) ReadGitActionConfig(id uint) (*models.Git
 	index := int(id - 1)
 	return repo.gitActionConfigs[index], nil
 }
+
+func (repo *GitActionConfigRepository) UpdateGitActionConfig(gac *models.GitActionConfig) error {
+	if !repo.canQuery {
+		return errors.New("Cannot write database")
+	}
+
+	if int(gac.ID-1) >= len(repo.gitActionConfigs) || repo.gitActionConfigs[gac.ID-1] == nil {
+		return gorm.ErrRecordNotFound
+	}
+
+	index := int(gac.ID - 1)
+	repo.gitActionConfigs[index] = gac
+	return nil
+}

+ 4 - 0
internal/repository/test/stack.go

@@ -21,6 +21,10 @@ func (repo *StackRepository) ListStacks(projectID, clusterID uint, namespace str
 	panic("unimplemented")
 }
 
+func (repo *StackRepository) ReadStackByID(projectID, stackID uint) (*models.Stack, error) {
+	panic("unimplemented")
+}
+
 // ReadStack gets a stack specified by its string id
 func (repo *StackRepository) ReadStackByStringID(projectID uint, stackID string) (*models.Stack, error) {
 	panic("unimplemented")

+ 18 - 2
internal/stacks/hooks.go

@@ -1,6 +1,8 @@
 package stacks
 
 import (
+	"fmt"
+
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"gorm.io/gorm"
 	"helm.sh/helm/v3/pkg/release"
@@ -24,13 +26,27 @@ func UpdateHelmRevision(config *config.Config, projID, clusterID uint, rel *rele
 		return err
 	}
 
-	// read the revision number and create a new revision of the stack
-	stackRevision, err := config.Repo.Stack().ReadStackRevision(stackResource.StackRevisionID)
+	// read the revision number corresponding and create a new revision of the stack
+	oldStackRevision, err := config.Repo.Stack().ReadStackRevision(stackResource.StackRevisionID)
 
 	if err != nil {
 		return err
 	}
 
+	// get the latest revision for that stack
+	stack, err := config.Repo.Stack().ReadStackByID(projID, oldStackRevision.StackID)
+
+	if err != nil {
+		return err
+	}
+
+	if len(stack.Revisions) == 0 {
+		return fmt.Errorf("length of stack revision list was 0")
+	}
+
+	currStackRevision := stack.Revisions[0]
+	stackRevision := &currStackRevision
+
 	clonedSourceConfigs, err := CloneSourceConfigs(stackRevision.SourceConfigs)
 
 	if err != nil {

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

@@ -248,7 +248,7 @@ func getProvisionerAgent(conf *ProvisionerConf) (*kubernetes.Agent, error) {
 		return nil, fmt.Errorf(`"kubeconfig" cluster option requires path to kubeconfig`)
 	}
 
-	agent, _ := kubernetes.GetAgentInClusterConfig()
+	agent, _ := kubernetes.GetAgentInClusterConfig(conf.ProvisionerJobNamespace)
 
 	return agent, nil
 }

部分文件因为文件数量过多而无法显示