瀏覽代碼

Merge branch 'master' into nafees/preview-env-improvements

Mohammed Nafees 3 年之前
父節點
當前提交
2e9dc5ccf6
共有 38 個文件被更改,包括 1605 次插入170 次删除
  1. 15 1
      .github/workflows/release.yaml
  2. 40 0
      api/client/environment.go
  3. 30 14
      api/server/handlers/cluster_integration/aws/get_cluster_info.go
  4. 42 5
      api/server/handlers/environment/finalize_deployment.go
  5. 166 0
      api/server/handlers/environment/finalize_deployment_with_errors.go
  6. 2 0
      api/server/handlers/environment/list_deployments_by_cluster.go
  7. 42 0
      api/server/handlers/infra/forms.go
  8. 36 0
      api/server/router/git_installation.go
  9. 3 0
      api/types/cluster_integration.go
  10. 14 2
      api/types/environment.go
  11. 2 0
      api/types/infra.go
  12. 9 6
      api/types/stacks.go
  13. 108 8
      cli/cmd/apply.go
  14. 51 16
      cli/cmd/list.go
  15. 39 2
      dashboard/src/components/repo-selector/RepoList.tsx
  16. 32 8
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx
  17. 4 1
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_RevisionList.tsx
  18. 84 9
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_SourceConfig.tsx
  19. 264 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/Select.tsx
  20. 328 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/SourceEditorDocker.tsx
  21. 25 9
      dashboard/src/main/home/cluster-dashboard/stacks/_StackList.tsx
  22. 33 0
      dashboard/src/main/home/cluster-dashboard/stacks/components/styles.ts
  23. 1 0
      dashboard/src/main/home/cluster-dashboard/stacks/types.ts
  24. 4 4
      dashboard/src/main/home/infrastructure/InfrastructureList.tsx
  25. 34 62
      dashboard/src/main/home/sidebar/Sidebar.tsx
  26. 21 1
      dashboard/src/shared/api.tsx
  27. 1 0
      dashboard/src/shared/types.tsx
  28. 1 1
      go.mod
  29. 2 0
      go.sum
  30. 37 0
      internal/models/infra.go
  31. 1 0
      internal/models/stack.go
  32. 62 19
      internal/repository/gorm/environment.go
  33. 1 0
      internal/repository/gorm/helpers_test.go
  34. 32 0
      internal/repository/gorm/infra.go
  35. 16 0
      internal/repository/gorm/stack.go
  36. 1 0
      internal/repository/stack.go
  37. 4 0
      internal/repository/test/stack.go
  38. 18 2
      internal/stacks/hooks.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

+ 40 - 0
api/client/environment.go

@@ -22,6 +22,26 @@ func (c *Client) ListEnvironments(
 	return resp, err
 }
 
+func (c *Client) CreateDeployment(
+	ctx context.Context,
+	projID, gitInstallationID, clusterID uint,
+	gitRepoOwner, gitRepoName string,
+	req *types.CreateDeploymentRequest,
+) (*types.Deployment, error) {
+	resp := &types.Deployment{}
+
+	err := c.postRequest(
+		fmt.Sprintf(
+			"/projects/%d/gitrepos/%d/%s/%s/clusters/%d/deployment",
+			projID, gitInstallationID, gitRepoOwner, gitRepoName, clusterID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
 func (c *Client) GetDeployment(
 	ctx context.Context,
 	projID, clusterID, envID uint,
@@ -98,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,

+ 30 - 14
api/server/handlers/cluster_integration/aws/get_cluster_info.go

@@ -89,26 +89,42 @@ func (c *GetClusterInfoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 
 	ec2Svc := ec2.New(awsSession, awsConf)
 
-	subnetsInfo, err := ec2Svc.DescribeSubnets(&ec2.DescribeSubnetsInput{
-		SubnetIds: clusterInfo.Cluster.ResourcesVpcConfig.SubnetIds,
-	})
-
-	if err != nil || len(subnetsInfo.Subnets) == 0 {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
 	res := &types.GetAWSClusterInfoResponse{
 		Name:       clusterName,
+		ARN:        *clusterInfo.Cluster.Arn,
+		Status:     *clusterInfo.Cluster.Status,
 		K8sVersion: *clusterInfo.Cluster.Version,
 		EKSVersion: *clusterInfo.Cluster.PlatformVersion,
 	}
 
-	for _, subnet := range subnetsInfo.Subnets {
-		res.Subnets = append(res.Subnets, &types.AWSSubnet{
-			AvailabilityZone:        *subnet.AvailabilityZone,
-			AvailableIPAddressCount: *subnet.AvailableIpAddressCount,
-		})
+	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)

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

@@ -104,11 +104,46 @@ func (c *FinalizeDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 		return
 	}
 
+	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(
@@ -116,7 +151,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())
+}

+ 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
 			}
 		}
 	}

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

@@ -500,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:
@@ -557,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

+ 36 - 0
api/server/router/git_installation.go

@@ -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(

+ 3 - 0
api/types/cluster_integration.go

@@ -1,13 +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"`
 }

+ 14 - 2
api/types/environment.go

@@ -69,9 +69,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 {

+ 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"`

+ 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 {

+ 108 - 8
cli/cmd/apply.go

@@ -743,7 +743,29 @@ func (t *DeploymentHook) PreApply() error {
 		},
 	)
 
-	if err == nil {
+	if err != nil && strings.Contains(err.Error(), "not found") {
+		// in this case, create the deployment
+		_, err = t.client.CreateDeployment(
+			context.Background(),
+			t.projectID, t.gitInstallationID, t.clusterID,
+			t.repoOwner, t.repoName,
+			&types.CreateDeploymentRequest{
+				Namespace:     t.namespace,
+				PullRequestID: t.prID,
+				CreateGHDeploymentRequest: &types.CreateGHDeploymentRequest{
+					ActionID: t.actionID,
+				},
+				GitHubMetadata: &types.GitHubMetadata{
+					PRName:       t.prName,
+					RepoName:     t.repoName,
+					RepoOwner:    t.repoOwner,
+					CommitSHA:    t.commitSHA,
+					PRBranchFrom: t.branchFrom,
+					PRBranchInto: t.branchInto,
+				},
+			},
+		)
+	} else if err == nil {
 		_, err = t.client.UpdateDeployment(
 			context.Background(),
 			t.projectID, t.gitInstallationID, t.clusterID,
@@ -837,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
@@ -878,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
@@ -962,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 ""
+}

+ 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)

+ 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);

+ 32 - 8
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx

@@ -17,6 +17,7 @@ import {
   InfoWrapper,
   LastDeployed,
   LineBreak,
+  NamespaceTag,
   SepDot,
   Text,
 } from "../components/styles";
@@ -79,13 +80,19 @@ const ExpandedStack = () => {
 
   return (
     <div>
-      <TitleSection
-        materialIconClass="material-icons-outlined"
-        icon={"lan"}
-        capitalize
-      >
-        {stack.name}
-      </TitleSection>
+      <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}
@@ -174,7 +181,12 @@ const ExpandedStack = () => {
             value: "source_config",
             component: (
               <>
-                <SourceConfig revision={currentRevision}></SourceConfig>
+                <SourceConfig
+                  namespace={namespace}
+                  revision={currentRevision}
+                  readOnly={stack.latest_revision.id !== currentRevision.id}
+                  onSourceConfigUpdate={() => getStack()}
+                ></SourceConfig>
               </>
             ),
           },
@@ -217,3 +229,15 @@ const StackErrorMessageStyles = {
     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;
+  }
+`;

+ 4 - 1
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_RevisionList.tsx

@@ -168,6 +168,8 @@ const _RevisionList = ({
 export default _RevisionList;
 
 const StyledRevisionSection = styled.div`
+  display: flex;
+  flex-direction: column;
   position: relative;
   width: 100%;
   max-height: ${(props: { showRevisions: boolean }) =>
@@ -195,7 +197,7 @@ const RevisionHeader = styled.div`
   display: flex;
   justify-content: space-between;
   align-items: center;
-  height: 40px;
+  min-height: 40px;
   font-size: 13px;
   width: 100%;
   padding-left: 15px;
@@ -228,6 +230,7 @@ const RevisionPreview = styled.div`
 
 const TableWrapper = styled.div`
   padding-bottom: 20px;
+  overflow-y: auto;
 `;
 
 const RevisionsTable = styled.table`

+ 84 - 9
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_SourceConfig.tsx

@@ -1,17 +1,72 @@
 import { Tooltip } from "@material-ui/core";
 import ImageSelector from "components/image-selector/ImageSelector";
-import React from "react";
+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 } from "../types";
+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);
+      });
+  };
 
-const _SourceConfig = ({ revision }: { revision: FullStackRevision }) => {
   return (
     <SourceConfigStyles.Wrapper>
       {revision.source_configs.map((sourceConfig) => {
         const apps = getAppsFromSourceConfig(revision.resources, sourceConfig);
 
         const appList = formatAppList(apps, 2);
-        console.log({ appList });
         return (
           <SourceConfigStyles.ItemContainer>
             {appList.hiddenApps?.length ? (
@@ -36,15 +91,26 @@ const _SourceConfig = ({ revision }: { revision: FullStackRevision }) => {
                 Used by {appList.value}
               </SourceConfigStyles.ItemTitle>
             )}
-            <ImageSelector
-              selectedImageUrl={sourceConfig.image_repo_uri}
-              selectedTag={sourceConfig.image_tag}
-              forceExpanded
-              readOnly
+            <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>
   );
 };
@@ -89,6 +155,7 @@ const formatAppList = (apps: AppResource[], limit: number = 3) => {
 const SourceConfigStyles = {
   Wrapper: styled.div`
     margin-top: 30px;
+    position: relative;
   `,
   ItemContainer: styled.div`
     background: #ffffff11;
@@ -102,4 +169,12 @@ const SourceConfigStyles = {
   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 - 0
dashboard/src/main/home/cluster-dashboard/stacks/types.ts

@@ -31,6 +31,7 @@ export type Stack = {
   name: string;
   created_at: string;
   updated_at: string;
+  namespace: string;
 
   revisions: 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>

+ 21 - 1
dashboard/src/shared/api.tsx

@@ -3,7 +3,10 @@ import { PullRequest } from "main/home/cluster-dashboard/preview-environments/ty
 import { baseApi } from "./baseApi";
 
 import { BuildConfig, FullActionConfigType } from "./types";
-import { CreateStackBody } from "main/home/cluster-dashboard/stacks/types";
+import {
+  CreateStackBody,
+  SourceConfig,
+} from "main/home/cluster-dashboard/stacks/types";
 
 /**
  * Generic api call format
@@ -2029,6 +2032,22 @@ 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`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -2220,4 +2239,5 @@ export default {
   createStack,
   rollbackStack,
   deleteStack,
+  updateStackSourceConfig,
 };

+ 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;

+ 1 - 1
go.mod

@@ -39,7 +39,7 @@ require (
 	github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6
 	github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799
 	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

+ 2 - 0
go.sum

@@ -1388,6 +1388,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/porter-dev/switchboard v0.0.0-20220416181342-416fc450addb h1:WNKCA31IJaGnf0VR0uzb3b10IzQb3OSuGlFi8X/AnLs=
 github.com/porter-dev/switchboard v0.0.0-20220416181342-416fc450addb/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
+github.com/porter-dev/switchboard v0.0.0-20220628112428-7665a0121e4f h1:REYJSDm2R3pM4mq88AlSBPIPhGiKFwiehe+GKZIc7Hc=
+github.com/porter-dev/switchboard v0.0.0-20220628112428-7665a0121e4f/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
 github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
 github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=

+ 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,

+ 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 = LOWER(?) AND git_repo_name = LOWER(?)",
-		projectID, clusterID, gitInstallationID,
-		strings.ToLower(gitRepoOwner), strings.ToLower(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 = LOWER(?) AND git_repo_name = LOWER(?)",
-		projectID, clusterID, strings.ToLower(gitRepoOwner), strings.ToLower(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 = LOWER(?) AND git_repo_name = LOWER(?)",
-		webhookID, strings.ToLower(gitRepoOwner), strings.ToLower(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
 }
 
@@ -148,11 +181,21 @@ func (repo *EnvironmentRepository) ReadDeploymentByGitDetails(
 ) (*models.Deployment, error) {
 	depl := &models.Deployment{}
 
-	if err := repo.db.Order("id asc").
-		Where("environment_id = ? AND repo_owner = LOWER(?) AND repo_name = LOWER(?) AND pull_request_id = ?",
-			environmentID, strings.ToLower(gitRepoOwner), strings.ToLower(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

+ 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)

+ 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 {