Просмотр исходного кода

Merge pull request #2184 from porter-dev/staging

Preview env improvements -> production
abelanger5 3 лет назад
Родитель
Сommit
f80af871a6
28 измененных файлов с 957 добавлено и 435 удалено
  1. 9 18
      api/client/environment.go
  2. 1 1
      api/server/handlers/environment/create_deployment.go
  3. 16 9
      api/server/handlers/environment/get_deployment_by_env.go
  4. 1 1
      api/server/handlers/environment/list.go
  5. 6 0
      api/server/handlers/environment/update_deployment_status.go
  6. 95 4
      api/server/handlers/webhook/github_incoming.go
  7. 3 3
      api/server/router/cluster.go
  8. 1 1
      api/server/router/git_installation.go
  9. 2 0
      api/types/environment.go
  10. 26 30
      cli/cmd/apply.go
  11. 18 2
      cli/cmd/docker/builder.go
  12. 52 25
      dashboard/src/components/image-selector/ImageList.tsx
  13. 51 10
      dashboard/src/components/image-selector/ImageSelector.tsx
  14. 17 7
      dashboard/src/components/image-selector/TagList.tsx
  15. 1 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentDetail.tsx
  16. 0 153
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack.tsx
  17. 219 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx
  18. 290 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_RevisionList.tsx
  19. 105 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_SourceConfig.tsx
  20. 1 1
      dashboard/src/main/home/cluster-dashboard/stacks/routes.tsx
  21. 6 4
      dashboard/src/main/home/cluster-dashboard/stacks/types.ts
  22. 7 5
      dashboard/src/shared/api.tsx
  23. 4 3
      go.mod
  24. 9 0
      go.sum
  25. 3 2
      internal/integrations/ci/actions/actions.go
  26. 6 131
      internal/integrations/ci/actions/preview.go
  27. 0 16
      internal/integrations/ci/actions/steps.go
  28. 8 8
      internal/repository/gorm/environment.go

+ 9 - 18
api/client/environment.go

@@ -7,20 +7,15 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
 )
 )
 
 
-func (c *Client) CreateDeployment(
+func (c *Client) ListEnvironments(
 	ctx context.Context,
 	ctx context.Context,
-	projID, gitInstallationID, clusterID uint,
-	gitRepoOwner, gitRepoName string,
-	req *types.CreateDeploymentRequest,
-) (*types.Deployment, error) {
-	resp := &types.Deployment{}
+	projID, clusterID uint,
+) (*types.ListEnvironmentsResponse, error) {
+	resp := &types.ListEnvironmentsResponse{}
 
 
-	err := c.postRequest(
-		fmt.Sprintf(
-			"/projects/%d/gitrepos/%d/%s/%s/clusters/%d/deployment",
-			projID, gitInstallationID, gitRepoOwner, gitRepoName, clusterID,
-		),
-		req,
+	err := c.getRequest(
+		fmt.Sprintf("/projects/%d/clusters/%d/environments", projID, clusterID),
+		nil,
 		resp,
 		resp,
 	)
 	)
 
 
@@ -29,17 +24,13 @@ func (c *Client) CreateDeployment(
 
 
 func (c *Client) GetDeployment(
 func (c *Client) GetDeployment(
 	ctx context.Context,
 	ctx context.Context,
-	projID, gitInstallationID, clusterID uint,
-	gitRepoOwner, gitRepoName string,
+	projID, clusterID, envID uint,
 	req *types.GetDeploymentRequest,
 	req *types.GetDeploymentRequest,
 ) (*types.Deployment, error) {
 ) (*types.Deployment, error) {
 	resp := &types.Deployment{}
 	resp := &types.Deployment{}
 
 
 	err := c.getRequest(
 	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,
 		req,
 		resp,
 		resp,
 	)
 	)

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

@@ -142,7 +142,7 @@ func createDeployment(
 	// Create Deployment Status to indicate it's in progress
 	// Create Deployment Status to indicate it's in progress
 
 
 	state := "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{
 	deploymentStatusRequest := github.DeploymentStatusRequest{
 		State:  &state,
 		State:  &state,

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

@@ -15,21 +15,21 @@ import (
 	"gorm.io/gorm"
 	"gorm.io/gorm"
 )
 )
 
 
-type GetDeploymentByClusterHandler struct {
+type GetDeploymentByEnvironmentHandler struct {
 	handlers.PorterHandlerReadWriter
 	handlers.PorterHandlerReadWriter
 }
 }
 
 
-func NewGetDeploymentByClusterHandler(
+func NewGetDeploymentByEnvironmentHandler(
 	config *config.Config,
 	config *config.Config,
 	decoderValidator shared.RequestDecoderValidator,
 	decoderValidator shared.RequestDecoderValidator,
 	writer shared.ResultWriter,
 	writer shared.ResultWriter,
-) *GetDeploymentByClusterHandler {
-	return &GetDeploymentByClusterHandler{
+) *GetDeploymentByEnvironmentHandler {
+	return &GetDeploymentByEnvironmentHandler{
 		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
 		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)
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 	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)
 	_, 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))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 		return
 	}
 	}
@@ -59,6 +61,11 @@ func (c *GetDeploymentByClusterHandler) ServeHTTP(w http.ResponseWriter, r *http
 	depl, err := c.Repo().Environment().ReadDeployment(envID, request.Namespace)
 	depl, err := c.Repo().Environment().ReadDeployment(envID, request.Namespace)
 
 
 	if err != nil {
 	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))
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 		return
 	}
 	}

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

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

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

@@ -63,6 +63,12 @@ func (c *UpdateDeploymentStatusHandler) ServeHTTP(w http.ResponseWriter, r *http
 		return
 		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)
 	depl.Status = types.DeploymentStatus(request.Status)
 
 
 	// create the deployment
 	// create the deployment

+ 95 - 4
api/server/handlers/webhook/github_incoming.go

@@ -6,6 +6,7 @@ import (
 	"net/http"
 	"net/http"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
+	"sync"
 
 
 	"github.com/bradleyfalzon/ghinstallation/v2"
 	"github.com/bradleyfalzon/ghinstallation/v2"
 	"github.com/google/go-github/v41/github"
 	"github.com/google/go-github/v41/github"
@@ -91,7 +92,50 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 	}
 	}
 
 
 	if env.Mode == "auto" && event.GetAction() == "opened" {
 	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),
 			r.Context(), owner, repo, fmt.Sprintf("porter_%s_env.yml", env.Name),
 			github.CreateWorkflowDispatchEventRequest{
 			github.CreateWorkflowDispatchEventRequest{
 				Ref: event.GetPullRequest().GetHead().GetRef(),
 				Ref: event.GetPullRequest().GetHead().GetRef(),
@@ -142,12 +186,58 @@ func (c *GithubIncomingWebhookHandler) processPullRequestEvent(event *github.Pul
 					event.GetPullRequest().GetNumber(), err)
 					event.GetPullRequest().GetNumber(), err)
 			}
 			}
 		} else {
 		} 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)
+			}
+
+			wg.Wait()
+
 			err = c.deleteDeployment(r, depl, env, client)
 			err = c.deleteDeployment(r, depl, env, client)
 
 
 			if err != nil {
 			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] "+
 				return 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)
+					"deployment deleted but errors found while trying to cancel active workflow runs %w", webhookID, owner, repo, env.ID, depl.ID,
+					event.GetPullRequest().GetNumber(), chanErr)
 			}
 			}
 		}
 		}
 	}
 	}
@@ -164,7 +254,8 @@ func (c *GithubIncomingWebhookHandler) deleteDeployment(
 	cluster, err := c.Repo().Cluster().ReadCluster(env.ProjectID, env.ClusterID)
 	cluster, err := c.Repo().Cluster().ReadCluster(env.ProjectID, env.ClusterID)
 
 
 	if err != nil {
 	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, "")
 	agent, err := c.GetAgent(r, cluster, "")

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

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

+ 1 - 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 ->
 		// GET /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/deployment ->
-		// environment.NewCreateDeploymentHandler
+		// environment.NewGetDeploymentHandler
 		getDeploymentEndpoint := factory.NewAPIEndpoint(
 		getDeploymentEndpoint := factory.NewAPIEndpoint(
 			&types.APIRequestMetadata{
 			&types.APIRequestMetadata{
 				Verb:   types.APIVerbGet,
 				Verb:   types.APIVerbGet,

+ 2 - 0
api/types/environment.go

@@ -110,3 +110,5 @@ type PullRequest struct {
 	BranchFrom string `json:"branch_from"`
 	BranchFrom string `json:"branch_from"`
 	BranchInto string `json:"branch_into"`
 	BranchInto string `json:"branch_into"`
 }
 }
+
+type ListEnvironmentsResponse []*Environment

+ 26 - 30
cli/cmd/apply.go

@@ -636,7 +636,7 @@ func (d *Driver) getAddonConfig(resource *models.Resource) (map[string]interface
 type DeploymentHook struct {
 type DeploymentHook struct {
 	client                                                                    *api.Client
 	client                                                                    *api.Client
 	resourceGroup                                                             *switchboardTypes.ResourceGroup
 	resourceGroup                                                             *switchboardTypes.ResourceGroup
-	gitInstallationID, projectID, clusterID, prID, actionID                   uint
+	gitInstallationID, projectID, clusterID, prID, actionID, envID            uint
 	branchFrom, branchInto, namespace, repoName, repoOwner, prName, commitSHA string
 	branchFrom, branchInto, namespace, repoName, repoOwner, prName, commitSHA string
 }
 }
 
 
@@ -713,40 +713,37 @@ func NewDeploymentHook(client *api.Client, resourceGroup *switchboardTypes.Resou
 }
 }
 
 
 func (t *DeploymentHook) PreApply() error {
 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
 	// attempt to read the deployment -- if it doesn't exist, create it
-	_, err := t.client.GetDeployment(
+	_, err = t.client.GetDeployment(
 		context.Background(),
 		context.Background(),
-		t.projectID, t.gitInstallationID, t.clusterID,
-		t.repoOwner, t.repoName,
+		t.projectID, t.clusterID, t.envID,
 		&types.GetDeploymentRequest{
 		&types.GetDeploymentRequest{
 			Namespace: t.namespace,
 			Namespace: t.namespace,
 		},
 		},
 	)
 	)
 
 
-	// TODO: case this on the response status code rather than text
-	if err != nil && strings.Contains(err.Error(), "deployment 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 {
+	if err == nil {
 		_, err = t.client.UpdateDeployment(
 		_, err = t.client.UpdateDeployment(
 			context.Background(),
 			context.Background(),
 			t.projectID, t.gitInstallationID, t.clusterID,
 			t.projectID, t.gitInstallationID, t.clusterID,
@@ -858,8 +855,7 @@ func (t *DeploymentHook) OnError(err error) {
 	// if the deployment exists, throw an error for that deployment
 	// if the deployment exists, throw an error for that deployment
 	_, getDeplErr := t.client.GetDeployment(
 	_, getDeplErr := t.client.GetDeployment(
 		context.Background(),
 		context.Background(),
-		t.projectID, t.gitInstallationID, t.clusterID,
-		t.repoOwner, t.repoName,
+		t.projectID, t.clusterID, t.envID,
 		&types.GetDeploymentRequest{
 		&types.GetDeploymentRequest{
 			Namespace: t.namespace,
 			Namespace: t.namespace,
 		},
 		},

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

@@ -12,6 +12,7 @@ import (
 
 
 	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/pkg/archive"
 	"github.com/docker/docker/pkg/archive"
+	"github.com/moby/buildkit/frontend/dockerfile/dockerignore"
 	"github.com/moby/moby/pkg/jsonmessage"
 	"github.com/moby/moby/pkg/jsonmessage"
 	"github.com/moby/moby/pkg/stringid"
 	"github.com/moby/moby/pkg/stringid"
 	"github.com/moby/term"
 	"github.com/moby/term"
@@ -31,9 +32,24 @@ type BuildOpts struct {
 }
 }
 
 
 // BuildLocal
 // BuildLocal
-func (a *Agent) BuildLocal(opts *BuildOpts) error {
+func (a *Agent) BuildLocal(opts *BuildOpts) (err error) {
 	dockerfilePath := opts.DockerfilePath
 	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 {
 	if err != nil {
 		return err
 		return err

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

@@ -9,17 +9,31 @@ import { ImageType } from "shared/types";
 import Loading from "../Loading";
 import Loading from "../Loading";
 import TagList from "./TagList";
 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 = {
 type StateType = {
   loading: boolean;
   loading: boolean;
@@ -222,28 +236,41 @@ export default class ImageList extends Component<PropsType, StateType> {
   renderExpanded = () => {
   renderExpanded = () => {
     let { selectedTag, selectedImageUrl, setSelectedTag } = this.props;
     let { selectedTag, selectedImageUrl, setSelectedTag } = this.props;
 
 
-    if (!this.props.clickedImage || this.props.noTagSelection) {
+    if (this.props.readOnly && this.props.clickedImage) {
       return (
       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 (
       return (
         <div>
         <div>
-          <ExpandedWrapper>
-            <TagList
-              selectedTag={selectedTag}
-              selectedImageUrl={selectedImageUrl}
-              setSelectedTag={setSelectedTag}
-              registryId={this.props.clickedImage.registryId}
-            />
-          </ExpandedWrapper>
+          <ExpandedWrapper>{this.renderImageList()}</ExpandedWrapper>
           {this.renderBackButton()}
           {this.renderBackButton()}
         </div>
         </div>
       );
       );
     }
     }
+
+    return (
+      <div>
+        <ExpandedWrapper>
+          <TagList
+            selectedTag={selectedTag}
+            selectedImageUrl={selectedImageUrl}
+            setSelectedTag={setSelectedTag}
+            registryId={this.props.clickedImage.registryId}
+          />
+        </ExpandedWrapper>
+        {this.renderBackButton()}
+      </div>
+    );
   };
   };
 
 
   render() {
   render() {

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

@@ -10,15 +10,27 @@ import { ImageType } from "shared/types";
 import Loading from "../Loading";
 import Loading from "../Loading";
 import ImageList from "./ImageList";
 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 = {
 type StateType = {
   isExpanded: boolean;
   isExpanded: boolean;
@@ -94,7 +106,7 @@ export default class ImageSelector extends Component<PropsType, StateType> {
       <Label>
       <Label>
         <img src={icon} />
         <img src={icon} />
         <Input
         <Input
-          disabled={this.props.disableImageSelect}
+          disabled={this.props.readOnly || this.props.disableImageSelect}
           onClick={(e: any) => e.stopPropagation()}
           onClick={(e: any) => e.stopPropagation()}
           value={selectedImageUrl}
           value={selectedImageUrl}
           onChange={(e: any) => {
           onChange={(e: any) => {
@@ -118,6 +130,35 @@ export default class ImageSelector extends Component<PropsType, StateType> {
   };
   };
 
 
   render() {
   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 (
     return (
       <div>
       <div>
         <StyledImageSelector
         <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;
 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 = {
 type StateType = {
   loading: boolean;
   loading: boolean;
@@ -123,7 +132,8 @@ export default class TagList extends Component<PropsType, StateType> {
       <>
       <>
         <TagNameAlt>
         <TagNameAlt>
           <Label>
           <Label>
-            <img src={info} /> Select Image Tag
+            <img src={info} />
+            {this.props.readOnly ? "Current image tag" : "Select Image Tag"}
           </Label>
           </Label>
           <Refresh onClick={this.refreshTagList}>
           <Refresh onClick={this.refreshTagList}>
             <i className="material-icons">autorenew</i> Refresh
             <i className="material-icons">autorenew</i> Refresh

+ 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"));
     let environment_id = parseInt(searchParams.get("environment_id"));
     setEnvironmentId(searchParams.get("environment_id"));
     setEnvironmentId(searchParams.get("environment_id"));
     api
     api
-      .getPRDeploymentByCluster(
+      .getPRDeploymentByEnvironment(
         "<token>",
         "<token>",
         {
         {
           namespace: params.namespace,
           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;
-  `,
-};

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

@@ -0,0 +1,219 @@
+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,
+  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>
+      <TitleSection
+        materialIconClass="material-icons-outlined"
+        icon={"lan"}
+        capitalize
+      >
+        {stack.name}
+      </TitleSection>
+      <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 revision={currentRevision}></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;
+  `,
+};

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

@@ -0,0 +1,290 @@
+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`
+  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;
+  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;
+`;
+
+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;
+`;

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

@@ -0,0 +1,105 @@
+import { Tooltip } from "@material-ui/core";
+import ImageSelector from "components/image-selector/ImageSelector";
+import React from "react";
+import styled from "styled-components";
+import { AppResource, FullStackRevision, SourceConfig } from "../types";
+
+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 ? (
+              <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>
+            )}
+            <ImageSelector
+              selectedImageUrl={sourceConfig.image_repo_uri}
+              selectedTag={sourceConfig.image_tag}
+              forceExpanded
+              readOnly
+            />
+          </SourceConfigStyles.ItemContainer>
+        );
+      })}
+    </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;
+  `,
+  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;
+  `,
+};

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

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

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

@@ -34,10 +34,12 @@ export type Stack = {
 
 
   revisions: StackRevision[];
   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 = {
 export type StackRevision = {

+ 7 - 5
dashboard/src/shared/api.tsx

@@ -359,7 +359,7 @@ const getPRDeploymentList = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/deployments`;
   return `/api/projects/${project_id}/clusters/${cluster_id}/deployments`;
 });
 });
 
 
-const getPRDeploymentByCluster = baseApi<
+const getPRDeploymentByEnvironment = baseApi<
   {
   {
     namespace: string;
     namespace: string;
   },
   },
@@ -371,7 +371,7 @@ const getPRDeploymentByCluster = baseApi<
 >("GET", (pathParams) => {
 >("GET", (pathParams) => {
   const { cluster_id, project_id, environment_id } = 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<
 const getPRDeployment = baseApi<
@@ -1991,7 +1991,7 @@ const getStackRevision = baseApi<
     cluster_id: number;
     cluster_id: number;
     namespace: string;
     namespace: string;
     stack_id: string;
     stack_id: string;
-    revision_id: string;
+    revision_id: number;
   }
   }
 >(
 >(
   "GET",
   "GET",
@@ -2000,7 +2000,9 @@ const getStackRevision = baseApi<
 );
 );
 
 
 const rollbackStack = baseApi<
 const rollbackStack = baseApi<
-  {},
+  {
+    target_revision: number;
+  },
   {
   {
     project_id: number;
     project_id: number;
     cluster_id: number;
     cluster_id: number;
@@ -2087,7 +2089,7 @@ export default {
   getClusterNode,
   getClusterNode,
   getConfigMap,
   getConfigMap,
   getPRDeploymentList,
   getPRDeploymentList,
-  getPRDeploymentByCluster,
+  getPRDeploymentByEnvironment,
   getPRDeployment,
   getPRDeployment,
   getGHAWorkflowTemplate,
   getGHAWorkflowTemplate,
   getGitRepoList,
   getGitRepoList,

+ 4 - 3
go.mod

@@ -154,7 +154,7 @@ require (
 	github.com/google/go-querystring v1.1.0 // indirect
 	github.com/google/go-querystring v1.1.0 // indirect
 	github.com/google/gofuzz v1.2.0 // indirect
 	github.com/google/gofuzz v1.2.0 // indirect
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // 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/gax-go/v2 v2.1.1 // indirect
 	github.com/googleapis/gnostic v0.5.5 // indirect
 	github.com/googleapis/gnostic v0.5.5 // indirect
 	github.com/gorilla/mux v1.8.0 // indirect
 	github.com/gorilla/mux v1.8.0 // indirect
@@ -205,6 +205,7 @@ require (
 	github.com/mitchellh/go-wordwrap v1.0.0 // indirect
 	github.com/mitchellh/go-wordwrap v1.0.0 // indirect
 	github.com/mitchellh/ioprogress v0.0.0-20180201004757-6a23b12fa88e // indirect
 	github.com/mitchellh/ioprogress v0.0.0-20180201004757-6a23b12fa88e // indirect
 	github.com/mitchellh/reflectwalk v1.0.2 // 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/locker v1.0.1 // indirect
 	github.com/moby/spdystream v0.2.0 // indirect
 	github.com/moby/spdystream v0.2.0 // indirect
 	github.com/moby/sys/mount v0.3.2 // indirect
 	github.com/moby/sys/mount v0.3.2 // indirect
@@ -220,9 +221,9 @@ require (
 	github.com/pelletier/go-toml v1.9.5 // indirect
 	github.com/pelletier/go-toml v1.9.5 // indirect
 	github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
 	github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
 	github.com/pmezard/go-difflib v1.0.0 // 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.1 // indirect
 	github.com/prometheus/client_model v0.2.0 // indirect
 	github.com/prometheus/client_model v0.2.0 // indirect
-	github.com/prometheus/common v0.30.0 // indirect
+	github.com/prometheus/common v0.32.1 // indirect
 	github.com/prometheus/procfs v0.7.3 // indirect
 	github.com/prometheus/procfs v0.7.3 // indirect
 	github.com/rivo/tview v0.0.0-20220307222120-9994674d60a8 // indirect
 	github.com/rivo/tview v0.0.0-20220307222120-9994674d60a8 // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect

+ 9 - 0
go.sum

@@ -827,6 +827,8 @@ github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs=
 github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs=
 github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
 github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
@@ -1230,6 +1232,8 @@ github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx
 github.com/mitchellh/reflectwalk v1.0.1/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
 github.com/mitchellh/reflectwalk v1.0.1/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
 github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
 github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
 github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
 github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
+github.com/moby/buildkit v0.10.3 h1:/dGykD8FW+H4p++q5+KqKEo6gAkYKyBQHdawdjVwVAU=
+github.com/moby/buildkit v0.10.3/go.mod h1:jxeOuly98l9gWHai0Ojrbnczrk/rf+o9/JqNhY+UCSo=
 github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg=
 github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg=
 github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc=
 github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc=
 github.com/moby/moby v20.10.6+incompatible h1:3wn5wW3KwjAv8Z36VHdbvaqvY273JiWUDFuudH0z5Vs=
 github.com/moby/moby v20.10.6+incompatible h1:3wn5wW3KwjAv8Z36VHdbvaqvY273JiWUDFuudH0z5Vs=
@@ -1398,6 +1402,8 @@ github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP
 github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
 github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
 github.com/prometheus/client_golang v1.11.1 h1:+4eQaD7vAZ6DsfsxB15hbE0odUjGI5ARs9yskGu1v4s=
 github.com/prometheus/client_golang v1.11.1 h1:+4eQaD7vAZ6DsfsxB15hbE0odUjGI5ARs9yskGu1v4s=
 github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
 github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
+github.com/prometheus/client_golang v1.12.1 h1:ZiaPsmm9uiBeaSMRznKsCDNtPCS0T3JVDGF+06gjBzk=
+github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
 github.com/prometheus/client_model v0.0.0-20171117100541-99fa1f4be8e5/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
 github.com/prometheus/client_model v0.0.0-20171117100541-99fa1f4be8e5/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
 github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
 github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
 github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@@ -1416,6 +1422,8 @@ github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9
 github.com/prometheus/common v0.28.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
 github.com/prometheus/common v0.28.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
 github.com/prometheus/common v0.30.0 h1:JEkYlQnpzrzQFxi6gnukFPdQ+ac82oRhzMcIduJu/Ug=
 github.com/prometheus/common v0.30.0 h1:JEkYlQnpzrzQFxi6gnukFPdQ+ac82oRhzMcIduJu/Ug=
 github.com/prometheus/common v0.30.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
 github.com/prometheus/common v0.30.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
+github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4=
+github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
 github.com/prometheus/procfs v0.0.0-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
 github.com/prometheus/procfs v0.0.0-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
 github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
 github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
 github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
 github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
@@ -2044,6 +2052,7 @@ golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 h1:xHms4gcpe1YE7A3yIllJXP16CMAGuqwO2lX1mTyyRRc=
 golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 h1:xHms4gcpe1YE7A3yIllJXP16CMAGuqwO2lX1mTyyRRc=

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

@@ -235,8 +235,9 @@ type GithubActionYAMLOnPush struct {
 }
 }
 
 
 type GithubActionYAMLJob 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 {
 type GithubActionYAML struct {

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

@@ -73,12 +73,6 @@ func SetupEnv(opts *EnvOpts) error {
 		return err
 		return err
 	}
 	}
 
 
-	deleteWorkflowYAML, err := getPreviewDeleteActionYAML(opts)
-
-	if err != nil {
-		return err
-	}
-
 	githubBranch, _, err := opts.Client.Repositories.GetBranch(
 	githubBranch, _, err := opts.Client.Repositories.GetBranch(
 		context.Background(), opts.GitRepoOwner, opts.GitRepoName, defaultBranch, true,
 		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"+
 				"Unable to create PR to merge workflow files into protected branch: %s.\n"+
 					"To enable Porter Preview Environment deployments, please create Github workflow "+
 					"To enable Porter Preview Environment deployments, please create Github workflow "+
 					"files in this branch with the following contents:\n"+
 					"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"+
 				"Unable to create PR to merge workflow files into protected branch: %s.\n"+
 					"To enable Porter Preview Environment deployments, please create Github workflow "+
 					"To enable Porter Preview Environment deployments, please create Github workflow "+
 					"files in this branch with the following contents:\n"+
 					"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",
 					"--------\n%s--------\nERROR: %w",
-				defaultBranch, string(deleteWorkflowYAML), ErrCreatePRForProtectedBranch,
+				defaultBranch, string(applyWorkflowYAML), ErrCreatePRForProtectedBranch,
 			)
 			)
 		}
 		}
 
 
@@ -160,75 +137,6 @@ func SetupEnv(opts *EnvOpts) error {
 		false,
 		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
 	return err
 }
 }
 
 
@@ -349,43 +257,10 @@ func getPreviewApplyActionYAML(opts *EnvOpts) ([]byte, error) {
 		Jobs: map[string]GithubActionYAMLJob{
 		Jobs: map[string]GithubActionYAMLJob{
 			"porter-preview": {
 			"porter-preview": {
 				RunsOn: "ubuntu-latest",
 				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 updateAppActionName = "porter-dev/porter-update-action"
 const createPreviewActionName = "porter-dev/porter-preview-action"
 const createPreviewActionName = "porter-dev/porter-preview-action"
-const deletePreviewActionName = "porter-dev/porter-delete-preview-action"
 
 
 func getCheckoutCodeStep() GithubActionYAMLStep {
 func getCheckoutCodeStep() GithubActionYAMLStep {
 	return GithubActionYAMLStep{
 	return GithubActionYAMLStep{
@@ -68,18 +67,3 @@ func getCreatePreviewEnvStep(
 		Timeout: 30,
 		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 - 8
internal/repository/gorm/environment.go

@@ -29,9 +29,9 @@ func (repo *EnvironmentRepository) CreateEnvironment(env *models.Environment) (*
 func (repo *EnvironmentRepository) ReadEnvironment(projectID, clusterID, gitInstallationID uint, gitRepoOwner, gitRepoName string) (*models.Environment, error) {
 func (repo *EnvironmentRepository) ReadEnvironment(projectID, clusterID, gitInstallationID uint, gitRepoOwner, gitRepoName string) (*models.Environment, error) {
 	env := &models.Environment{}
 	env := &models.Environment{}
 	if err := repo.db.Order("id desc").Where(
 	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 = ?",
+		"project_id = ? AND cluster_id = ? AND git_installation_id = ? AND git_repo_owner = LOWER(?) AND git_repo_name = LOWER(?)",
 		projectID, clusterID, gitInstallationID,
 		projectID, clusterID, gitInstallationID,
-		gitRepoOwner, gitRepoName,
+		strings.ToLower(gitRepoOwner), strings.ToLower(gitRepoName),
 	).First(&env).Error; err != nil {
 	).First(&env).Error; err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
@@ -56,8 +56,8 @@ func (repo *EnvironmentRepository) ReadEnvironmentByOwnerRepoName(
 	gitRepoOwner, gitRepoName string,
 	gitRepoOwner, gitRepoName string,
 ) (*models.Environment, error) {
 ) (*models.Environment, error) {
 	env := &models.Environment{}
 	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,
+	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 {
 	).First(&env).Error; err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
@@ -68,8 +68,8 @@ func (repo *EnvironmentRepository) ReadEnvironmentByWebhookIDOwnerRepoName(
 	webhookID, gitRepoOwner, gitRepoName string,
 	webhookID, gitRepoOwner, gitRepoName string,
 ) (*models.Environment, error) {
 ) (*models.Environment, error) {
 	env := &models.Environment{}
 	env := &models.Environment{}
-	if err := repo.db.Order("id desc").Where("webhook_id = ? AND git_repo_owner = ? AND git_repo_name = ?",
-		webhookID, gitRepoOwner, gitRepoName,
+	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 {
 	).First(&env).Error; err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
@@ -149,8 +149,8 @@ func (repo *EnvironmentRepository) ReadDeploymentByGitDetails(
 	depl := &models.Deployment{}
 	depl := &models.Deployment{}
 
 
 	if err := repo.db.Order("id asc").
 	if err := repo.db.Order("id asc").
-		Where("environment_id = ? AND repo_owner = ? AND repo_name = ? AND pull_request_id = ?",
-			environmentID, gitRepoOwner, gitRepoName, prNumber).
+		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 {
 		First(&depl).Error; err != nil {
 		return nil, err
 		return nil, err
 	}
 	}