Explorar o código

Merge branch 'master' of github.com:porter-dev/porter into nico/por-652-porter-form-add-url-link-component

jnfrati %!s(int64=3) %!d(string=hai) anos
pai
achega
b19ffaa476
Modificáronse 84 ficheiros con 2004 adicións e 383 borrados
  1. 7 0
      .github/workflows/prerelease.yaml
  2. 3 2
      .gitignore
  3. 1 1
      Makefile
  4. 20 0
      api/client/registry.go
  5. 45 20
      api/server/handlers/environment/create.go
  6. 45 28
      api/server/handlers/environment/create_deployment.go
  7. 13 8
      api/server/handlers/environment/delete.go
  8. 27 22
      api/server/handlers/environment/delete_deployment.go
  9. 14 7
      api/server/handlers/environment/enable_pull_request.go
  10. 11 40
      api/server/handlers/environment/finalize_deployment.go
  11. 4 9
      api/server/handlers/environment/finalize_deployment_with_errors.go
  12. 8 2
      api/server/handlers/environment/list.go
  13. 1 1
      api/server/handlers/environment/update_deployment.go
  14. 2 0
      api/server/handlers/infra/create.go
  15. 105 18
      api/server/handlers/infra/forms.go
  16. 2 0
      api/server/handlers/infra/get_template.go
  17. 8 0
      api/server/handlers/infra/list_templates.go
  18. 1 1
      api/server/handlers/registry/create.go
  19. 63 0
      api/server/handlers/registry/get_token.go
  20. 23 0
      api/server/handlers/release/upgrade.go
  21. 11 2
      api/server/handlers/stack/create.go
  22. 64 0
      api/server/handlers/stack/update_stack.go
  23. 23 0
      api/server/handlers/v1/release/upgrade.go
  24. 1 1
      api/server/router/git_installation.go
  25. 28 0
      api/server/router/project.go
  26. 57 1
      api/server/router/v1/stack.go
  27. 1 0
      api/types/infra.go
  28. 5 0
      api/types/integrations.go
  29. 7 2
      api/types/registry.go
  30. 8 0
      api/types/release.go
  31. 10 0
      api/types/stacks.go
  32. 63 46
      cli/cmd/apply.go
  33. 26 0
      cli/cmd/connect.go
  34. 92 0
      cli/cmd/connect/gar.go
  35. 2 2
      cli/cmd/connect/gcr.go
  36. 5 0
      cli/cmd/deploy/create.go
  37. 50 0
      cli/cmd/docker/auth.go
  38. 2 2
      cli/cmd/preview/build_image_driver.go
  39. 1 1
      cli/cmd/preview/env_group_driver.go
  40. 1 1
      cli/cmd/preview/push_image_driver.go
  41. 2 2
      cli/cmd/preview/update_config_driver.go
  42. 17 14
      cli/cmd/preview/utils.go
  43. 4 3
      cmd/app/main.go
  44. 4 1
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  45. 4 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  46. 1 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  47. 1 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx
  48. 1 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/build-settings/BuildSettingsTab.tsx
  49. 1 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/useJobs.ts
  50. 5 1
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx
  51. 111 72
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_SourceConfig.tsx
  52. 67 5
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/Settings.tsx
  53. 21 3
      dashboard/src/main/home/cluster-dashboard/stacks/launch/SelectSource.tsx
  54. 5 7
      dashboard/src/main/home/cluster-dashboard/stacks/launch/Store.tsx
  55. 2 0
      dashboard/src/main/home/cluster-dashboard/stacks/types.ts
  56. 3 0
      dashboard/src/main/home/integrations/create-integration/CreateIntegrationForm.tsx
  57. 156 0
      dashboard/src/main/home/integrations/create-integration/GARForm.tsx
  58. 6 0
      dashboard/src/main/home/onboarding/components/ProviderSelector.tsx
  59. 26 0
      dashboard/src/main/home/onboarding/constants.ts
  60. 3 1
      dashboard/src/main/home/onboarding/state/StateHandler.ts
  61. 12 0
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/FormFlow.tsx
  62. 135 0
      dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/_GCPRegistryForm.tsx
  63. 1 1
      dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx
  64. 14 35
      dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_GCPProvisionerForm.tsx
  65. 13 0
      dashboard/src/main/home/onboarding/types.ts
  66. 17 0
      dashboard/src/shared/api.tsx
  67. 10 0
      dashboard/src/shared/common.tsx
  68. 2 0
      dashboard/src/shared/hooks/useChart.ts
  69. 8 0
      dashboard/src/shared/types.tsx
  70. 7 3
      go.mod
  71. 78 0
      go.sum
  72. 2 0
      internal/helm/postrenderer.go
  73. 5 1
      internal/models/registry.go
  74. 13 8
      internal/models/stack.go
  75. 232 1
      internal/registry/registry.go
  76. 8 0
      internal/repository/gorm/stack.go
  77. 1 0
      internal/repository/stack.go
  78. 4 0
      internal/repository/test/stack.go
  79. 1 1
      internal/stacks/helpers.go
  80. 1 1
      provisioner/server/handlers/provision/apply.go
  81. 17 2
      provisioner/server/handlers/state/create_resource.go
  82. 1 1
      provisioner/server/handlers/state/delete_resource.go
  83. 4 3
      workers/jobs/helm_revisions_count_tracker.go
  84. 118 0
      workers/utils/retry_helm_agent.go

+ 7 - 0
.github/workflows/prerelease.yaml

@@ -577,6 +577,13 @@ jobs:
           git config user.email "support@porter.run"
 
           git diff --quiet --exit-code || git add . && git commit -m "Update to Porter GHA version ${{steps.tag_name.outputs.tag}}" && git push -f
+
+          git checkout test-preview-env
+          git merge main -m "Merge with main"
+
+          sed -i 's/TEST:v.*/TEST:${{ steps.tag_name.outputs.tag }}/g' porter.yaml
+
+          git diff --quiet --exit-code || git add . && git commit -m "Update to Porter GHA version ${{steps.tag_name.outputs.tag}}" && git push -f
   run-new-release-tests-workflows:
     name: Run new-release-tests Porter workflows
     runs-on: ubuntu-latest

+ 3 - 2
.gitignore

@@ -15,6 +15,7 @@ staging.sh
 *.key
 bin
 openapi.yaml
+.idea
 
 # Local docs directories
 /docs/.obsidian
@@ -37,8 +38,8 @@ openapi.yaml
 crash.log
 
 # Exclude all .tfvars files, which are likely to contain sentitive data, such as
-# password, private keys, and other secrets. These should not be part of version 
-# control as they are data points which are potentially sensitive and subject 
+# password, private keys, and other secrets. These should not be part of version
+# control as they are data points which are potentially sensitive and subject
 # to change depending on the environment.
 #
 *.tfvars

+ 1 - 1
Makefile

@@ -17,7 +17,7 @@ build-cli:
 	go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd/config.Version=${VERSION}'" -a -tags cli -o $(BINDIR)/porter ./cli
 
 build-cli-dev:
-	go build -tags cli -o $(BINDIR)/porter ./cli
+	go build -ldflags="-X 'github.com/porter-dev/porter/cli/cmd/config.Version=${VERSION}'" -tags cli -o $(BINDIR)/porter ./cli
 
 start-provisioner-dev: install setup-env-files
 	bash ./scripts/dev-environment/StartProvisionerServer.sh

+ 20 - 0
api/client/registry.go

@@ -123,6 +123,26 @@ func (c *Client) GetGCRAuthorizationToken(
 	return resp, err
 }
 
+// GetGARAuthorizationToken gets a GAR authorization token
+func (c *Client) GetGARAuthorizationToken(
+	ctx context.Context,
+	projectID uint,
+	req *types.GetRegistryGARTokenRequest,
+) (*types.GetRegistryTokenResponse, error) {
+	resp := &types.GetRegistryTokenResponse{}
+
+	err := c.getRequest(
+		fmt.Sprintf(
+			"/projects/%d/registries/gar/token",
+			projectID,
+		),
+		req,
+		resp,
+	)
+
+	return resp, err
+}
+
 // GetACRAuthorizationToken gets a ACR authorization token
 func (c *Client) GetACRAuthorizationToken(
 	ctx context.Context,

+ 45 - 20
api/server/handlers/environment/create.go

@@ -1,6 +1,7 @@
 package environment
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"net/http"
@@ -87,7 +88,7 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 
 	// create incoming webhook
 	hook, _, err := client.Repositories.CreateHook(
-		r.Context(), owner, name, &github.Hook{
+		context.Background(), owner, name, &github.Hook{
 			Config: map[string]interface{}{
 				"url":          webhookURL,
 				"content_type": "json",
@@ -98,10 +99,9 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		},
 	)
 
-	if err != nil && !strings.Contains(err.Error(), "already exists on this repository") {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			fmt.Errorf("error trying to create a new github repository webhook: %w", err), http.StatusConflict),
-		)
+	if err != nil && !strings.Contains(err.Error(), "already exists") {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("%v: %w", errGithubAPI, err),
+			http.StatusConflict))
 		return
 	}
 
@@ -110,6 +110,14 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	env, err = c.Repo().Environment().CreateEnvironment(env)
 
 	if err != nil {
+		_, deleteErr := client.Repositories.DeleteHook(context.Background(), owner, name, hook.GetID())
+
+		if deleteErr != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("%v: %w", errGithubAPI, deleteErr),
+				http.StatusConflict, "error creating environment"))
+			return
+		}
+
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
@@ -118,14 +126,44 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	jwt, err := token.GetTokenForAPI(user.ID, project.ID)
 
 	if err != nil {
-		c.deleteEnvAndReportError(w, r, env, err)
+		_, deleteErr := client.Repositories.DeleteHook(context.Background(), owner, name, hook.GetID())
+
+		if deleteErr != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("%v: %w", errGithubAPI, deleteErr),
+				http.StatusConflict, "error getting token for API while creating environment"))
+			return
+		}
+
+		_, deleteErr = c.Repo().Environment().DeleteEnvironment(env)
+
+		if deleteErr != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(deleteErr))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
 	encoded, err := jwt.EncodeToken(c.Config().TokenConf)
 
 	if err != nil {
-		c.deleteEnvAndReportError(w, r, env, err)
+		_, deleteErr := client.Repositories.DeleteHook(context.Background(), owner, name, hook.GetID())
+
+		if deleteErr != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("%v: %w", errGithubAPI, deleteErr),
+				http.StatusConflict, "error encoding token while creating environment"))
+			return
+		}
+
+		_, deleteErr = c.Repo().Environment().DeleteEnvironment(env)
+
+		if deleteErr != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(deleteErr))
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
@@ -159,19 +197,6 @@ func (c *CreateEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	c.WriteResult(w, r, env.ToEnvironmentType())
 }
 
-func (c *CreateEnvironmentHandler) deleteEnvAndReportError(
-	w http.ResponseWriter, r *http.Request, env *models.Environment, err error,
-) {
-	_, delErr := c.Repo().Environment().DeleteEnvironment(env)
-
-	if delErr != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(delErr))
-		return
-	}
-
-	c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-}
-
 func getGithubClientFromEnvironment(config *config.Config, env *models.Environment) (*github.Client, error) {
 	// get the github app client
 	ghAppId, err := strconv.Atoi(config.ServerConf.GithubAppID)

+ 45 - 28
api/server/handlers/environment/create_deployment.go

@@ -2,6 +2,7 @@ package environment
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"net/http"
 
@@ -15,8 +16,11 @@ import (
 	"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"
 )
 
+var errGithubAPI = errors.New("error communicating with the github API")
+
 type CreateDeploymentHandler struct {
 	handlers.PorterHandlerReadWriter
 	authz.KubernetesAgentGetter
@@ -54,6 +58,13 @@ func (c *CreateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	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("error creating deployment: no environment found")),
+			)
+			return
+		}
+
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
@@ -70,22 +81,21 @@ func (c *CreateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	prClosed, err := isGithubPRClosed(client, owner, name, int(request.PullRequestID))
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			fmt.Errorf("error fetching details of github PR. Error: %w", err), http.StatusConflict,
-		))
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusConflict))
 		return
 	}
 
 	if prClosed {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("Github PR has been closed"),
-			http.StatusConflict))
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("cannot create deployment for closed github PR"), http.StatusConflict,
+		))
 		return
 	}
 
-	ghDeployment, err := createDeployment(client, env, request.PRBranchFrom, request.ActionID)
+	ghDeployment, err := createGithubDeployment(client, env, request.PRBranchFrom, request.ActionID)
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusConflict))
 		return
 	}
 
@@ -105,6 +115,20 @@ func (c *CreateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	})
 
 	if err != nil {
+		// try to delete the GitHub deployment
+		_, err = client.Repositories.DeleteDeployment(
+			context.Background(),
+			env.GitRepoOwner,
+			env.GitRepoName,
+			ghDeployment.GetID(),
+		)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("%v: %w", errGithubAPI, err),
+				http.StatusConflict))
+			return
+		}
+
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
@@ -127,7 +151,7 @@ func (c *CreateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	c.WriteResult(w, r, depl.ToDeploymentType())
 }
 
-func createDeployment(
+func createGithubDeployment(
 	client *github.Client,
 	env *models.Environment,
 	branchFrom string,
@@ -135,46 +159,39 @@ func createDeployment(
 ) (*github.Deployment, error) {
 	requiredContexts := []string{}
 
-	deploymentRequest := github.DeploymentRequest{
-		Ref:              github.String(branchFrom),
-		Environment:      github.String(env.Name),
-		AutoMerge:        github.Bool(false),
-		RequiredContexts: &requiredContexts,
-	}
-
 	deployment, _, err := client.Repositories.CreateDeployment(
 		context.Background(),
 		env.GitRepoOwner,
 		env.GitRepoName,
-		&deploymentRequest,
+		&github.DeploymentRequest{
+			Ref:              github.String(branchFrom),
+			Environment:      github.String(env.Name),
+			AutoMerge:        github.Bool(false),
+			RequiredContexts: &requiredContexts,
+		},
 	)
 
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("%v: %w", errGithubAPI, err)
 	}
 
 	depID := deployment.GetID()
 
 	// Create Deployment Status to indicate it's in progress
-
-	state := "in_progress"
-	log_url := fmt.Sprintf("https://github.com/%s/%s/actions/runs/%d", env.GitRepoOwner, env.GitRepoName, actionID)
-
-	deploymentStatusRequest := github.DeploymentStatusRequest{
-		State:  &state,
-		LogURL: &log_url, // link to actions tab
-	}
-
 	_, _, err = client.Repositories.CreateDeploymentStatus(
 		context.Background(),
 		env.GitRepoOwner,
 		env.GitRepoName,
 		depID,
-		&deploymentStatusRequest,
+		&github.DeploymentStatusRequest{
+			State: github.String("in_progress"),
+			LogURL: github.String(fmt.Sprintf("https://github.com/%s/%s/actions/runs/%d",
+				env.GitRepoOwner, env.GitRepoName, actionID)), // link to actions tab
+		},
 	)
 
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("%v: %w", errGithubAPI, err)
 	}
 
 	return deployment, nil

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

@@ -17,6 +17,7 @@ import (
 	"github.com/porter-dev/porter/internal/integrations/ci/actions"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models/integrations"
+	"gorm.io/gorm"
 )
 
 type DeleteEnvironmentHandler struct {
@@ -50,14 +51,11 @@ func (c *DeleteEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	env, err := c.Repo().Environment().ReadEnvironment(project.ID, cluster.ID, uint(ga.InstallationID), owner, name)
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
-	}
-
-	// delete Github actions files from the repo
-	client, err := getGithubClientFromEnvironment(c.Config(), env)
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("environment not found in cluster and project")))
+			return
+		}
 
-	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
@@ -92,6 +90,13 @@ func (c *DeleteEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	// FIXME: ignore the return status codes for now, should be fixed when we start returning all non-fatal errors
 	if ghWebhookID != 0 {
 		client.Repositories.DeleteHook(context.Background(), owner, name, ghWebhookID)
@@ -129,7 +134,7 @@ func (c *DeleteEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	if err != nil {
 		if errors.Is(err, actions.ErrProtectedBranch) {
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-				fmt.Errorf("We were unable to delete the Porter Preview Environment workflow files for this "+
+				fmt.Errorf("we were unable to delete the Porter Preview Environment workflow files for this "+
 					"repository as the default branch is protected. Please manually delete them."), http.StatusConflict,
 			))
 			return

+ 27 - 22
api/server/handlers/environment/delete_deployment.go

@@ -50,6 +50,11 @@ func (c *DeleteDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 	depl, err := c.Repo().Environment().ReadDeploymentByID(project.ID, cluster.ID, deplID)
 
 	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("deployment id not found in cluster and project")))
+			return
+		}
+
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
@@ -85,41 +90,41 @@ func (c *DeleteDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
-	client, err := getGithubClientFromEnvironment(c.Config(), env)
+	depl.Status = types.DeploymentStatusInactive
+
+	// update the deployment to mark it inactive
+	depl, err = c.Repo().Environment().UpdateDeployment(depl)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	// Create new deployment status to indicate deployment is ready
-	state := "inactive"
-
-	deploymentStatusRequest := github.DeploymentStatusRequest{
-		State: &state,
-	}
-
-	_, _, err = client.Repositories.CreateDeploymentStatus(
-		context.Background(),
-		env.GitRepoOwner,
-		env.GitRepoName,
-		depl.GHDeploymentID,
-		&deploymentStatusRequest,
-	)
+	client, err := getGithubClientFromEnvironment(c.Config(), env)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	depl.Status = types.DeploymentStatusInactive
-
-	// update the deployment to mark it inactive
-	depl, err = c.Repo().Environment().UpdateDeployment(depl)
+	if depl.GHDeploymentID != 0 {
+		// set the GitHub deployment status to be inactive
+		_, _, err := client.Repositories.CreateDeploymentStatus(
+			context.Background(),
+			env.GitRepoOwner,
+			env.GitRepoName,
+			depl.GHDeploymentID,
+			&github.DeploymentStatusRequest{
+				State: github.String("inactive"),
+			},
+		)
 
-	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
-		return
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("%v: %w", errGithubAPI, err), http.StatusConflict,
+			))
+			return
+		}
 	}
 
 	c.WriteResult(w, r, depl.ToDeploymentType())

+ 14 - 7
api/server/handlers/environment/enable_pull_request.go

@@ -1,6 +1,7 @@
 package environment
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 	"strconv"
@@ -14,6 +15,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
 )
 
 type EnablePullRequestHandler struct {
@@ -44,6 +46,11 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	env, err := c.Repo().Environment().ReadEnvironmentByOwnerRepoName(project.ID, cluster.ID, request.RepoOwner, request.RepoName)
 
 	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("environment not found in cluster and project")))
+			return
+		}
+
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
@@ -59,12 +66,13 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	pr, _, err := client.PullRequests.Get(r.Context(), env.GitRepoOwner, env.GitRepoName, int(request.Number))
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("%v: %w", errGithubAPI, err),
+			http.StatusConflict))
 		return
 	}
 
 	if pr.GetState() == "closed" {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("Github PR has been closed"),
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("cannot enable deployment for closed PR"),
 			http.StatusConflict))
 		return
 	}
@@ -86,17 +94,17 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		if ghResp.StatusCode == 404 {
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
 				fmt.Errorf(
-					"Please make sure the preview environment workflow files are present in PR branch %s and are up to"+
+					"please make sure the preview environment workflow files are present in PR branch %s and are up to"+
 						" date with the default branch", request.BranchFrom,
-				), 404),
+				), http.StatusConflict),
 			)
 			return
 		} else if ghResp.StatusCode == 422 {
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
 				fmt.Errorf(
-					"Please make sure the workflow files in PR branch %s are up to date with the default branch",
+					"please make sure the workflow files in PR branch %s are up to date with the default branch",
 					request.BranchFrom,
-				), 422),
+				), http.StatusConflict),
 			)
 			return
 		}
@@ -141,5 +149,4 @@ func (c *EnablePullRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
-
 }

+ 11 - 40
api/server/handlers/environment/finalize_deployment.go

@@ -4,7 +4,6 @@ import (
 	"context"
 	"fmt"
 	"net/http"
-	"net/url"
 	"strings"
 
 	"github.com/google/go-github/v41/github"
@@ -124,48 +123,20 @@ 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
-	}
+	commentBody := "## Porter Preview Environments\n"
 
 	if depl.Subdomain == "" {
-		depl.Subdomain = "*Ingress is disabled for this deployment*"
+		commentBody += fmt.Sprintf(
+			"✅ The latest SHA ([`%s`](https://github.com/%s/%s/commit/%s)) has been successfully deployed.",
+			depl.CommitSHA, depl.RepoOwner, depl.RepoName, depl.CommitSHA,
+		)
+	} else {
+		commentBody += fmt.Sprintf(
+			"✅ The latest SHA ([`%s`](https://github.com/%s/%s/commit/%s)) has been successfully deployed to %s",
+			depl.CommitSHA, depl.RepoOwner, depl.RepoName, depl.CommitSHA, depl.Subdomain,
+		)
 	}
 
-	// write comment in PR
-	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&project_id=%d&cluster=%s |",
-		depl.CommitSHA, depl.RepoOwner, depl.RepoName, depl.CommitSHA, depl.Subdomain, workflowRun.GetHTMLURL(),
-		c.Config().ServerConf.ServerURL, depl.Namespace, depl.EnvironmentID, project.ID, url.QueryEscape(cluster.Name),
-	)
-
-	// 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 = createOrUpdateComment(client, c.Repo(), env.NewCommentsDisabled, depl, github.String(commentBody))
 
 	if err != nil {
@@ -293,7 +264,7 @@ func isGithubPRClosed(
 	)
 
 	if err != nil {
-		return false, err
+		return false, fmt.Errorf("%v: %w", errGithubAPI, err)
 	}
 
 	return ghPR.GetState() == "closed", nil

+ 4 - 9
api/server/handlers/environment/finalize_deployment_with_errors.go

@@ -61,7 +61,7 @@ func (c *FinalizeDeploymentWithErrorsHandler) ServeHTTP(w http.ResponseWriter, r
 
 	if err != nil {
 		if errors.Is(err, gorm.ErrRecordNotFound) {
-			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("no environment found")))
+			c.HandleAPIError(w, r, apierrors.NewErrNotFound(fmt.Errorf("environment not found in cluster and project")))
 			return
 		}
 
@@ -85,9 +85,7 @@ func (c *FinalizeDeploymentWithErrorsHandler) ServeHTTP(w http.ResponseWriter, r
 	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,
-		))
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
@@ -95,15 +93,12 @@ func (c *FinalizeDeploymentWithErrorsHandler) ServeHTTP(w http.ResponseWriter, r
 	prClosed, err := isGithubPRClosed(client, owner, name, int(depl.PullRequestID))
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			fmt.Errorf("error fetching details of github PR for deployment ID: %d. Error: %w",
-				depl.ID, err), http.StatusConflict,
-		))
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusConflict))
 		return
 	}
 
 	if prClosed {
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("Github PR has been closed"),
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf("github PR has been closed"),
 			http.StatusConflict))
 		return
 	}

+ 8 - 2
api/server/handlers/environment/list.go

@@ -1,6 +1,7 @@
 package environment
 
 import (
+	"fmt"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -31,7 +32,9 @@ func (c *ListEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	envs, err := c.Repo().Environment().ListEnvironments(project.ID, cluster.ID)
 
 	if err != nil {
-		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("error listing environments"), http.StatusInternalServerError, err.Error(),
+		))
 		return
 	}
 
@@ -43,7 +46,10 @@ func (c *ListEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		depls, err := c.Repo().Environment().ListDeployments(env.ID)
 
 		if err != nil {
-			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("error listing environments: error listing deployments for environment ID %d", env.ID),
+				http.StatusInternalServerError, err.Error(),
+			))
 			return
 		}
 

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

@@ -89,7 +89,7 @@ func (c *UpdateDeploymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque
 		return
 	}
 
-	ghDeployment, err := createDeployment(client, env, request.PRBranchFrom, request.ActionID)
+	ghDeployment, err := createGithubDeployment(client, env, request.PRBranchFrom, request.ActionID)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 2 - 0
api/server/handlers/infra/create.go

@@ -207,6 +207,8 @@ func getSourceLinkAndVersion(kind types.InfraKind) (string, string) {
 		return "porter/aws/s3", "v0.1.0"
 	case types.InfraGCR:
 		return "porter/gcp/gcr", "v0.1.0"
+	case types.InfraGAR:
+		return "porter/gcp/gar", "v0.1.0"
 	case types.InfraGKE:
 		return "porter/gcp/gke", "v0.1.0"
 	case types.InfraDOCR:

+ 105 - 18
api/server/handlers/infra/forms.go

@@ -9,7 +9,7 @@ tabs:
   label: Configuration
   sections:
   - name: section_one
-    contents: 
+    contents:
     - type: heading
       label: String to echo
     - type: string-input
@@ -27,7 +27,7 @@ tabs:
   label: Main
   sections:
   - name: heading
-    contents: 
+    contents:
     - type: heading
       label: S3 Settings
   - name: bucket_name
@@ -48,7 +48,7 @@ tabs:
   label: Main
   sections:
   - name: heading
-    contents: 
+    contents:
     - type: heading
       label: Database Settings
   - name: user
@@ -110,7 +110,7 @@ tabs:
         - label: "Postgres 13"
           value: postgres13
   - name: pg-9-versions
-    show_if: 
+    show_if:
       is: "postgres9"
       variable: db_family
     contents:
@@ -165,7 +165,7 @@ tabs:
         - label: "v9.6.23"
           value: "9.6.23"
   - name: pg-10-versions
-    show_if: 
+    show_if:
       is: "postgres10"
       variable: db_family
     contents:
@@ -212,7 +212,7 @@ tabs:
         - label: "v10.18"
           value: "10.18"
   - name: pg-11-versions
-    show_if: 
+    show_if:
       is: "postgres11"
       variable: db_family
     contents:
@@ -249,7 +249,7 @@ tabs:
         - label: "v11.13"
           value: "11.13"
   - name: pg-12-versions
-    show_if: 
+    show_if:
       is: "postgres12"
       variable: db_family
     contents:
@@ -276,7 +276,7 @@ tabs:
         - label: "v12.10"
           value: "12.10"
   - name: pg-13-versions
-    show_if: 
+    show_if:
       is: "postgres13"
       variable: db_family
     contents:
@@ -326,7 +326,7 @@ tabs:
         default: 20
     - type: checkbox
       variable: db_storage_encrypted
-      label: Enable storage encryption for the database. 
+      label: Enable storage encryption for the database.
       settings:
         default: false
 - name: advanced
@@ -353,7 +353,7 @@ tabs:
   label: Configuration
   sections:
   - name: section_one
-    contents: 
+    contents:
     - type: heading
       label: ECR Configuration
     - type: string-input
@@ -371,7 +371,7 @@ tabs:
   label: Configuration
   sections:
   - name: section_one
-    contents: 
+    contents:
     - type: heading
       label: EKS Configuration
     - type: select
@@ -513,7 +513,7 @@ tabs:
       settings:
         default: true
   - name: aws_auth_warning
-    show_if: 
+    show_if:
       not: manage_aws_auth_configmap
     contents:
     - type: subtitle
@@ -635,7 +635,7 @@ tabs:
   label: Configuration
   sections:
   - name: section_one
-    contents: 
+    contents:
     - type: heading
       label: GCR Configuration
     - type: select
@@ -698,6 +698,93 @@ tabs:
           value: us-west4
 `
 
+const garForm = `name: GAR
+hasSource: false
+includeHiddenFields: true
+tabs:
+- name: main
+  label: Configuration
+  sections:
+  - name: section_one
+    contents:
+    - type: heading
+      label: GAR Configuration
+    - type: select
+      label: 📍 GCP Region
+      variable: gcp_region
+      settings:
+        default: us-central1
+        options:
+        - label: asia-east1
+          value: asia-east1
+        - label: asia-east2
+          value: asia-east2
+        - label: asia-northeast1
+          value: asia-northeast1
+        - label: asia-northeast2
+          value: asia-northeast2
+        - label: asia-northeast3
+          value: asia-northeast3
+        - label: asia-south1
+          value: asia-south1
+        - label: asia-south2
+          value: asia-south2
+        - label: asia-southeast1
+          value: asia-southeast1
+        - label: asia-southeast2
+          value: asia-southeast2
+        - label: australia-southeast1
+          value: australia-southeast1
+        - label: australia-southeast2
+          value: australia-southeast2
+        - label: europe-central2
+          value: europe-central2
+        - label: europe-north1
+          value: europe-north1
+        - label: europe-southwest1
+          value: europe-southwest1
+        - label: europe-west1
+          value: europe-west1
+        - label: europe-west2
+          value: europe-west2
+        - label: europe-west3
+          value: europe-west3
+        - label: europe-west4
+          value: europe-west4
+        - label: europe-west6
+          value: europe-west6
+        - label: europe-west8
+          value: europe-west8
+        - label: europe-west9
+          value: europe-west9
+        - label: northamerica-northeast1
+          value: northamerica-northeast1
+        - label: northamerica-northeast2
+          value: northamerica-northeast2
+        - label: southamerica-east1
+          value: southamerica-east1
+        - label: southamerica-west1
+          value: southamerica-west1
+        - label: us-central1
+          value: us-central1
+        - label: us-east1
+          value: us-east1
+        - label: us-east4
+          value: us-east4
+        - label: us-east5
+          value: us-east5
+        - label: us-south1
+          value: us-south1
+        - label: us-west1
+          value: us-west1
+        - label: us-west2
+          value: us-west2
+        - label: us-west3
+          value: us-west3
+        - label: us-west4
+          value: us-west4
+`
+
 const gkeForm = `name: GKE
 hasSource: false
 includeHiddenFields: true
@@ -706,7 +793,7 @@ tabs:
   label: Configuration
   sections:
   - name: section_one
-    contents: 
+    contents:
     - type: heading
       label: GKE Configuration
     - type: select
@@ -787,7 +874,7 @@ tabs:
   label: Configuration
   sections:
   - name: section_one
-    contents: 
+    contents:
     - type: heading
       label: DOCR Configuration
     - type: select
@@ -815,7 +902,7 @@ tabs:
   label: Configuration
   sections:
   - name: section_one
-    contents: 
+    contents:
     - type: heading
       label: DOKS Configuration
     - type: select
@@ -865,7 +952,7 @@ tabs:
   label: Configuration
   sections:
   - name: section_one
-    contents: 
+    contents:
     - type: heading
       label: ACR Configuration
     - type: select
@@ -900,7 +987,7 @@ tabs:
   label: Configuration
   sections:
   - name: section_one
-    contents: 
+    contents:
     - type: heading
       label: AKS Configuration
     - type: select

+ 2 - 0
api/server/handlers/infra/get_template.go

@@ -73,6 +73,8 @@ func getFormBytesFromKind(kind string) []byte {
 		formBytes = []byte(eksForm)
 	case "gcr":
 		formBytes = []byte(gcrForm)
+	case "gar":
+		formBytes = []byte(garForm)
 	case "gke":
 		formBytes = []byte(gkeForm)
 	case "docr":

+ 8 - 0
api/server/handlers/infra/list_templates.go

@@ -81,6 +81,14 @@ var templateMap = map[string]*types.InfraTemplateMeta{
 		Kind:               "gcr",
 		RequiredCredential: "gcp_integration_id",
 	},
+	"gar": {
+		Icon:               "https://carlossanchez.files.wordpress.com/2019/06/21046548.png?w=640",
+		Description:        "Create a Google Artifact Registry.",
+		Name:               "GAR",
+		Version:            "v0.1.0",
+		Kind:               "gar",
+		RequiredCredential: "gcp_integration_id",
+	},
 	"gke": {
 		Icon:               "https://sysdig.com/wp-content/uploads/2016/08/GKE_color.png",
 		Description:        "Create a Google Kubernetes Engine cluster.",

+ 1 - 1
api/server/handlers/registry/create.go

@@ -83,7 +83,7 @@ func (p *RegistryCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 	var err error
 
 	if request.GCPIntegrationID != 0 {
-		_, err = p.Repo().GCPIntegration().ReadGCPIntegration(proj.ID, request.GCPIntegrationID)
+		_, err := p.Repo().GCPIntegration().ReadGCPIntegration(proj.ID, request.GCPIntegrationID)
 
 		if err != nil {
 			if errors.Is(err, gorm.ErrRecordNotFound) {

+ 63 - 0
api/server/handlers/registry/get_token.go

@@ -173,6 +173,69 @@ func (c *RegistryGetGCRTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 	c.WriteResult(w, r, resp)
 }
 
+type RegistryGetGARTokenHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewRegistryGetGARTokenHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *RegistryGetGARTokenHandler {
+	return &RegistryGetGARTokenHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *RegistryGetGARTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	request := &types.GetRegistryGCRTokenRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	// list registries and find one that matches the region
+	regs, err := c.Repo().Registry().ListRegistriesByProjectID(proj.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var token string
+	var expiresAt *time.Time
+
+	for _, reg := range regs {
+		if reg.GCPIntegrationID != 0 && strings.Contains(reg.URL, request.ServerURL) {
+			_reg := registry.Registry(*reg)
+
+			oauthTok, err := _reg.GetGARToken(c.Repo())
+
+			// if the oauth token is not nil, but the error is not nil, we still return the token
+			// but log an error
+			if oauthTok != nil && err != nil {
+				c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+			} else if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			token = oauthTok.AccessToken
+			expiresAt = &oauthTok.Expiry
+			break
+		}
+	}
+
+	resp := &types.GetRegistryTokenResponse{
+		Token:     token,
+		ExpiresAt: expiresAt,
+	}
+
+	c.WriteResult(w, r, resp)
+}
+
 type RegistryGetDOCRTokenHandler struct {
 	handlers.PorterHandlerReadWriter
 }

+ 23 - 0
api/server/handlers/release/upgrade.go

@@ -118,6 +118,29 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		conf.Chart = chart
 	}
 
+	// if LatestRevision is set, check that the revision matches the latest revision in the database
+	if request.LatestRevision != 0 {
+		currHelmRelease, err := helmAgent.GetRelease(helmRelease.Name, 0, false)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("could not retrieve latest revision"),
+				http.StatusBadRequest,
+			))
+
+			return
+		}
+
+		if currHelmRelease.Version != int(request.LatestRevision) {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("The provided revision is not up to date with the current revision (you may need to refresh the deployment). Provided revision is %d, latest revision is %d. If you would like to deploy from this revision, please revert first and update the configuration.", request.LatestRevision, currHelmRelease.Version),
+				http.StatusBadRequest,
+			))
+
+			return
+		}
+	}
+
 	newHelmRelease, upgradeErr := helmAgent.UpgradeRelease(conf, request.Values, c.Config().DOConf)
 
 	if upgradeErr == nil && newHelmRelease != nil {

+ 11 - 2
api/server/handlers/stack/create.go

@@ -261,12 +261,21 @@ func getSourceConfigModels(sourceConfigs []*types.CreateStackSourceConfigRequest
 				return nil, err
 			}
 
-			res = append(res, models.StackSourceConfig{
+			newSourceConfig := &models.StackSourceConfig{
 				UID:          uid,
 				Name:         sourceConfig.Name,
 				ImageRepoURI: sourceConfig.ImageRepoURI,
 				ImageTag:     sourceConfig.ImageTag,
-			})
+			}
+
+			// If the source config had a source config ID then we need to copy it over
+			if sourceConfig.StableSourceConfigID != "" {
+				newSourceConfig.StableSourceConfigID = sourceConfig.StableSourceConfigID
+			} else {
+				newSourceConfig.StableSourceConfigID = string(uid)
+			}
+
+			res = append(res, *newSourceConfig)
 		}
 	}
 

+ 64 - 0
api/server/handlers/stack/update_stack.go

@@ -0,0 +1,64 @@
+package stack
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type StackUpdateStack struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewStackUpdateStackHandler(
+	config *config.Config,
+	reader shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StackUpdateStack {
+	return &StackUpdateStack{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, reader, writer),
+	}
+}
+
+func (p *StackUpdateStack) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+
+	req := &types.UpdateStackRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	if len(stack.Revisions) == 0 {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("no stack revisions exist"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	stack, err := p.Repo().Stack().ReadStackByID(proj.ID, stack.ID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// Update stack name
+	stack.Name = req.Name
+
+	newStack, err := p.Repo().Stack().UpdateStack(stack)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	p.WriteResult(w, r, newStack)
+}

+ 23 - 0
api/server/handlers/v1/release/upgrade.go

@@ -120,6 +120,29 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 
 	conf.Values = request.Values
 
+	// if LatestRevision is set, check that the revision matches the latest revision in the database
+	if request.LatestRevision != 0 {
+		currHelmRelease, err := helmAgent.GetRelease(helmRelease.Name, 0, false)
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("could not retrieve latest revision"),
+				http.StatusBadRequest,
+			))
+
+			return
+		}
+
+		if currHelmRelease.Version != int(request.LatestRevision) {
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+				fmt.Errorf("The provided revision is not up to date with the current revision (you may need to refresh the deployment). Provided revision is %d, latest revision is %d. If you would like to deploy from this revision, please revert first and update the configuration.", request.LatestRevision, currHelmRelease.Version),
+				http.StatusBadRequest,
+			))
+
+			return
+		}
+	}
+
 	newHelmRelease, upgradeErr := helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf)
 
 	if upgradeErr == nil && newHelmRelease != nil {

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

@@ -115,7 +115,7 @@ func getGitInstallationRoutes(
 
 	if config.ServerConf.GithubIncomingWebhookSecret != "" {
 
-		// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id} ->
+		// POST /api/projects/{project_id}/gitrepos/{git_installation_id}/{owner}/{name}/clusters/{cluster_id}/environment ->
 		// environment.NewCreateEnvironmentHandler
 		createEnvironmentEndpoint := factory.NewAPIEndpoint(
 			&types.APIRequestMetadata{

+ 28 - 0
api/server/router/project.go

@@ -608,6 +608,34 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	//  GET /api/projects/{project_id}/registries/gar/token -> registry.NewRegistryGetGARTokenHandler
+	getGARTokenEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/registries/gar/token",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	getGARTokenHandler := registry.NewRegistryGetGARTokenHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getGARTokenEndpoint,
+		Handler:  getGARTokenHandler,
+		Router:   r,
+	})
+
 	//  GET /api/projects/{project_id}/registries/acr/token -> registry.NewRegistryGetACRTokenHandler
 	getACRTokenEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 57 - 1
api/server/router/v1/stack.go

@@ -9,7 +9,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 )
 
-// swagger:parameters getStack deleteStack putStackSource rollbackStack listStackRevisions addApplication addEnvGroup
+// swagger:parameters getStack deleteStack putStackSource rollbackStack listStackRevisions addApplication addEnvGroup updateStack
 type stackPathParams struct {
 	// The project id
 	// in: path
@@ -820,5 +820,61 @@ func getV1StackRoutes(
 		Router:   r,
 	})
 
+	// PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id} -> stack.NewStackUpdateStackHandler
+	// swagger:operation PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id} updateStack
+	//
+	// Updates a stack. Currently the only value available to update is the stack name.
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Update Stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - in: body
+	//     name: UpdateStack
+	//     description: The stack to update
+	//     schema:
+	//       $ref: '#/definitions/UpdateStackRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully updated the stack
+	//   '403':
+	//     description: Forbidden
+	updateStackEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPatch,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	updateStackHandler := stack.NewStackUpdateStackHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: updateStackEndpoint,
+		Handler:  updateStackHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 1 - 0
api/types/infra.go

@@ -23,6 +23,7 @@ const (
 	InfraECR  InfraKind = "ecr"
 	InfraEKS  InfraKind = "eks"
 	InfraGCR  InfraKind = "gcr"
+	InfraGAR  InfraKind = "gar"
 	InfraGKE  InfraKind = "gke"
 	InfraDOCR InfraKind = "docr"
 	InfraDOKS InfraKind = "doks"

+ 5 - 0
api/types/integrations.go

@@ -48,6 +48,11 @@ var PorterRegistryIntegrations = []PorterIntegration{
 		Category:      "registry",
 		Service:       string(GCR),
 	},
+	{
+		AuthMechanism: "gcp",
+		Category:      "registry",
+		Service:       string(GAR),
+	},
 	{
 		AuthMechanism: "aws",
 		Category:      "registry",

+ 7 - 2
api/types/registry.go

@@ -26,7 +26,7 @@ type Registry struct {
 	URL string `json:"url"`
 
 	// The integration service for this registry
-	// enum: gcr,ecr,acr,docr,dockerhub
+	// enum: gcr,gar,ecr,acr,docr,dockerhub
 	// example: ecr
 	Service string `json:"service"`
 
@@ -97,6 +97,7 @@ type RegistryService string
 
 const (
 	GCR       RegistryService = "gcr"
+	GAR       RegistryService = "gar"
 	ECR       RegistryService = "ecr"
 	ACR       RegistryService = "acr"
 	DOCR      RegistryService = "docr"
@@ -159,7 +160,7 @@ type GetRegistryResponse Registry
 
 // swagger:model
 type CreateRegistryRepositoryRequest struct {
-	// The URL to the repository of a registry (**ECR only**)
+	// The URL to the repository of a registry (ECR, GAR)
 	// required: true
 	ImageRepoURI string `json:"image_repo_uri" form:"required"`
 }
@@ -179,6 +180,10 @@ type GetRegistryGCRTokenRequest struct {
 	ServerURL string `schema:"server_url"`
 }
 
+type GetRegistryGARTokenRequest struct {
+	ServerURL string `schema:"server_url"`
+}
+
 type GetRegistryECRTokenRequest struct {
 	Region    string `schema:"region"`
 	AccountID string `schema:"account_id"`

+ 8 - 0
api/types/release.go

@@ -110,11 +110,19 @@ type V1UpgradeReleaseRequest struct {
 
 	// The Porter charts version to upgrade the release with
 	ChartVersion string `json:"version"`
+
+	// (optional) if set, the backend will validate that the user was upgrading from the revision specified by
+	// LatestRevision, and there hasn't been an upgrade in the meantime.
+	LatestRevision uint `json:"latest_revision"`
 }
 
 type UpgradeReleaseRequest struct {
 	Values       string `json:"values" form:"required"`
 	ChartVersion string `json:"version"`
+
+	// (optional) if set, the backend will validate that the user was upgrading from the revision specified by
+	// LatestRevision, and there hasn't been an upgrade in the meantime.
+	LatestRevision uint `json:"latest_revision"`
 }
 
 type UpdateImageBatchRequest struct {

+ 10 - 0
api/types/stacks.go

@@ -63,6 +63,11 @@ type CreateStackAppResourceRequest struct {
 	SourceConfigName string `json:"source_config_name" form:"required"`
 }
 
+// swagger:model
+type UpdateStackRequest struct {
+	Name string `json:"name" form:"required"`
+}
+
 // swagger:model
 type Stack struct {
 	// The time that the stack was initially created
@@ -221,6 +226,9 @@ type StackSourceConfig struct {
 
 	// If this field is empty, the resource is deployed directly from the image repo uri
 	StackSourceConfigBuild *StackSourceConfigBuild `json:"build,omitempty"`
+
+	// Unique ID to identify between revisions
+	StableSourceConfigID string `json:"stable_source_config_id"`
 }
 
 // swagger:model
@@ -254,6 +262,8 @@ type CreateStackSourceConfigRequest struct {
 	// required: true
 	ImageTag string `json:"image_tag" form:"required"`
 
+	StableSourceConfigID string `json:"source_config_id,omitempty"`
+
 	// If this field is empty, the resource is deployed directly from the image repo uri
 	StackSourceConfigBuild *StackSourceConfigBuild `json:"build,omitempty"`
 }

+ 63 - 46
cli/cmd/apply.go

@@ -25,7 +25,7 @@ import (
 	"github.com/porter-dev/switchboard/pkg/models"
 	"github.com/porter-dev/switchboard/pkg/parser"
 	switchboardTypes "github.com/porter-dev/switchboard/pkg/types"
-	"github.com/porter-dev/switchboard/pkg/worker"
+	switchboardWorker "github.com/porter-dev/switchboard/pkg/worker"
 	"github.com/rs/zerolog"
 	"github.com/spf13/cobra"
 )
@@ -80,25 +80,27 @@ func init() {
 	applyCmd.MarkFlagRequired("file")
 }
 
-func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
 	fileBytes, err := ioutil.ReadFile(porterYAML)
+
 	if err != nil {
-		return err
+		return fmt.Errorf("error reading porter.yaml: %w", err)
 	}
 
 	resGroup, err := parser.ParseRawBytes(fileBytes)
+
 	if err != nil {
-		return err
+		return fmt.Errorf("error parsing porter.yaml: %w", err)
 	}
 
 	basePath, err := os.Getwd()
 
 	if err != nil {
-		return err
+		return fmt.Errorf("error getting working directory: %w", err)
 	}
 
-	worker := worker.NewWorker()
-	worker.RegisterDriver("deploy", NewPorterDriver)
+	worker := switchboardWorker.NewWorker()
+	worker.RegisterDriver("deploy", NewDeployDriver)
 	worker.RegisterDriver("build-image", preview.NewBuildDriver)
 	worker.RegisterDriver("push-image", preview.NewPushDriver)
 	worker.RegisterDriver("update-config", preview.NewUpdateConfigDriver)
@@ -118,7 +120,7 @@ func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []str
 		deploymentHook, err := NewDeploymentHook(client, resGroup, deplNamespace)
 
 		if err != nil {
-			return err
+			return fmt.Errorf("error creating deployment hook: %w", err)
 		}
 
 		worker.RegisterHook("deployment", deploymentHook)
@@ -191,7 +193,7 @@ type ApplicationConfig struct {
 	Values map[string]interface{}
 }
 
-type Driver struct {
+type DeployDriver struct {
 	source      *preview.Source
 	target      *preview.Target
 	output      map[string]interface{}
@@ -199,21 +201,23 @@ type Driver struct {
 	logger      *zerolog.Logger
 }
 
-func NewPorterDriver(resource *models.Resource, opts *drivers.SharedDriverOpts) (drivers.Driver, error) {
-	driver := &Driver{
+func NewDeployDriver(resource *models.Resource, opts *drivers.SharedDriverOpts) (drivers.Driver, error) {
+	driver := &DeployDriver{
 		lookupTable: opts.DriverLookupTable,
 		logger:      opts.Logger,
 		output:      make(map[string]interface{}),
 	}
 
-	source, err := preview.GetSource(resource.Source)
+	source, err := preview.GetSource(resource.Name, resource.Source)
+
 	if err != nil {
 		return nil, err
 	}
 
 	driver.source = source
 
-	target, err := preview.GetTarget(resource.Target)
+	target, err := preview.GetTarget(resource.Name, resource.Target)
+
 	if err != nil {
 		return nil, err
 	}
@@ -223,16 +227,16 @@ func NewPorterDriver(resource *models.Resource, opts *drivers.SharedDriverOpts)
 	return driver, nil
 }
 
-func (d *Driver) ShouldApply(resource *models.Resource) bool {
+func (d *DeployDriver) ShouldApply(_ *models.Resource) bool {
 	return true
 }
 
-func (d *Driver) Apply(resource *models.Resource) (*models.Resource, error) {
+func (d *DeployDriver) Apply(resource *models.Resource) (*models.Resource, error) {
 	client := config.GetAPIClient()
 	name := resource.Name
 
 	if name == "" {
-		return nil, fmt.Errorf("empty app name")
+		return nil, fmt.Errorf("empty resource name")
 	}
 
 	_, err := client.GetRelease(
@@ -257,11 +261,11 @@ func (d *Driver) Apply(resource *models.Resource) (*models.Resource, error) {
 }
 
 // Simple apply for addons
-func (d *Driver) applyAddon(resource *models.Resource, client *api.Client, shouldCreate bool) (*models.Resource, error) {
+func (d *DeployDriver) applyAddon(resource *models.Resource, client *api.Client, shouldCreate bool) (*models.Resource, error) {
 	addonConfig, err := d.getAddonConfig(resource)
 
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("error getting addon config for resource %s: %w", resource.Name, err)
 	}
 
 	if shouldCreate {
@@ -282,13 +286,13 @@ func (d *Driver) applyAddon(resource *models.Resource, client *api.Client, shoul
 		)
 
 		if err != nil {
-			return nil, err
+			return nil, fmt.Errorf("error creating addon from resource %s: %w", resource.Name, err)
 		}
 	} else {
 		bytes, err := json.Marshal(addonConfig)
 
 		if err != nil {
-			return nil, err
+			return nil, fmt.Errorf("error marshalling addon config from resource %s: %w", resource.Name, err)
 		}
 
 		err = client.UpgradeRelease(
@@ -303,7 +307,7 @@ func (d *Driver) applyAddon(resource *models.Resource, client *api.Client, shoul
 		)
 
 		if err != nil {
-			return nil, err
+			return nil, fmt.Errorf("error updating addon from resource %s: %w", resource.Name, err)
 		}
 	}
 
@@ -314,7 +318,11 @@ func (d *Driver) applyAddon(resource *models.Resource, client *api.Client, shoul
 	return resource, nil
 }
 
-func (d *Driver) applyApplication(resource *models.Resource, client *api.Client, shouldCreate bool) (*models.Resource, error) {
+func (d *DeployDriver) applyApplication(resource *models.Resource, client *api.Client, shouldCreate bool) (*models.Resource, error) {
+	if resource == nil {
+		return nil, fmt.Errorf("nil resource")
+	}
+
 	appConfig, err := d.getApplicationConfig(resource)
 
 	if err != nil {
@@ -324,25 +332,32 @@ func (d *Driver) applyApplication(resource *models.Resource, client *api.Client,
 	method := appConfig.Build.Method
 
 	if method != "pack" && method != "docker" && method != "registry" {
-		return nil, fmt.Errorf("method should either be \"docker\", \"pack\" or \"registry\"")
+		return nil, fmt.Errorf("for resource %s, config.build.method should either be \"docker\", \"pack\" or \"registry\"",
+			resource.Name)
 	}
 
 	fullPath, err := filepath.Abs(appConfig.Build.Context)
 
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("for resource %s, error getting absolute path for config.build.context: %w", resource.Name,
+			err)
 	}
 
 	tag := os.Getenv("PORTER_TAG")
 
 	if tag == "" {
+		color.New(color.FgYellow).Printf("for resource %s, since PORTER_TAG is not set, the Docker image tag will default to"+
+			" the git repo SHA", resource.Name)
+
 		commit, err := git.LastCommit()
 
 		if err != nil {
-			return nil, err
+			return nil, fmt.Errorf("for resource %s, error getting last git commit: %w", resource.Name, err)
 		}
 
 		tag = commit.Sha[:7]
+
+		color.New(color.FgYellow).Printf("for resource %s, using tag %s\n", resource.Name, tag)
 	}
 
 	// if the method is registry and a tag is defined, we use the provided tag
@@ -383,16 +398,16 @@ func (d *Driver) applyApplication(resource *models.Resource, client *api.Client,
 		resource, err = d.createApplication(resource, client, sharedOpts, appConfig)
 
 		if err != nil {
-			return nil, err
+			return nil, fmt.Errorf("error creating app from resource %s: %w", resource.Name, err)
 		}
 	} else if !appConfig.OnlyCreate {
 		resource, err = d.updateApplication(resource, client, sharedOpts, appConfig)
 
 		if err != nil {
-			return nil, err
+			return nil, fmt.Errorf("error updating application from resource %s: %w", resource.Name, err)
 		}
 	} else {
-		color.New(color.FgYellow).Printf("Skipping creation for %s as onlyCreate is set to true\n", resource.Name)
+		color.New(color.FgYellow).Printf("Skipping creation for resource %s as onlyCreate is set to true\n", resource.Name)
 	}
 
 	if err = d.assignOutput(resource, client); err != nil {
@@ -430,14 +445,14 @@ func (d *Driver) applyApplication(resource *models.Resource, client *api.Client,
 	return resource, err
 }
 
-func (d *Driver) createApplication(resource *models.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *ApplicationConfig) (*models.Resource, error) {
+func (d *DeployDriver) createApplication(resource *models.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *ApplicationConfig) (*models.Resource, error) {
 	// create new release
 	color.New(color.FgGreen).Printf("Creating %s release: %s\n", d.source.Name, resource.Name)
 
 	regList, err := client.ListRegistries(context.Background(), d.target.Project)
 
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("for resource %s, error listing registries: %w", resource.Name, err)
 	}
 
 	var registryURL string
@@ -448,6 +463,8 @@ func (d *Driver) createApplication(resource *models.Resource, client *api.Client
 		registryURL = (*regList)[0].URL
 	}
 
+	color.New(color.FgBlue).Printf("for resource %s, using registry %s\n", resource.Name, registryURL)
+
 	// attempt to get repo suffix from environment variables
 	var repoSuffix string
 
@@ -514,7 +531,7 @@ func (d *Driver) createApplication(resource *models.Resource, client *api.Client
 	return resource, handleSubdomainCreate(subdomain, err)
 }
 
-func (d *Driver) updateApplication(resource *models.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *ApplicationConfig) (*models.Resource, error) {
+func (d *DeployDriver) updateApplication(resource *models.Resource, client *api.Client, sharedOpts *deploy.SharedOpts, appConf *ApplicationConfig) (*models.Resource, error) {
 	color.New(color.FgGreen).Println("Updating existing release:", resource.Name)
 
 	if len(appConf.Build.Env) > 0 {
@@ -580,7 +597,7 @@ func (d *Driver) updateApplication(resource *models.Resource, client *api.Client
 	return resource, nil
 }
 
-func (d *Driver) assignOutput(resource *models.Resource, client *api.Client) error {
+func (d *DeployDriver) assignOutput(resource *models.Resource, client *api.Client) error {
 	release, err := client.GetRelease(
 		context.Background(),
 		d.target.Project,
@@ -598,11 +615,11 @@ func (d *Driver) assignOutput(resource *models.Resource, client *api.Client) err
 	return nil
 }
 
-func (d *Driver) Output() (map[string]interface{}, error) {
+func (d *DeployDriver) Output() (map[string]interface{}, error) {
 	return d.output, nil
 }
 
-func (d *Driver) getApplicationConfig(resource *models.Resource) (*ApplicationConfig, error) {
+func (d *DeployDriver) getApplicationConfig(resource *models.Resource) (*ApplicationConfig, error) {
 	populatedConf, err := drivers.ConstructConfig(&drivers.ConstructConfigOpts{
 		RawConf:      resource.Config,
 		LookupTable:  *d.lookupTable,
@@ -613,9 +630,9 @@ func (d *Driver) getApplicationConfig(resource *models.Resource) (*ApplicationCo
 		return nil, err
 	}
 
-	config := &ApplicationConfig{}
+	appConf := &ApplicationConfig{}
 
-	err = mapstructure.Decode(populatedConf, config)
+	err = mapstructure.Decode(populatedConf, appConf)
 
 	if err != nil {
 		return nil, err
@@ -623,13 +640,13 @@ func (d *Driver) getApplicationConfig(resource *models.Resource) (*ApplicationCo
 
 	if _, ok := resource.Config["waitForJob"]; !ok && d.source.Name == "job" {
 		// default to true and wait for the job to finish
-		config.WaitForJob = true
+		appConf.WaitForJob = true
 	}
 
-	return config, nil
+	return appConf, nil
 }
 
-func (d *Driver) getAddonConfig(resource *models.Resource) (map[string]interface{}, error) {
+func (d *DeployDriver) getAddonConfig(resource *models.Resource) (map[string]interface{}, error) {
 	return drivers.ConstructConfig(&drivers.ConstructConfigOpts{
 		RawConf:      resource.Config,
 		LookupTable:  *d.lookupTable,
@@ -974,21 +991,21 @@ func (t *CloneEnvGroupHook) PreApply() error {
 			continue
 		}
 
-		config := &ApplicationConfig{}
+		appConf := &ApplicationConfig{}
 
-		err := mapstructure.Decode(res.Config, &config)
+		err := mapstructure.Decode(res.Config, &appConf)
 		if err != nil {
 			continue
 		}
 
-		if config != nil && len(config.EnvGroups) > 0 {
-			target, err := preview.GetTarget(res.Target)
+		if appConf != nil && len(appConf.EnvGroups) > 0 {
+			target, err := preview.GetTarget(res.Name, res.Target)
 
 			if err != nil {
 				return err
 			}
 
-			for _, group := range config.EnvGroups {
+			for _, group := range appConf.EnvGroups {
 				if group.Name == "" {
 					return fmt.Errorf("env group name cannot be empty")
 				}
@@ -1051,7 +1068,7 @@ 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)
+	target, _ := preview.GetTarget(res.Name, res.Target)
 
 	if target.AppName != "" {
 		return target.AppName
@@ -1063,7 +1080,7 @@ func getReleaseName(res *switchboardTypes.Resource) string {
 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)
+	source, _ := preview.GetSource(res.Name, res.Source)
 
 	if source != nil && source.Name != "" {
 		return source.Name

+ 26 - 0
cli/cmd/connect.go

@@ -92,6 +92,18 @@ var connectGCRCmd = &cobra.Command{
 	},
 }
 
+var connectGARCmd = &cobra.Command{
+	Use:   "gar",
+	Short: "Adds a GAR instance to a project",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, runConnectGAR)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
 var connectDOCRCmd = &cobra.Command{
 	Use:   "docr",
 	Short: "Adds a DOCR instance to a project",
@@ -127,6 +139,7 @@ func init() {
 	connectCmd.AddCommand(connectRegistryCmd)
 	connectCmd.AddCommand(connectDockerhubCmd)
 	connectCmd.AddCommand(connectGCRCmd)
+	connectCmd.AddCommand(connectGARCmd)
 	connectCmd.AddCommand(connectDOCRCmd)
 	connectCmd.AddCommand(connectHelmRepoCmd)
 }
@@ -179,6 +192,19 @@ func runConnectGCR(_ *types.GetAuthenticatedUserResponse, client *api.Client, _
 	return cliConf.SetRegistry(regID)
 }
 
+func runConnectGAR(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
+	regID, err := connect.GAR(
+		client,
+		cliConf.Project,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	return cliConf.SetRegistry(regID)
+}
+
 func runConnectDOCR(_ *types.GetAuthenticatedUserResponse, client *api.Client, _ []string) error {
 	regID, err := connect.DOCR(
 		client,

+ 92 - 0
cli/cmd/connect/gar.go

@@ -0,0 +1,92 @@
+package connect
+
+import (
+	"context"
+	"fmt"
+	"io/ioutil"
+	"os"
+
+	"github.com/fatih/color"
+
+	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+)
+
+// GAR creates a GAR integration
+func GAR(
+	client *api.Client,
+	projectID uint,
+) (uint, error) {
+	// if project ID is 0, ask the user to set the project ID or create a project
+	if projectID == 0 {
+		return 0, fmt.Errorf("no project set, please run porter config set-project")
+	}
+
+	keyFileLocation, err := utils.PromptPlaintext(fmt.Sprintf(`Please provide the full path to a service account key file.
+Key file location: `))
+
+	if err != nil {
+		return 0, err
+	}
+
+	// attempt to read the key file location
+	if info, err := os.Stat(keyFileLocation); !os.IsNotExist(err) && !info.IsDir() {
+		// read the file
+		bytes, err := ioutil.ReadFile(keyFileLocation)
+
+		if err != nil {
+			return 0, err
+		}
+
+		// create the gcp integration
+		integration, err := client.CreateGCPIntegration(
+			context.Background(),
+			projectID,
+			&types.CreateGCPRequest{
+				GCPKeyData: string(bytes),
+			},
+		)
+
+		if err != nil {
+			return 0, err
+		}
+
+		color.New(color.FgGreen).Printf("created gcp integration with id %d\n", integration.ID)
+
+		region, err := utils.PromptPlaintext(fmt.Sprintf(`Please enter the artifact registry region. For example, us-central-1.
+Artifact registry region: `))
+
+		if err != nil {
+			return 0, err
+		}
+
+		// create the registry
+		// query for registry name
+		regName, err := utils.PromptPlaintext(fmt.Sprintf(`Give this registry a name: `))
+
+		if err != nil {
+			return 0, err
+		}
+
+		reg, err := client.CreateRegistry(
+			context.Background(),
+			projectID,
+			&types.CreateRegistryRequest{
+				Name:             regName,
+				GCPIntegrationID: integration.ID,
+				URL:              region + "-docker.pkg.dev/" + integration.GCPProjectID,
+			},
+		)
+
+		if err != nil {
+			return 0, err
+		}
+
+		color.New(color.FgGreen).Printf("created registry with id %d and name %s\n", reg.ID, reg.Name)
+
+		return reg.ID, nil
+	}
+
+	return 0, fmt.Errorf("could not read service account key file")
+}

+ 2 - 2
cli/cmd/connect/gcr.go

@@ -20,7 +20,7 @@ func GCR(
 ) (uint, error) {
 	// if project ID is 0, ask the user to set the project ID or create a project
 	if projectID == 0 {
-		return 0, fmt.Errorf("no project set, please run porter project set [id]")
+		return 0, fmt.Errorf("no project set, please run porter config set-project")
 	}
 
 	keyFileLocation, err := utils.PromptPlaintext(fmt.Sprintf(`Please provide the full path to a service account key file.
@@ -39,7 +39,7 @@ Key file location: `))
 			return 0, err
 		}
 
-		// create the aws integration
+		// create the gcp integration
 		integration, err := client.CreateGCPIntegration(
 			context.Background(),
 			projectID,

+ 5 - 0
cli/cmd/deploy/create.go

@@ -435,6 +435,11 @@ func (c *CreateAgent) GetImageRepoURL(name, namespace string) (uint, string, err
 		}
 	}
 
+	if strings.Contains(imageURI, "pkg.dev") {
+		repoSlice := strings.Split(imageURI, "/")
+		imageURI = fmt.Sprintf("%s/%s", imageURI, repoSlice[len(repoSlice)-1])
+	}
+
 	return regID, imageURI, nil
 }
 

+ 50 - 0
cli/cmd/docker/auth.go

@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"io/ioutil"
+	"net/url"
 	"os"
 	"path/filepath"
 	"regexp"
@@ -51,6 +52,8 @@ type AuthGetter struct {
 func (a *AuthGetter) GetCredentials(serverURL string) (user string, secret string, err error) {
 	if strings.Contains(serverURL, "gcr.io") {
 		return a.GetGCRCredentials(serverURL, a.ProjectID)
+	} else if strings.Contains(serverURL, "pkg.dev") {
+		return a.GetGARCredentials(serverURL, a.ProjectID)
 	} else if strings.Contains(serverURL, "registry.digitalocean.com") {
 		return a.GetDOCRCredentials(serverURL, a.ProjectID)
 	} else if strings.Contains(serverURL, "index.docker.io") {
@@ -97,6 +100,53 @@ func (a *AuthGetter) GetGCRCredentials(serverURL string, projID uint) (user stri
 	return "oauth2accesstoken", token, nil
 }
 
+func (a *AuthGetter) GetGARCredentials(serverURL string, projID uint) (user string, secret string, err error) {
+	if err != nil {
+		return "", "", err
+	}
+
+	cachedEntry := a.Cache.Get(serverURL)
+
+	if !strings.HasPrefix(serverURL, "https://") {
+		serverURL = "https://" + serverURL
+	}
+
+	parsedURL, err := url.Parse(serverURL)
+
+	if err != nil {
+		return "", "", err
+	}
+
+	serverURL = parsedURL.Host + "/" + strings.Split(parsedURL.Path, "/")[0]
+
+	var token string
+
+	if cachedEntry != nil && cachedEntry.IsValid(time.Now()) {
+		token = cachedEntry.AuthorizationToken
+	} else {
+		// get a token from the server
+		tokenResp, err := a.Client.GetGARAuthorizationToken(context.Background(), projID, &types.GetRegistryGARTokenRequest{
+			ServerURL: serverURL,
+		})
+
+		if err != nil {
+			return "", "", err
+		}
+
+		token = tokenResp.Token
+
+		// set the token in cache
+		a.Cache.Set(serverURL, &AuthEntry{
+			AuthorizationToken: token,
+			RequestedAt:        time.Now(),
+			ExpiresAt:          *tokenResp.ExpiresAt,
+			ProxyEndpoint:      serverURL,
+		})
+	}
+
+	return "oauth2accesstoken", token, nil
+}
+
 func (a *AuthGetter) GetDOCRCredentials(serverURL string, projID uint) (user string, secret string, err error) {
 	cachedEntry := a.Cache.Get(serverURL)
 

+ 2 - 2
cli/cmd/preview/build_image_driver.go

@@ -49,14 +49,14 @@ func NewBuildDriver(resource *models.Resource, opts *drivers.SharedDriverOpts) (
 		output:      make(map[string]interface{}),
 	}
 
-	source, err := GetSource(resource.Source)
+	source, err := GetSource(resource.Name, resource.Source)
 	if err != nil {
 		return nil, err
 	}
 
 	driver.source = source
 
-	target, err := GetTarget(resource.Target)
+	target, err := GetTarget(resource.Name, resource.Target)
 	if err != nil {
 		return nil, err
 	}

+ 1 - 1
cli/cmd/preview/env_group_driver.go

@@ -29,7 +29,7 @@ func NewEnvGroupDriver(resource *models.Resource, opts *drivers.SharedDriverOpts
 		output:      make(map[string]interface{}),
 	}
 
-	target, err := GetTarget(resource.Target)
+	target, err := GetTarget(resource.Name, resource.Target)
 
 	if err != nil {
 		return nil, err

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

@@ -35,7 +35,7 @@ func NewPushDriver(resource *models.Resource, opts *drivers.SharedDriverOpts) (d
 		output:      make(map[string]interface{}),
 	}
 
-	target, err := GetTarget(resource.Target)
+	target, err := GetTarget(resource.Name, resource.Target)
 	if err != nil {
 		return nil, err
 	}

+ 2 - 2
cli/cmd/preview/update_config_driver.go

@@ -50,14 +50,14 @@ func NewUpdateConfigDriver(resource *models.Resource, opts *drivers.SharedDriver
 		output:      make(map[string]interface{}),
 	}
 
-	source, err := GetSource(resource.Source)
+	source, err := GetSource(resource.Name, resource.Source)
 	if err != nil {
 		return nil, err
 	}
 
 	driver.source = source
 
-	target, err := GetTarget(resource.Target)
+	target, err := GetTarget(resource.Name, resource.Target)
 	if err != nil {
 		return nil, err
 	}

+ 17 - 14
cli/cmd/preview/utils.go

@@ -25,7 +25,7 @@ type Target struct {
 	Namespace string
 }
 
-func GetSource(input map[string]interface{}) (*Source, error) {
+func GetSource(resourceName string, input map[string]interface{}) (*Source, error) {
 	output := &Source{}
 
 	// first read from env vars
@@ -38,21 +38,21 @@ func GetSource(input map[string]interface{}) (*Source, error) {
 		if name, ok := input["name"]; ok {
 			nameVal, ok := name.(string)
 			if !ok {
-				return nil, fmt.Errorf("invalid name provided")
+				return nil, fmt.Errorf("error parsing source for resource '%s': invalid name provided", resourceName)
 			}
 			output.Name = nameVal
 		}
 	}
 
 	if output.Name == "" {
-		return nil, fmt.Errorf("source name required")
+		return nil, fmt.Errorf("error parsing source for resource '%s': source name required", resourceName)
 	}
 
 	if output.Repo == "" {
 		if repo, ok := input["repo"]; ok {
 			repoVal, ok := repo.(string)
 			if !ok {
-				return nil, fmt.Errorf("invalid repo provided")
+				return nil, fmt.Errorf("error parsing source for resource '%s': invalid repo provided", resourceName)
 			}
 			output.Repo = repoVal
 		}
@@ -62,7 +62,7 @@ func GetSource(input map[string]interface{}) (*Source, error) {
 		if version, ok := input["version"]; ok {
 			versionVal, ok := version.(string)
 			if !ok {
-				return nil, fmt.Errorf("invalid version provided")
+				return nil, fmt.Errorf("error parsing source for resource '%s': invalid version provided", resourceName)
 			}
 			output.Version = versionVal
 		}
@@ -97,7 +97,8 @@ func GetSource(input map[string]interface{}) (*Source, error) {
 			return output, nil
 		}
 
-		return nil, fmt.Errorf("source does not exist in any repo")
+		return nil, fmt.Errorf("error parsing source for resource '%s': source does not exist in "+
+			"'https://charts.getporter.dev' or 'https://chart-addons.getporter.dev'", resourceName)
 	} else {
 		// we look in the passed-in repo
 		values, err := existsInRepo(output.Name, output.Version, output.Repo)
@@ -108,17 +109,18 @@ func GetSource(input map[string]interface{}) (*Source, error) {
 		}
 	}
 
-	return nil, fmt.Errorf("source '%s' does not exist in repo '%s'", output.Name, output.Repo)
+	return nil, fmt.Errorf("error parsing source for resource '%s': source '%s' does not exist in repo '%s'",
+		resourceName, output.Name, output.Repo)
 }
 
-func GetTarget(input map[string]interface{}) (*Target, error) {
+func GetTarget(resourceName string, input map[string]interface{}) (*Target, error) {
 	output := &Target{}
 
 	// first read from env vars
 	if projectEnv := os.Getenv("PORTER_PROJECT"); projectEnv != "" {
 		project, err := strconv.Atoi(projectEnv)
 		if err != nil {
-			return nil, err
+			return nil, fmt.Errorf("error parsing target for resource '%s': %w", resourceName, err)
 		}
 		output.Project = uint(project)
 	}
@@ -126,7 +128,7 @@ func GetTarget(input map[string]interface{}) (*Target, error) {
 	if clusterEnv := os.Getenv("PORTER_CLUSTER"); clusterEnv != "" {
 		cluster, err := strconv.Atoi(clusterEnv)
 		if err != nil {
-			return nil, err
+			return nil, fmt.Errorf("error parsing target for resource '%s': %w", resourceName, err)
 		}
 		output.Cluster = uint(cluster)
 	}
@@ -138,7 +140,7 @@ func GetTarget(input map[string]interface{}) (*Target, error) {
 		if project, ok := input["project"]; ok {
 			projectVal, ok := project.(uint)
 			if !ok {
-				return nil, fmt.Errorf("project value must be an integer")
+				return nil, fmt.Errorf("error parsing target for resource '%s': project value must be an integer", resourceName)
 			}
 			output.Project = projectVal
 		}
@@ -148,7 +150,8 @@ func GetTarget(input map[string]interface{}) (*Target, error) {
 		if cluster, ok := input["cluster"]; ok {
 			clusterVal, ok := cluster.(uint)
 			if !ok {
-				return nil, fmt.Errorf("cluster value must be an integer")
+				return nil, fmt.Errorf("error parsing target for resource '%s': cluster value must be an integer",
+					resourceName)
 			}
 			output.Cluster = clusterVal
 		}
@@ -158,7 +161,7 @@ func GetTarget(input map[string]interface{}) (*Target, error) {
 		if namespace, ok := input["namespace"]; ok {
 			namespaceVal, ok := namespace.(string)
 			if !ok {
-				return nil, fmt.Errorf("invalid namespace provided")
+				return nil, fmt.Errorf("error parsing target for resource '%s': invalid namespace provided", resourceName)
 			}
 			output.Namespace = namespaceVal
 		}
@@ -167,7 +170,7 @@ func GetTarget(input map[string]interface{}) (*Target, error) {
 	if appName, ok := input["app_name"]; ok {
 		appNameVal, ok := appName.(string)
 		if !ok {
-			return nil, fmt.Errorf("invalid app_name provided")
+			return nil, fmt.Errorf("error parsing target for resource '%s': invalid app_name provided", resourceName)
 		}
 		output.AppName = appNameVal
 	}

+ 4 - 3
cmd/app/main.go

@@ -116,9 +116,10 @@ func initData(conf *config.Config) error {
 			l.Debug().Msg("default cluster not found: attempting creation")
 
 			_, err = conf.Repo.Cluster().CreateCluster(&models.Cluster{
-				Name:          defaultClusterName,
-				AuthMechanism: models.InCluster,
-				ProjectID:     1,
+				Name:                defaultClusterName,
+				AuthMechanism:       models.InCluster,
+				ProjectID:           1,
+				MonitorHelmReleases: true,
 			})
 
 			if err != nil {

+ 4 - 1
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -140,7 +140,10 @@ const ChartList: React.FunctionComponent<Props> = ({
                 return chart;
               });
             case "DELETE":
-              return tmpCharts.filter((chart) => !isSameChart(chart));
+              const chartToDelete = tmpCharts.find(isSameChart);
+              if (chartToDelete.version === newChart.version) {
+                return tmpCharts.filter((chart) => !isSameChart(chart));
+              }
             default:
               return tmpCharts;
           }

+ 4 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -312,6 +312,9 @@ const ExpandedChart: React.FC<Props> = (props) => {
         "<token>",
         {
           values: valuesYaml,
+          // this is triggered from the Porter form, so we set the latest revision to ensure that the release is
+          // up to date
+          latest_revision: currentChart.version,
         },
         {
           id: currentProject.id,
@@ -369,6 +372,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
           {
             values: valuesYaml,
             version: version,
+            latest_revision: currentChart.version,
           },
           {
             id: currentProject.id,

+ 1 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -116,6 +116,7 @@ const SettingsSection: React.FC<PropsType> = ({
         "<token>",
         {
           values: conf,
+          latest_revision: currentChart?.version,
         },
         {
           id: currentProject.id,

+ 1 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx

@@ -64,6 +64,7 @@ export default class ValuesYaml extends Component<PropsType, StateType> {
         "<token>",
         {
           values: valuesString,
+          latest_revision: this.props.currentChart.version,
         },
         {
           id: currentProject.id,

+ 1 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/build-settings/BuildSettingsTab.tsx

@@ -122,6 +122,7 @@ const BuildSettingsTab: React.FC<Props> = ({
         "<token>",
         {
           values: valuesYaml,
+          latest_revision: chart.version,
         },
         {
           id: currentProject.id,

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

@@ -354,6 +354,7 @@ export const useJobs = (chart: ChartType) => {
         "<token>",
         {
           values: yamlValues,
+          latest_revision: chart.version,
         },
         {
           id: currentProject.id,

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

@@ -218,7 +218,11 @@ const ExpandedStack = () => {
             component: (
               <>
                 <Gap></Gap>
-                <Settings stackName={stack.name} onDelete={handleDelete} />
+                <Settings
+                  stack={stack}
+                  onDelete={handleDelete}
+                  onUpdate={refreshStack}
+                />
               </>
             ),
           },

+ 111 - 72
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_SourceConfig.tsx

@@ -1,11 +1,9 @@
-import { Tooltip } from "@material-ui/core";
-import ImageSelector from "components/image-selector/ImageSelector";
 import SaveButton from "components/SaveButton";
-import React, { useContext, useMemo, useState } from "react";
+import React, { useContext, useReducer, useRef, useState } from "react";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import styled from "styled-components";
-import { AppResource, FullStackRevision, SourceConfig, Stack } from "../types";
+import { FullStackRevision, SourceConfig } from "../types";
 import SourceEditorDocker from "./components/SourceEditorDocker";
 
 const _SourceConfig = ({
@@ -64,39 +62,13 @@ const _SourceConfig = ({
   return (
     <SourceConfigStyles.Wrapper>
       {revision.source_configs.map((sourceConfig) => {
-        const apps = getAppsFromSourceConfig(revision.resources, sourceConfig);
-
-        const appList = formatAppList(apps, 2);
         return (
-          <SourceConfigStyles.ItemContainer>
-            {appList.hiddenApps?.length ? (
-              <Tooltip
-                title={
-                  <>
-                    {appList.hiddenApps.map((appName) => (
-                      <SourceConfigStyles.TooltipItem>
-                        {appName}
-                      </SourceConfigStyles.TooltipItem>
-                    ))}
-                  </>
-                }
-                placement={"bottom-end"}
-              >
-                <SourceConfigStyles.ItemTitle>
-                  Used by {appList.value}
-                </SourceConfigStyles.ItemTitle>
-              </Tooltip>
-            ) : (
-              <SourceConfigStyles.ItemTitle>
-                Used by {appList.value}
-              </SourceConfigStyles.ItemTitle>
-            )}
-            <SourceEditorDocker
-              sourceConfig={sourceConfig}
-              onChange={handleChange}
-              readOnly={readOnly || buttonStatus === "loading"}
-            />
-          </SourceConfigStyles.ItemContainer>
+          <SourceConfigItem
+            sourceConfig={sourceConfig}
+            key={sourceConfig.id}
+            handleChange={handleChange}
+            disabled={readOnly || buttonStatus === "loading"}
+          />
         );
       })}
       {readOnly ? null : (
@@ -117,41 +89,6 @@ const _SourceConfig = ({
 
 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;
@@ -164,8 +101,17 @@ const SourceConfigStyles = {
   `,
   ItemTitle: styled.div`
     font-size: 16px;
-    width: fit-content;
     font-weight: 500;
+
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 10px;
+    > span {
+      overflow-x: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
   `,
   TooltipItem: styled.div`
     font-size: 14px;
@@ -179,3 +125,96 @@ const SourceConfigStyles = {
     z-index: unset;
   `,
 };
+
+const SourceConfigItem = ({
+  sourceConfig,
+  handleChange,
+  disabled,
+}: {
+  sourceConfig: SourceConfig;
+  handleChange: (sourceConfig: SourceConfig) => void;
+  disabled: boolean;
+}) => {
+  const [editNameMode, toggleEditNameMode] = useReducer((prev) => !prev, false);
+  const prevName = useRef(sourceConfig.name);
+  const [name, setName] = useState(sourceConfig.name);
+
+  const handleNameChange = (newName: string) => {
+    setName(newName);
+    handleChange({ ...sourceConfig, name: newName });
+  };
+
+  const handleNameChangeCancel = () => {
+    setName(prevName.current);
+    handleChange({ ...sourceConfig, name: prevName.current });
+    toggleEditNameMode();
+  };
+
+  return (
+    <SourceConfigStyles.ItemContainer>
+      {editNameMode && !disabled ? (
+        <>
+          <SourceConfigStyles.ItemTitle>
+            <PlainTextInput
+              value={name}
+              onChange={(e) => handleNameChange(e.target.value)}
+              type="text"
+              disabled={disabled}
+            />
+            <EditButton onClick={handleNameChangeCancel}>
+              <i className="material-icons-outlined">close</i>
+            </EditButton>
+          </SourceConfigStyles.ItemTitle>
+        </>
+      ) : (
+        <SourceConfigStyles.ItemTitle>
+          <span>{name}</span>
+
+          {sourceConfig.stable_source_config_id && (
+            <EditButton
+              onClick={toggleEditNameMode}
+              disabled={!sourceConfig.stable_source_config_id}
+            >
+              <i className="material-icons-outlined">edit</i>
+            </EditButton>
+          )}
+        </SourceConfigStyles.ItemTitle>
+      )}
+
+      <SourceEditorDocker
+        sourceConfig={sourceConfig}
+        onChange={handleChange}
+        readOnly={disabled}
+      />
+    </SourceConfigStyles.ItemContainer>
+  );
+};
+
+const EditButton = styled.button`
+  outline: none;
+  cursor: pointer;
+  color: white;
+  border: 1px solid rgba(255, 255, 255, 0.333);
+  background: rgba(255, 255, 255, 0.067);
+  height: 35px;
+  width: 35px;
+  border-radius: 24px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  > i {
+    font-size: 20px;
+  }
+`;
+
+const PlainTextInput = styled.input`
+  outline: none;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  font-size: 13px;
+  background: #ffffff11;
+  width: 100%;
+  color: white;
+  padding: 5px 10px;
+  height: 35px;
+`;

+ 67 - 5
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/Settings.tsx

@@ -1,16 +1,30 @@
 import Heading from "components/form-components/Heading";
-import React, { useContext } from "react";
+import Helper from "components/form-components/Helper";
+import InputRow from "components/form-components/InputRow";
+import React, { useContext, useState } from "react";
+import api from "shared/api";
 import { Context } from "shared/Context";
 import styled from "styled-components";
+import { SubmitButton } from "../../launch/components/styles";
+import { Stack } from "../../types";
 
 const Settings = ({
-  stackName,
+  stack,
   onDelete,
+  onUpdate,
 }: {
-  stackName: string;
+  stack: Stack;
   onDelete: () => void;
+  onUpdate: () => Promise<void>;
 }) => {
-  const { setCurrentOverlay } = useContext(Context);
+  const {
+    currentCluster,
+    currentProject,
+    setCurrentOverlay,
+    setCurrentError,
+  } = useContext(Context);
+  const [stackName, setStackName] = useState(stack.name);
+  const [buttonStatus, setButtonStatus] = useState("");
 
   const handleDelete = () => {
     setCurrentOverlay({
@@ -22,10 +36,54 @@ const Settings = ({
       onNo: () => setCurrentOverlay(null),
     });
   };
+
+  const handleStackNameChange = async () => {
+    setButtonStatus("loading");
+    try {
+      await api.updateStack(
+        "<token>",
+        {
+          name: stackName,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          stack_id: stack.id,
+          namespace: stack.namespace,
+        }
+      );
+      await onUpdate();
+      setButtonStatus("successful");
+    } catch (err) {
+      setCurrentError(err);
+      setButtonStatus("Couldn't update the stack name. Try again later.");
+    }
+  };
+
   return (
     <Wrapper>
       <StyledSettingsSection>
-        <Heading>Settings</Heading>
+        <Heading>Update Stack name</Heading>
+
+        <InputRow
+          label="Stack name"
+          value={stackName}
+          setValue={setStackName as any}
+          type="text"
+          width="300px"
+        />
+        <SaveButton
+          text="Update"
+          onClick={handleStackNameChange}
+          disabled={stackName === stack.name}
+          makeFlush
+          clearPosition
+          statusPosition="right"
+          status={buttonStatus}
+        ></SaveButton>
+
+        <Heading>Additional Settings</Heading>
+
         <Button color="#b91133" onClick={handleDelete}>
           Delete stack
         </Button>
@@ -36,6 +94,10 @@ const Settings = ({
 
 export default Settings;
 
+const SaveButton = styled(SubmitButton)`
+  justify-content: flex-start;
+`;
+
 const Wrapper = styled.div`
   width: 100%;
   padding-bottom: 65px;

+ 21 - 3
dashboard/src/main/home/cluster-dashboard/stacks/launch/SelectSource.tsx

@@ -8,10 +8,11 @@ import Helper from "components/form-components/Helper";
 import Heading from "components/form-components/Heading";
 import styled from "styled-components";
 import TitleSection from "components/TitleSection";
+import InputRow from "components/form-components/InputRow";
 
 const SelectSource = () => {
   const { addSourceConfig } = useContext(StacksLaunchContext);
-
+  const [sourceName, setSourceName] = useState("");
   const [imageUrl, setImageUrl] = useState("");
   const [imageTag, setImageTag] = useState("");
   const { pushFiltered } = useRouting();
@@ -21,7 +22,8 @@ const SelectSource = () => {
       return;
     }
 
-    const newSource: Omit<CreateStackBody["source_configs"][0], "name"> = {
+    const newSource: CreateStackBody["source_configs"][0] = {
+      name: sourceName,
       image_repo_uri: imageUrl,
       image_tag: imageTag,
     };
@@ -39,11 +41,23 @@ const SelectSource = () => {
         New Application Stack
       </TitleSection>
       <Heading>Stack Source</Heading>
+
+      <Br />
+      <InputRowWrapper>
+        <InputRow
+          label="Source Name"
+          value={sourceName}
+          setValue={(val) => setSourceName(val as string)}
+          type="text"
+          width="100%"
+          placeholder="Leave empty for auto-generated source config name"
+        />
+      </InputRowWrapper>
+
       <Helper>
         Specify a source to deploy all stack applications from:
         <Required>*</Required>
       </Helper>
-      <Br />
       <ImageSelector
         selectedImageUrl={imageUrl}
         setSelectedImageUrl={setImageUrl}
@@ -86,3 +100,7 @@ const Polymer = styled.div`
     margin-right: 18px;
   }
 `;
+
+const InputRowWrapper = styled.div`
+  width: 60%;
+`;

+ 5 - 7
dashboard/src/main/home/cluster-dashboard/stacks/launch/Store.tsx

@@ -11,9 +11,7 @@ export type StacksLaunchContextType = {
   setStackName: (name: string) => void;
   setStackNamespace: (namespace: string) => void;
 
-  addSourceConfig: (
-    sourceConfig: Omit<CreateStackBody["source_configs"][0], "name">
-  ) => void;
+  addSourceConfig: (sourceConfig: CreateStackBody["source_configs"][0]) => void;
 
   addAppResource: (
     appResource: CreateStackBody["app_resources"][0],
@@ -42,9 +40,7 @@ const defaultValues: StacksLaunchContextType = {
   setStackName: (name: string) => {},
   setStackNamespace: (namespace: string) => {},
 
-  addSourceConfig: (
-    sourceConfig: Omit<CreateStackBody["source_configs"][0], "name">
-  ) => {},
+  addSourceConfig: (sourceConfig: CreateStackBody["source_configs"][0]) => {},
 
   addAppResource: (appResource: CreateStackBody["app_resources"][0]) => {},
 
@@ -96,7 +92,9 @@ const StacksLaunchContextProvider: React.FC<{}> = ({ children }) => {
       source_configs: [
         ...prev.source_configs,
         {
-          name: newSourceConfigName(prev.source_configs.length),
+          name:
+            sourceConfig.name ||
+            newSourceConfigName(prev.source_configs.length),
           ...sourceConfig,
         },
       ],

+ 2 - 0
dashboard/src/main/home/cluster-dashboard/stacks/types.ts

@@ -90,6 +90,8 @@ export type SourceConfig = {
   stack_id: string;
   stack_revision_id: number;
 
+  stable_source_config_id: string;
+
   build?: {
     method: "pack" | "docker";
     folder_path: string;

+ 3 - 0
dashboard/src/main/home/integrations/create-integration/CreateIntegrationForm.tsx

@@ -8,6 +8,7 @@ import EKSForm from "./EKSForm";
 import GCRForm from "./GCRForm";
 import ECRForm from "./ECRForm";
 import GitlabForm from "./GitlabForm";
+import GARForm from "./GARForm";
 
 type PropsType = {
   integrationName: string;
@@ -34,6 +35,8 @@ export default class CreateIntegrationForm extends Component<
         return <ECRForm closeForm={this.props.closeForm} />;
       case "gcr":
         return <GCRForm closeForm={this.props.closeForm} />;
+      case "gar":
+        return <GARForm closeForm={this.props.closeForm} />;
       case "gitlab":
         return <GitlabForm closeForm={this.props.closeForm} />;
       default:

+ 156 - 0
dashboard/src/main/home/integrations/create-integration/GARForm.tsx

@@ -0,0 +1,156 @@
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import InputRow from "components/form-components/InputRow";
+import SelectRow from "components/form-components/SelectRow";
+import UploadArea from "components/form-components/UploadArea";
+import SaveButton from "components/SaveButton";
+import { GCP_REGION_OPTIONS } from "main/home/onboarding/constants";
+import React, { useContext, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import styled from "styled-components";
+
+type GCPIntegration = {
+  id: string;
+  gcp_project_id: string;
+  [key: string]: unknown;
+};
+
+const GARForm = (props: { closeForm: () => void }) => {
+  const { closeForm } = props;
+  const { currentProject } = useContext(Context);
+
+  const [credentialsName, setCredentialsName] = useState("");
+  const [serviceAccountKey, setServiceAccountKey] = useState("");
+  const [region, setRegion] = useState("us-east1");
+  const [buttonStatus, setButtonStatus] = useState("");
+
+  const isValid = () => {
+    return (
+      credentialsName.length > 0 &&
+      serviceAccountKey.length > 0 &&
+      region.length > 0
+    );
+  };
+
+  const handleSubmit = async () => {
+    setButtonStatus("loading");
+
+    let integration: GCPIntegration;
+
+    try {
+      const res = await api.createGCPIntegration<GCPIntegration>(
+        "<token>",
+        {
+          gcp_key_data: serviceAccountKey,
+          gcp_project_id: "",
+        },
+        {
+          project_id: currentProject.id,
+        }
+      );
+
+      integration = res.data;
+    } catch (error) {
+      setButtonStatus(
+        "Couldn't connect with GCP with the provided credentials."
+      );
+      return;
+    }
+
+    try {
+      await api.connectGCRRegistry(
+        "token",
+        {
+          gcp_integration_id: integration.id,
+          name: credentialsName,
+          url: `${region}-docker.pkg.dev/${integration.gcp_project_id}`,
+        },
+        { id: currentProject.id }
+      );
+    } catch (error) {
+      setButtonStatus(
+        "Couldn't connect the GAR registry with the provided credentials."
+      );
+      return;
+    }
+
+    setButtonStatus("successfull");
+    closeForm();
+  };
+
+  return (
+    <StyledForm>
+      <CredentialWrapper>
+        <Heading>Porter Settings</Heading>
+        <Helper>
+          Give a name to this set of registry credentials (just for Porter).
+        </Helper>
+        <InputRow
+          type="text"
+          value={credentialsName}
+          setValue={(credentialsName: string) =>
+            setCredentialsName(credentialsName)
+          }
+          isRequired={true}
+          label="🏷️ Registry Name"
+          placeholder="ex: paper-straw"
+          width="100%"
+        />
+        <Heading>GCP Settings</Heading>
+        <Helper>Service account credentials for GCP permissions.</Helper>
+        <UploadArea
+          setValue={(x: any) => setServiceAccountKey(x)}
+          label="🔒 GCP Key Data (JSON)"
+          placeholder="Choose a file or drag it here."
+          width="100%"
+          height="100%"
+          isRequired={true}
+        />
+        <Helper>GAR Region</Helper>
+        <SelectRow
+          options={GCP_REGION_OPTIONS}
+          width="100%"
+          value={region}
+          scrollBuffer={true}
+          dropdownMaxHeight="240px"
+          setActiveValue={(x: string) => {
+            setRegion(x);
+          }}
+          label="📍 GCP Region"
+        />
+      </CredentialWrapper>
+      <SaveButton
+        text="Save Settings"
+        status={buttonStatus}
+        makeFlush={true}
+        disabled={!isValid()}
+        onClick={!isValid() ? null : handleSubmit}
+      />
+    </StyledForm>
+  );
+};
+
+export default GARForm;
+
+const CredentialWrapper = styled.div`
+  padding: 5px 40px 25px;
+  background: #ffffff11;
+  border-radius: 5px;
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding-bottom: 75px;
+`;
+
+const CodeBlock = styled.span`
+  display: inline-block;
+  background-color: #1b1d26;
+  color: white;
+  border-radius: 5px;
+  font-family: monospace;
+  padding: 2px 3px;
+  margin-top: -2px;
+  user-select: text;
+`;

+ 6 - 0
dashboard/src/main/home/onboarding/components/ProviderSelector.tsx

@@ -32,6 +32,11 @@ export const registryOptions = [
     icon: integrationList["gcr"]?.icon,
     label: "Google Cloud Registry (GCR)",
   },
+  {
+    value: "gar",
+    icon: integrationList["gcr"]?.icon,
+    label: "Google Artifact Registry (GAR)",
+  },
   {
     value: "do",
     icon: integrationList["do"]?.icon,
@@ -50,6 +55,7 @@ export const provisionerOptions = [
     icon: integrationList["gcp"]?.icon,
     label: "Google Cloud Platform (GCP)",
   },
+
   {
     value: "do",
     icon: integrationList["do"]?.icon,

+ 26 - 0
dashboard/src/main/home/onboarding/constants.ts

@@ -0,0 +1,26 @@
+export const GCP_REGION_OPTIONS = [
+  { value: "asia-east1", label: "asia-east1" },
+  { value: "asia-east2", label: "asia-east2" },
+  { value: "asia-northeast1", label: "asia-northeast1" },
+  { value: "asia-northeast2", label: "asia-northeast2" },
+  { value: "asia-northeast3", label: "asia-northeast3" },
+  { value: "asia-south1", label: "asia-south1" },
+  { value: "asia-southeast1", label: "asia-southeast1" },
+  { value: "asia-southeast2", label: "asia-southeast2" },
+  { value: "australia-southeast1", label: "australia-southeast1" },
+  { value: "europe-north1", label: "europe-north1" },
+  { value: "europe-west1", label: "europe-west1" },
+  { value: "europe-west2", label: "europe-west2" },
+  { value: "europe-west3", label: "europe-west3" },
+  { value: "europe-west4", label: "europe-west4" },
+  { value: "europe-west6", label: "europe-west6" },
+  { value: "northamerica-northeast1", label: "northamerica-northeast1" },
+  { value: "southamerica-east1", label: "southamerica-east1" },
+  { value: "us-central1", label: "us-central1" },
+  { value: "us-east1", label: "us-east1" },
+  { value: "us-east4", label: "us-east4" },
+  { value: "us-west1", label: "us-west1" },
+  { value: "us-west2", label: "us-west2" },
+  { value: "us-west3", label: "us-west3" },
+  { value: "us-west4", label: "us-west4" },
+];

+ 3 - 1
dashboard/src/main/home/onboarding/state/StateHandler.ts

@@ -3,6 +3,7 @@ import type {
   AWSProvisionerConfig,
   AWSRegistryConfig,
   DORegistryConfig,
+  GARRegistryConfig,
   GCPProvisionerConfig,
   GCPRegistryConfig,
   SkipProvisionConfig,
@@ -12,6 +13,7 @@ import type {
 export type ConnectedRegistryConfig =
   | AWSRegistryConfig
   | GCPRegistryConfig
+  | GARRegistryConfig
   | DORegistryConfig
   | SkipRegistryConnection;
 
@@ -34,7 +36,7 @@ export type OnboardingState = {
   user_email: string;
   project: ProjectData | null;
   connected_source: ConnectedSourceData | null;
-  connected_registry: any | null;
+  connected_registry: ConnectedRegistryConfig | null;
   provision_resources: Partial<ProvisionerConfig> | null;
   actions: {
     restoreState: (state: OnboardingState) => void;

+ 12 - 0
dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/FormFlow.tsx

@@ -23,6 +23,7 @@ import {
 
 import {
   CredentialsForm as GCPCredentialsForm,
+  GARegistryConfig,
   SettingsForm as GCPSettingsForm,
   TestRegistryConnection as GCPTestRegistryConnection,
 } from "./_GCPRegistryForm";
@@ -42,6 +43,11 @@ const Forms = {
     settings: GCPSettingsForm,
     test_connection: GCPTestRegistryConnection,
   },
+  gar: {
+    credentials: GCPCredentialsForm,
+    settings: GARegistryConfig,
+    test_connection: GCPTestRegistryConnection,
+  },
   do: {
     credentials: DOCredentialsForm,
     settings: DOSettingsForm,
@@ -62,6 +68,12 @@ const FormTitle = {
     doc:
       "https://docs.porter.run/deploying-applications/deploying-from-docker-registry/linking-existing-registry#google-container-registry-gcr",
   },
+  gar: {
+    label: "Google Artifact Registry (GAR)",
+    icon: integrationList["gcr"].icon,
+    doc:
+      "https://docs.porter.run/deploying-applications/deploying-from-docker-registry/linking-existing-registry#google-artifact-registry-gar",
+  },
   do: {
     label: "DigitalOcean Container Registry (DOCR)",
     icon: integrationList["do"].icon,

+ 135 - 0
dashboard/src/main/home/onboarding/steps/ConnectRegistry/forms/_GCPRegistryForm.tsx

@@ -1,9 +1,11 @@
 import Helper from "components/form-components/Helper";
 import InputRow from "components/form-components/InputRow";
+import SelectRow from "components/form-components/SelectRow";
 import UploadArea from "components/form-components/UploadArea";
 import Loading from "components/Loading";
 import SaveButton from "components/SaveButton";
 import RegistryImageList from "main/home/onboarding/components/RegistryImageList";
+import { GCP_REGION_OPTIONS } from "main/home/onboarding/constants";
 import { OFState } from "main/home/onboarding/state";
 import { StateHandler } from "main/home/onboarding/state/StateHandler";
 import { GCPRegistryConfig } from "main/home/onboarding/types";
@@ -305,6 +307,139 @@ export const SettingsForm: React.FC<{
   );
 };
 
+export const GARegistryConfig: React.FC<{
+  nextFormStep: (data: Partial<GCPRegistryConfig>) => void;
+  project: any;
+}> = ({ nextFormStep, project }) => {
+  const [buttonStatus, setButtonStatus] = useState("");
+  const [registryName, setRegistryName] = useState("");
+  const [region, setRegion] = useState("us-east1");
+
+  const snap = useSnapshot(OFState);
+
+  const validate = () => {
+    if (!registryName) {
+      return {
+        hasError: true,
+        error: "Registry Name cannot be empty",
+      };
+    }
+    if (!region) {
+      return {
+        hasError: true,
+        error: "Region is missing",
+      };
+    }
+
+    if (!GCP_REGION_OPTIONS.map((val) => val.value).includes(region)) {
+      return {
+        hasError: true,
+        error: "Region is invalid",
+      };
+    }
+
+    return { hasError: false, error: "" };
+  };
+
+  const submit = async () => {
+    const validation = validate();
+
+    if (validation.hasError) {
+      setButtonStatus(validation.error);
+      return;
+    }
+
+    setButtonStatus("loading");
+
+    let gcpProjectId = NaN;
+
+    try {
+      const gcp_integration = await api
+        .getGCPIntegration("<token>", {}, { project_id: project.id })
+        .then((res) => {
+          let integrations = res.data;
+
+          let lastUsed = integrations.find((i: any) => {
+            return (
+              i.id === snap.StateHandler?.connected_registry?.credentials?.id
+            );
+          });
+          return lastUsed;
+        });
+
+      if (gcp_integration) {
+        gcpProjectId = gcp_integration.gpc_project_id;
+      }
+    } catch (error) {
+      setButtonStatus("Couldn't get the project id from the GCP integration.");
+      return;
+    }
+
+    const registryUrl = `${region}-docker.pkg.dev/${gcpProjectId}`;
+
+    try {
+      const data = await api
+        .connectGCRRegistry(
+          "<token>",
+          {
+            name: registryName,
+            gcp_integration_id:
+              snap.StateHandler.connected_registry.credentials.id,
+            url: registryUrl,
+          },
+          {
+            id: project.id,
+          }
+        )
+        .then((res) => res?.data);
+      nextFormStep({
+        settings: {
+          registry_connection_id: data.id,
+          gcr_url: registryUrl,
+          registry_name: registryName,
+        },
+      });
+    } catch (error) {
+      setButtonStatus("Couldn't connect registry.");
+    }
+  };
+  return (
+    <>
+      <Helper>Porter will use this registry to store your images.</Helper>
+      <InputRow
+        type="text"
+        value={registryName}
+        setValue={(name: string) => setRegistryName(name)}
+        isRequired={true}
+        label="🏷️ Registry Name"
+        placeholder="ex: paper-straw"
+        width="100%"
+      />
+      <SelectRow
+        options={GCP_REGION_OPTIONS}
+        width="100%"
+        value={region}
+        scrollBuffer={true}
+        dropdownMaxHeight="240px"
+        setActiveValue={(x: string) => {
+          setRegion(x);
+        }}
+        label="📍 GCP Region"
+      />
+      <Br />
+      <SaveButton
+        text="Continue"
+        disabled={false}
+        onClick={submit}
+        makeFlush={true}
+        clearPosition={true}
+        status={buttonStatus}
+        statusPosition={"right"}
+      />
+    </>
+  );
+};
+
 export const TestRegistryConnection: React.FC<{
   nextFormStep: () => void;
   project: any;

+ 1 - 1
dashboard/src/main/home/onboarding/steps/ProvisionResources/ProvisionResources.tsx

@@ -203,7 +203,7 @@ const ProvisionResources: React.FC<{}> = () => {
       case "aws":
         return ["eks", "ecr"];
       case "gcp":
-        return ["gke", "gcr"];
+        return ["gke", "gcr", "gar"];
       case "do":
         return ["doks", "docr"];
     }

+ 14 - 35
dashboard/src/main/home/onboarding/steps/ProvisionResources/forms/_GCPProvisionerForm.tsx

@@ -4,6 +4,7 @@ import SelectRow from "components/form-components/SelectRow";
 import UploadArea from "components/form-components/UploadArea";
 import Loading from "components/Loading";
 import SaveButton from "components/SaveButton";
+import { GCP_REGION_OPTIONS } from "main/home/onboarding/constants";
 import { OFState } from "main/home/onboarding/state";
 import {
   GCPProvisionerConfig,
@@ -16,33 +17,6 @@ import { Infrastructure } from "shared/types";
 import styled from "styled-components";
 import { useSnapshot } from "valtio";
 
-const regionOptions = [
-  { value: "asia-east1", label: "asia-east1" },
-  { value: "asia-east2", label: "asia-east2" },
-  { value: "asia-northeast1", label: "asia-northeast1" },
-  { value: "asia-northeast2", label: "asia-northeast2" },
-  { value: "asia-northeast3", label: "asia-northeast3" },
-  { value: "asia-south1", label: "asia-south1" },
-  { value: "asia-southeast1", label: "asia-southeast1" },
-  { value: "asia-southeast2", label: "asia-southeast2" },
-  { value: "australia-southeast1", label: "australia-southeast1" },
-  { value: "europe-north1", label: "europe-north1" },
-  { value: "europe-west1", label: "europe-west1" },
-  { value: "europe-west2", label: "europe-west2" },
-  { value: "europe-west3", label: "europe-west3" },
-  { value: "europe-west4", label: "europe-west4" },
-  { value: "europe-west6", label: "europe-west6" },
-  { value: "northamerica-northeast1", label: "northamerica-northeast1" },
-  { value: "southamerica-east1", label: "southamerica-east1" },
-  { value: "us-central1", label: "us-central1" },
-  { value: "us-east1", label: "us-east1" },
-  { value: "us-east4", label: "us-east4" },
-  { value: "us-west1", label: "us-west1" },
-  { value: "us-west2", label: "us-west2" },
-  { value: "us-west3", label: "us-west3" },
-  { value: "us-west4", label: "us-west4" },
-];
-
 export const CredentialsForm: React.FC<{
   nextFormStep: (data: Partial<GCPRegistryConfig>) => void;
   project: any;
@@ -275,7 +249,7 @@ export const SettingsForm: React.FC<{
           .filter((infra) => infra.kind == "gke")
           .sort(sortFunc);
         const matchedGCRInfras = data
-          .filter((infra) => infra.kind == "gcr")
+          .filter((infra) => infra.kind == "gcr" || infra.kind == "gar")
           .sort(sortFunc);
 
         if (matchedGKEInfras.length > 0) {
@@ -327,7 +301,8 @@ export const SettingsForm: React.FC<{
     infras: { kind: string; status: string }[]
   ) => {
     return !!infras.find(
-      (i) => ["docr", "gcr", "ecr"].includes(i.kind) && i.status === "created"
+      (i) =>
+        ["docr", "gcr", "ecr", "gar"].includes(i.kind) && i.status === "created"
     );
   };
 
@@ -370,7 +345,7 @@ export const SettingsForm: React.FC<{
     let clusterProvisionResponse = null;
     if (snap.StateHandler.connected_registry.skip) {
       if (!hasRegistryProvisioned(infras)) {
-        registryProvisionResponse = await provisionGCR(integrationId);
+        registryProvisionResponse = await provisionGAR(integrationId);
       }
     }
     if (!hasClusterProvisioned(infras)) {
@@ -387,7 +362,7 @@ export const SettingsForm: React.FC<{
     });
   };
 
-  const provisionGCR = async (id: number) => {
+  const provisionGAR = async (id: number) => {
     // console.log("Provisioning GCR");
 
     // See if there's an infra for GKE that is in an errored state and the last operation
@@ -401,7 +376,9 @@ export const SettingsForm: React.FC<{
           "<token>",
           {
             gcp_integration_id: id,
-            values: {},
+            values: {
+              gcp_region: region,
+            },
           },
           { project_id: project.id, infra_id: currGCRInfra.id }
         );
@@ -414,9 +391,11 @@ export const SettingsForm: React.FC<{
         const res = await api.provisionInfra(
           "<token>",
           {
-            kind: "gcr",
+            kind: "gar",
             gcp_integration_id: id,
-            values: {},
+            values: {
+              gcp_region: region,
+            },
           },
           { project_id: project.id }
         );
@@ -489,7 +468,7 @@ export const SettingsForm: React.FC<{
         isRequired={true}
       />
       <SelectRow
-        options={regionOptions}
+        options={GCP_REGION_OPTIONS}
         width="100%"
         value={region}
         scrollBuffer={true}

+ 13 - 0
dashboard/src/main/home/onboarding/types.ts

@@ -34,6 +34,19 @@ export type GCPRegistryConfig = {
   };
 };
 
+export type GARRegistryConfig = {
+  skip: false;
+  provider: "gar";
+  credentials: {
+    id: number;
+  };
+  settings: {
+    registry_connection_id: number;
+    registry_name: string;
+    gar_url: string;
+  };
+};
+
 export type DORegistryConfig = {
   skip: false;
   provider: "do";

+ 17 - 0
dashboard/src/shared/api.tsx

@@ -2145,6 +2145,22 @@ const removeStackEnvGroup = baseApi<
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/remove_env_group/${env_group_name}`
 );
 
+const updateStack = baseApi<
+  {
+    name: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+  }
+>(
+  "PATCH",
+  ({ project_id, cluster_id, namespace, stack_id }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}`
+);
+
 const getGithubStatus = baseApi<{}, {}>("GET", ({}) => `/api/status/github`);
 
 // Bundle export to allow default api import (api.<method> is more readable)
@@ -2346,6 +2362,7 @@ export default {
   removeStackAppResource,
   addStackEnvGroup,
   removeStackEnvGroup,
+  updateStack,
 
   // STATUS
   getGithubStatus,

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

@@ -64,6 +64,11 @@ export const integrationList: any = {
       "https://carlossanchez.files.wordpress.com/2019/06/21046548.png?w=640",
     label: "Google Container Registry (GCR)",
   },
+  gar: {
+    icon:
+      "https://carlossanchez.files.wordpress.com/2019/06/21046548.png?w=640",
+    label: "Google Artifact Registry (GAR)",
+  },
   ecr: {
     icon:
       "https://avatars2.githubusercontent.com/u/52505464?s=400&u=da920f994c67665c7ad6c606a5286557d4f8555f&v=4",
@@ -97,6 +102,11 @@ export const integrationList: any = {
     icon: gcp,
     label: "GCP",
   },
+  gar: {
+    icon:
+      "https://carlossanchez.files.wordpress.com/2019/06/21046548.png?w=640",
+    label: "Google Artifact Registry (GAR)",
+  },
   do: {
     icon: digitalOcean,
     label: "DigitalOcean",

+ 2 - 0
dashboard/src/shared/hooks/useChart.ts

@@ -67,6 +67,7 @@ export const useChart = (oldChart: ChartType, closeChart: () => void) => {
         {
           values: valuesYaml,
           version: chart.latest_version,
+          latest_revision: chart.version,
         },
         {
           id: currentProject.id,
@@ -237,6 +238,7 @@ export const useChart = (oldChart: ChartType, closeChart: () => void) => {
         "<token>",
         {
           values,
+          latest_revision: chart.version,
         },
         {
           id: currentProject.id,

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

@@ -410,6 +410,7 @@ export type InfraKind =
   | "s3"
   | "gke"
   | "gcr"
+  | "gar"
   | "doks"
   | "docr"
   | "aks"
@@ -528,6 +529,13 @@ export const KindMap: ProviderInfoMap = {
     resource_link: "/integrations/registry",
     provider_name: "Google Container Registry (GCR)",
   },
+  gar: {
+    provider: "gcp",
+    source: "porter/gcp/gar",
+    resource_name: "Registry",
+    resource_link: "/integrations/registry",
+    provider_name: "Google Artifact Registry (GAR)",
+  },
   gke: {
     provider: "gcp",
     source: "porter/gcp/gke",

+ 7 - 3
go.mod

@@ -3,7 +3,7 @@ module github.com/porter-dev/porter
 go 1.18
 
 require (
-	cloud.google.com/go v0.99.0
+	cloud.google.com/go v0.102.0
 	github.com/AlecAivazis/survey/v2 v2.2.9
 	github.com/Masterminds/semver/v3 v3.1.1
 	github.com/aws/aws-sdk-go v1.43.28
@@ -49,7 +49,7 @@ require (
 	golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d
 	golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e
 	golang.org/x/oauth2 v0.0.0-20220628200809-02e64fa58f26
-	google.golang.org/api v0.62.0
+	google.golang.org/api v0.88.0
 	google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03
 	google.golang.org/grpc v1.47.0
 	google.golang.org/protobuf v1.28.0
@@ -74,6 +74,9 @@ require (
 )
 
 require (
+	cloud.google.com/go/artifactregistry v1.3.0 // indirect
+	cloud.google.com/go/compute v1.7.0 // indirect
+	cloud.google.com/go/iam v0.3.0 // indirect
 	github.com/Azure/azure-sdk-for-go v65.0.0+incompatible // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/azcore v0.23.1 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v0.9.1 // indirect
@@ -104,6 +107,7 @@ require (
 	github.com/go-gorp/gorp/v3 v3.0.2 // indirect
 	github.com/golang-jwt/jwt v3.2.1+incompatible // indirect
 	github.com/google/gnostic v0.6.9 // indirect
+	github.com/googleapis/enterprise-certificate-proxy v0.1.0 // indirect
 	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
 	github.com/hashicorp/go-retryablehttp v0.7.1 // indirect
 	github.com/kylelemons/godebug v1.1.0 // indirect
@@ -182,7 +186,7 @@ require (
 	github.com/google/gofuzz v1.2.0 // indirect
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // 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.4.0 // indirect
 	github.com/googleapis/gnostic v0.5.5 // indirect
 	github.com/gorilla/mux v1.8.0 // indirect
 	github.com/gosuri/uitable v0.0.4 // indirect

+ 78 - 0
go.sum

@@ -33,17 +33,33 @@ cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Ud
 cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0cM=
 cloud.google.com/go v0.99.0 h1:y/cM2iqGgGi5D5DQZl6D9STN/3dR/Vx5Mp8s752oJTY=
 cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
+cloud.google.com/go v0.100.2 h1:t9Iw5QH5v4XtlEQaCtUY7x6sCABps8sW0acw7e2WQ6Y=
+cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
+cloud.google.com/go v0.102.0 h1:DAq3r8y4mDgyB/ZPJ9v/5VJNqjgJAxTn6ZYLlUywOu8=
+cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc=
+cloud.google.com/go/artifactregistry v1.3.0 h1:kB+76CLiFcliaoEG51lxvvDvF9GkjnN0YrF8kZDh+/Q=
+cloud.google.com/go/artifactregistry v1.3.0/go.mod h1:plM9tUGHmFSJuzbaLena6C4v4QoGpRQJkXqo3W3ajYw=
 cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
 cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
 cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
 cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
 cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
 cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
+cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow=
+cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM=
+cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M=
+cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s=
+cloud.google.com/go/compute v1.6.1 h1:2sMmt8prCn7DPaG4Pmh0N3Inmc8cT8ae5k1M6VJ9Wqc=
+cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU=
+cloud.google.com/go/compute v1.7.0 h1:v/k9Eueb8aAJ0vZuxKMrgm6kPhCLZU9HxFU+AFDs9Uk=
+cloud.google.com/go/compute v1.7.0/go.mod h1:435lt8av5oL9P3fv1OEzSbSUe+ybHXGMPQHHZWZxy9U=
 cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
 cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
 cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
 cloud.google.com/go/firestore v1.6.0/go.mod h1:afJwI0vaXwAG54kI7A//lP/lSPDkQORQuMkv56TxEPU=
 cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
+cloud.google.com/go/iam v0.3.0 h1:exkAomrVUuzx9kWFI1wm3KI0uoDeUFPB4kKGzx6x+Gc=
+cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY=
 cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
 cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
 cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
@@ -55,6 +71,7 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo
 cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
 cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
 cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
+cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y=
 contrib.go.opencensus.io/exporter/stackdriver v0.13.4/go.mod h1:aXENhDJ1Y4lIg4EUaVTwzvYETVNZk10Pu26tevFKLUc=
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
 github.com/AdaLogics/go-fuzz-headers v0.0.0-20210715213245-6c3934b029d8/go.mod h1:CzsSbkDixRphAF5hS6wbMKq0eI6ccJRb7/A0M6JBnwg=
@@ -1023,16 +1040,25 @@ 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.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
+github.com/googleapis/enterprise-certificate-proxy v0.1.0 h1:zO8WHNx/MYiAKJ3d5spxZXZE6KHmIQGQcAzwUzV7qQw=
+github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
 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.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
 github.com/googleapis/gax-go/v2 v2.1.1 h1:dp3bWCh+PPO1zjRRiCSczJav13sBvG4UhNyVTa1KqdU=
 github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
+github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM=
+github.com/googleapis/gax-go/v2 v2.3.0 h1:nRJtk3y8Fm770D42QV6T90ZnvFZyk7agSo3Q+Z9p3WI=
+github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM=
+github.com/googleapis/gax-go/v2 v2.4.0 h1:dS9eYAjhrE2RjmzYw2XAPvcXfmcQLtFEQWn0CR82awk=
+github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c=
 github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
 github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg=
 github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2cUuW7uA/OeU=
 github.com/googleapis/gnostic v0.5.5 h1:9fHAtK0uDfpveeqqo1hkEZJcFvYXAiCN3UutL8F9xHw=
 github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA=
+github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
 github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ=
 github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8=
 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
@@ -2282,10 +2308,13 @@ golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qx
 golang.org/x/net v0.0.0-20220107192237-5cfca573fb4d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 h1:HVyaeDAYux4pnY+D/SiwmLOR36ewZ4iGQIIrtnuCjFA=
 golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2 h1:NWy5+hlRbC7HK+PmcXVUmW1IMyFce7to56IUvhUFm7Y=
 golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e h1:TsQ7F31D3bUCLeqPT0u+yjp1guoArKaNKmCr22PYgTQ=
 golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -2308,8 +2337,11 @@ golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ
 golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg=
 golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
+golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
 golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 h1:OSnWWcOd/CtWQC2cYSBgbTSJv3ciqd8r54ySIW2y3RE=
 golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
+golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
+golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
 golang.org/x/oauth2 v0.0.0-20220628200809-02e64fa58f26 h1:uBgVQYJLi/m8M0wzp+aGwBWt90gMRoOVf+aWTW10QHI=
 golang.org/x/oauth2 v0.0.0-20220628200809-02e64fa58f26/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -2464,15 +2496,23 @@ golang.org/x/sys v0.0.0-20211110154304-99a53858aa08/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/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-20211210111614-af8b64212486/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-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/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-20220328115105-d36c6a25d886/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/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k=
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b h1:2n253B2r0pYSmEV+UNCQoPfU/FiaizQEK5Gu4Bq4JE8=
 golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
@@ -2638,6 +2678,9 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
+golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
 gonum.org/v1/gonum v0.0.0-20190331200053-3d26580ed485/go.mod h1:2ltnJ7xHfj0zHS40VVPYEAAMTa3ZGguvHGBSJeRWqE0=
 gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
 gonum.org/v1/netlib v0.0.0-20190331212654-76723241ea4e/go.mod h1:kS+toOQn6AQKjmKJ7gzohV1XkqsFehRA2FbsbkopSuQ=
@@ -2677,6 +2720,19 @@ google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUb
 google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
 google.golang.org/api v0.62.0 h1:PhGymJMXfGBzc4lBRmrx9+1w4w2wEzURHNGF/sD/xGc=
 google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw=
+google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
+google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g=
+google.golang.org/api v0.70.0 h1:67zQnAE0T2rB0A3CwLSas0K+SbVzSxP+zTLkQLexeiw=
+google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA=
+google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8=
+google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs=
+google.golang.org/api v0.75.0 h1:0AYh/ae6l9TDUvIQrDw5QRpM100P6oHgD+o3dYHMzJg=
+google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA=
+google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw=
+google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg=
+google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o=
+google.golang.org/api v0.88.0 h1:MPwxQRqpyskYhr2iNyfsQ8R06eeyhe7UEuR30p136ZQ=
+google.golang.org/api v0.88.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -2738,6 +2794,7 @@ google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6D
 google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210329143202-679c6ae281ee/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
 google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
 google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
 google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
@@ -2763,9 +2820,28 @@ google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ6
 google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20220107163113-42d7afdf6368/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
+google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
+google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
+google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
+google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
+google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
+google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
+google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
+google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
 google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731 h1:nquqdM9+ps0JZcIiI70+tqoaIFS5Ql4ZuK8UXnz3HfE=
 google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
+google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
+google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
+google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
+google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
+google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
+google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
+google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
 google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03 h1:W70HjnmXFJm+8RNjOpIDYW2nKsSi/af0VvIZUtYkwuU=
 google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
 google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
@@ -2802,9 +2878,11 @@ google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9K
 google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
 google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
 google.golang.org/grpc v1.43.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
+google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
 google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
 google.golang.org/grpc v1.46.0 h1:oCjezcn6g6A75TGoKYBPgKmVBLexhYLM6MebdrPApP8=
 google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
+google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
 google.golang.org/grpc v1.47.0 h1:9n77onPX5F3qfFCqjy9dhn8PbNQsIKeVU04J9G7umt8=
 google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
 google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=

+ 2 - 0
internal/helm/postrenderer.go

@@ -813,6 +813,8 @@ func getRegNameFromImageRef(image string) (string, error) {
 	// if registry is dockerhub, leave the image name as-is
 	if strings.Contains(domain, "docker.io") {
 		regName = "index.docker.io/" + path
+	} else if strings.Contains(domain, "pkg.dev") {
+		regName = domain + "/" + strings.Split(path, "/")[0]
 	} else {
 		regName = domain
 

+ 5 - 1
internal/models/registry.go

@@ -45,7 +45,11 @@ func (r *Registry) ToRegistryType() *types.Registry {
 	if r.AWSIntegrationID != 0 {
 		serv = types.ECR
 	} else if r.GCPIntegrationID != 0 {
-		serv = types.GCR
+		if strings.Contains(r.URL, "pkg.dev") {
+			serv = types.GAR
+		} else {
+			serv = types.GCR
+		}
 	} else if r.DOIntegrationID != 0 {
 		serv = types.DOCR
 	} else if r.AzureIntegrationID != 0 {

+ 13 - 8
internal/models/stack.go

@@ -160,6 +160,10 @@ func (s StackResource) ToStackResource(stackID string, stackRevisionID uint, sou
 type StackSourceConfig struct {
 	gorm.Model
 
+	// A unique identifier for this source config, this will allow us identify a same source config
+	// across multiple revisions and updates. This is not the same as the UID or ID which are updated over revisions.
+	StableSourceConfigID string
+
 	StackRevisionID uint
 
 	Name string
@@ -175,14 +179,15 @@ type StackSourceConfig struct {
 
 func (s StackSourceConfig) ToStackSourceConfigType(stackID string, stackRevisionID uint) *types.StackSourceConfig {
 	return &types.StackSourceConfig{
-		CreatedAt:       s.CreatedAt,
-		UpdatedAt:       s.UpdatedAt,
-		StackID:         stackID,
-		StackRevisionID: stackRevisionID,
-		Name:            s.Name,
-		ID:              s.UID,
-		ImageRepoURI:    s.ImageRepoURI,
-		ImageTag:        s.ImageTag,
+		CreatedAt:            s.CreatedAt,
+		UpdatedAt:            s.UpdatedAt,
+		StackID:              stackID,
+		StackRevisionID:      stackRevisionID,
+		Name:                 s.Name,
+		ID:                   s.UID,
+		ImageRepoURI:         s.ImageRepoURI,
+		ImageTag:             s.ImageTag,
+		StableSourceConfigID: s.StableSourceConfigID,
 	}
 }
 

+ 232 - 1
internal/registry/registry.go

@@ -11,6 +11,7 @@ import (
 	"sync"
 	"time"
 
+	artifactregistry "cloud.google.com/go/artifactregistry/apiv1beta2"
 	"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
 	"github.com/aws/aws-sdk-go/aws/awserr"
 	"github.com/aws/aws-sdk-go/service/ecr"
@@ -18,6 +19,10 @@ import (
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/repository"
 	"golang.org/x/oauth2"
+	v1artifactregistry "google.golang.org/api/artifactregistry/v1"
+	"google.golang.org/api/iterator"
+	"google.golang.org/api/option"
+	artifactregistrypb "google.golang.org/genproto/googleapis/devtools/artifactregistry/v1beta2"
 
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 
@@ -70,7 +75,11 @@ func (r *Registry) ListRepositories(
 	}
 
 	if r.GCPIntegrationID != 0 {
-		return r.listGCRRepositories(repo)
+		if strings.Contains(r.URL, "pkg.dev") {
+			return r.listGARRepositories(repo)
+		} else {
+			return r.listGCRRepositories(repo)
+		}
 	}
 
 	if r.DOIntegrationID != 0 {
@@ -203,6 +212,103 @@ func (r *Registry) listGCRRepositories(
 	return res, nil
 }
 
+func (r *Registry) GetGARToken(repo repository.Repository) (*oauth2.Token, error) {
+	getTokenCache := r.getTokenCacheFunc(repo)
+
+	gcp, err := repo.GCPIntegration().ReadGCPIntegration(
+		r.ProjectID,
+		r.GCPIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// get oauth2 access token
+	return gcp.GetBearerToken(
+		getTokenCache,
+		r.setTokenCacheFunc(repo),
+		"https://www.googleapis.com/auth/cloud-platform",
+	)
+}
+
+type garTokenSource struct {
+	reg  *Registry
+	repo repository.Repository
+}
+
+func (source *garTokenSource) Token() (*oauth2.Token, error) {
+	return source.reg.GetGARToken(source.repo)
+}
+
+func (r *Registry) listGARRepositories(
+	repo repository.Repository,
+) ([]*ptypes.RegistryRepository, error) {
+	gcpInt, err := repo.GCPIntegration().ReadGCPIntegration(
+		r.ProjectID,
+		r.GCPIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	client, err := artifactregistry.NewClient(context.Background(), option.WithTokenSource(&garTokenSource{
+		reg:  r,
+		repo: repo,
+	}), option.WithScopes("roles/artifactregistry.reader"))
+
+	if err != nil {
+		return nil, err
+	}
+
+	var res []*ptypes.RegistryRepository
+	nextToken := ""
+
+	parsedURL, err := url.Parse("https://" + r.URL)
+
+	if err != nil {
+		return nil, err
+	}
+
+	location := strings.TrimSuffix(parsedURL.Host, "-docker.pkg.dev")
+
+	for {
+		it := client.ListRepositories(context.Background(), &artifactregistrypb.ListRepositoriesRequest{
+			Parent:    fmt.Sprintf("projects/%s/locations/%s", gcpInt.GCPProjectID, location),
+			PageSize:  1000,
+			PageToken: nextToken,
+		})
+
+		for {
+			resp, err := it.Next()
+
+			if err == iterator.Done {
+				break
+			} else if err != nil {
+				return nil, err
+			}
+
+			repoSlice := strings.Split(resp.GetName(), "/")
+			repoName := repoSlice[len(repoSlice)-1]
+
+			res = append(res, &ptypes.RegistryRepository{
+				Name:      resp.GetName(),
+				CreatedAt: resp.GetCreateTime().AsTime(),
+				URI:       parsedURL.Host + "/" + gcpInt.GCPProjectID + "/" + repoName,
+			})
+		}
+
+		if it.PageInfo().Token == "" {
+			break
+		}
+
+		nextToken = it.PageInfo().Token
+	}
+
+	return res, nil
+}
+
 func (r *Registry) listECRRepositories(repo repository.Repository) ([]*ptypes.RegistryRepository, error) {
 	aws, err := repo.AWSIntegration().ReadAWSIntegration(
 		r.ProjectID,
@@ -589,6 +695,8 @@ func (r *Registry) CreateRepository(
 	// if aws, create repository
 	if r.AWSIntegrationID != 0 {
 		return r.createECRRepository(repo, name)
+	} else if r.GCPIntegrationID != 0 && strings.Contains(r.URL, "pkg.dev") {
+		return r.createGARRepository(repo, name)
 	}
 
 	// otherwise, no-op
@@ -635,6 +743,62 @@ func (r *Registry) createECRRepository(
 	return nil
 }
 
+func (r *Registry) createGARRepository(
+	repo repository.Repository,
+	name string,
+) error {
+	gcpInt, err := repo.GCPIntegration().ReadGCPIntegration(
+		r.ProjectID,
+		r.GCPIntegrationID,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	client, err := artifactregistry.NewClient(context.Background(), option.WithTokenSource(&garTokenSource{
+		reg:  r,
+		repo: repo,
+	}), option.WithScopes("roles/artifactregistry.admin"))
+
+	if err != nil {
+		return err
+	}
+
+	defer client.Close()
+
+	parsedURL, err := url.Parse("https://" + r.URL)
+
+	if err != nil {
+		return err
+	}
+
+	location := strings.TrimSuffix(parsedURL.Host, "-docker.pkg.dev")
+
+	_, err = client.GetRepository(context.Background(), &artifactregistrypb.GetRepositoryRequest{
+		Name: fmt.Sprintf("projects/%s/locations/%s/repositories/%s", gcpInt.GCPProjectID, location, name),
+	})
+
+	if err != nil && strings.Contains(err.Error(), "not found") {
+		// create a new repository
+		_, err := client.CreateRepository(context.Background(), &artifactregistrypb.CreateRepositoryRequest{
+			Parent:       fmt.Sprintf("projects/%s/locations/%s", gcpInt.GCPProjectID, location),
+			RepositoryId: name,
+			Repository: &artifactregistrypb.Repository{
+				Format: artifactregistrypb.Repository_DOCKER,
+			},
+		})
+
+		if err != nil {
+			return err
+		}
+	} else if err != nil {
+		return err
+	}
+
+	return nil
+}
+
 // ListImages lists the images for an image repository
 func (r *Registry) ListImages(
 	repoName string,
@@ -651,6 +815,10 @@ func (r *Registry) ListImages(
 	}
 
 	if r.GCPIntegrationID != 0 {
+		if strings.Contains(r.URL, "pkg.dev") {
+			return r.listGARImages(repoName, repo)
+		}
+
 		return r.listGCRImages(repoName, repo)
 	}
 
@@ -1003,6 +1171,69 @@ func (r *Registry) listGCRImages(repoName string, repo repository.Repository) ([
 	return res, nil
 }
 
+func (r *Registry) listGARImages(repoName string, repo repository.Repository) ([]*ptypes.Image, error) {
+	gcpInt, err := repo.GCPIntegration().ReadGCPIntegration(
+		r.ProjectID,
+		r.GCPIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	svc, err := v1artifactregistry.NewService(context.Background(), option.WithTokenSource(&garTokenSource{
+		reg:  r,
+		repo: repo,
+	}), option.WithScopes("roles/artifactregistry.reader"))
+
+	if err != nil {
+		return nil, err
+	}
+
+	nextToken := ""
+	var res []*ptypes.Image
+
+	parsedURL, err := url.Parse("https://" + r.URL)
+
+	if err != nil {
+		return nil, err
+	}
+
+	location := strings.TrimSuffix(parsedURL.Host, "-docker.pkg.dev")
+
+	dockerSvc := v1artifactregistry.NewProjectsLocationsRepositoriesDockerImagesService(svc)
+
+	for {
+		resp, err := dockerSvc.List(fmt.Sprintf("projects/%s/locations/%s/repositories/%s",
+			gcpInt.GCPProjectID, location, repoName)).PageSize(1000).PageToken(nextToken).Do()
+
+		if err != nil {
+			return nil, err
+		}
+
+		for _, image := range resp.DockerImages {
+			uploadTime, _ := time.Parse(time.RFC3339, image.UploadTime)
+
+			for _, tag := range image.Tags {
+				res = append(res, &ptypes.Image{
+					RepositoryName: repoName,
+					Tag:            tag,
+					PushedAt:       &uploadTime,
+					Digest:         strings.Split(image.Name, "@")[1],
+				})
+			}
+		}
+
+		if resp.NextPageToken == "" {
+			break
+		}
+
+		nextToken = resp.NextPageToken
+	}
+
+	return res, nil
+}
+
 func (r *Registry) listDOCRImages(
 	repoName string,
 	repo repository.Repository,

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

@@ -118,6 +118,14 @@ func (repo *StackRepository) DeleteStack(stack *models.Stack) (*models.Stack, er
 	return stack, nil
 }
 
+func (repo *StackRepository) UpdateStack(stack *models.Stack) (*models.Stack, error) {
+	if err := repo.db.Save(stack).Error; err != nil {
+		return nil, err
+	}
+
+	return stack, nil
+}
+
 func (repo *StackRepository) UpdateStackRevision(revision *models.StackRevision) (*models.StackRevision, error) {
 	if err := repo.db.Save(revision).Error; err != nil {
 		return nil, err

+ 1 - 0
internal/repository/stack.go

@@ -10,6 +10,7 @@ type StackRepository interface {
 	ListStacks(projectID uint, clusterID uint, namespace string) ([]*models.Stack, error)
 	DeleteStack(stack *models.Stack) (*models.Stack, error)
 
+	UpdateStack(stack *models.Stack) (*models.Stack, error)
 	UpdateStackRevision(revision *models.StackRevision) (*models.StackRevision, error)
 	ReadStackRevision(stackRevisionID uint) (*models.StackRevision, error)
 	ReadStackRevisionByNumber(stackID uint, revisionNumber uint) (*models.StackRevision, error)

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

@@ -35,6 +35,10 @@ func (repo *StackRepository) DeleteStack(stack *models.Stack) (*models.Stack, er
 	panic("unimplemented")
 }
 
+func (repo *StackRepository) UpdateStack(stack *models.Stack) (*models.Stack, error) {
+	panic("unimplemented")
+}
+
 func (repo *StackRepository) UpdateStackRevision(revision *models.StackRevision) (*models.StackRevision, error) {
 	panic("unimplemented")
 }

+ 1 - 1
internal/stacks/helpers.go

@@ -52,7 +52,7 @@ func CloneAppResources(
 			if prevSourceConfig.UID == appResource.StackSourceConfigUID {
 				// find the corresponding new source config
 				for _, newSourceConfig := range newSourceConfigs {
-					if newSourceConfig.Name == prevSourceConfig.Name {
+					if newSourceConfig.StableSourceConfigID == prevSourceConfig.StableSourceConfigID {
 						linkedSourceConfigUID = newSourceConfig.UID
 					}
 				}

+ 1 - 1
provisioner/server/handlers/provision/apply.go

@@ -153,7 +153,7 @@ func (c *ProvisionApplyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 				InfraID:                infra.ID,
 			},
 		))
-	case types.InfraDOCR, types.InfraECR, types.InfraGCR, types.InfraACR:
+	case types.InfraDOCR, types.InfraECR, types.InfraGCR, types.InfraGAR, types.InfraACR:
 		c.Config.AnalyticsClient.Track(analytics.RegistryProvisioningStartTrack(
 			&analytics.RegistryProvisioningStartTrackOpts{
 				ProjectScopedTrackOpts: analytics.GetProjectScopedTrackOpts(0, infra.ProjectID),

+ 17 - 2
provisioner/server/handlers/state/create_resource.go

@@ -110,6 +110,8 @@ func (c *CreateResourceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		_, err = createDOCRRegistry(c.Config, infra, operation, req.Output)
 	case string(types.InfraGCR):
 		_, err = createGCRRegistry(c.Config, infra, operation, req.Output)
+	case string(types.InfraGAR):
+		_, err = createGARRegistry(c.Config, infra, operation, req.Output)
 	case string(types.InfraACR):
 		_, err = createACRRegistry(c.Config, infra, operation, req.Output)
 	}
@@ -301,8 +303,9 @@ func createCluster(config *config.Config, infra *models.Infra, operation *models
 
 func getNewCluster(infra *models.Infra) *models.Cluster {
 	res := &models.Cluster{
-		ProjectID: infra.ProjectID,
-		InfraID:   infra.ID,
+		ProjectID:           infra.ProjectID,
+		InfraID:             infra.ID,
+		MonitorHelmReleases: true,
 	}
 
 	switch infra.Kind {
@@ -367,6 +370,18 @@ func createGCRRegistry(config *config.Config, infra *models.Infra, operation *mo
 	return config.Repo.Registry().CreateRegistry(reg)
 }
 
+func createGARRegistry(config *config.Config, infra *models.Infra, operation *models.Operation, output map[string]interface{}) (*models.Registry, error) {
+	reg := &models.Registry{
+		ProjectID:        infra.ProjectID,
+		GCPIntegrationID: infra.GCPIntegrationID,
+		InfraID:          infra.ID,
+		URL:              output["url"].(string),
+		Name:             "gar-registry",
+	}
+
+	return config.Repo.Registry().CreateRegistry(reg)
+}
+
 func createACRRegistry(config *config.Config, infra *models.Infra, operation *models.Operation, output map[string]interface{}) (*models.Registry, error) {
 	reg := &models.Registry{
 		ProjectID:          infra.ProjectID,

+ 1 - 1
provisioner/server/handlers/state/delete_resource.go

@@ -69,7 +69,7 @@ func (c *DeleteResourceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 
 	// switch on the kind of resource and write the corresponding objects to the database
 	switch infra.Kind {
-	case types.InfraECR, types.InfraGCR, types.InfraDOCR, types.InfraACR:
+	case types.InfraECR, types.InfraGCR, types.InfraGAR, types.InfraDOCR, types.InfraACR:
 		_, err = deleteRegistry(c.Config, infra, operation)
 	case types.InfraEKS, types.InfraDOKS, types.InfraGKE, types.InfraAKS:
 		_, err = deleteCluster(c.Config, infra, operation)

+ 4 - 3
workers/jobs/helm_revisions_count_tracker.go

@@ -31,6 +31,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/pkg/logger"
 	"github.com/porter-dev/porter/provisioner/integrations/storage/s3"
+	"github.com/porter-dev/porter/workers/utils"
 
 	"github.com/porter-dev/porter/ee/integrations/vault"
 	"github.com/porter-dev/porter/internal/helm"
@@ -191,13 +192,13 @@ func (t *helmRevisionsCountTracker) Run() error {
 				log.Printf("fetched %d namespaces for cluster ID %d", len(namespaces.Items), cluster.ID)
 
 				for _, ns := range namespaces.Items {
-					agent, err := helm.GetAgentOutOfClusterConfig(&helm.Form{
+					agent, err := utils.NewRetryHelmAgent(&helm.Form{
 						Cluster:                   cluster,
 						Namespace:                 ns.Name,
 						Repo:                      t.repo,
 						DigitalOceanOAuth:         t.doConf,
 						AllowInClusterConnections: false,
-					}, logger.New(true, os.Stdout))
+					}, logger.New(true, os.Stdout), 3, time.Second)
 
 					if err != nil {
 						log.Printf("error fetching helm client for namespace %s in cluster ID %d: %v. "+
@@ -219,7 +220,7 @@ func (t *helmRevisionsCountTracker) Run() error {
 
 					if err != nil {
 						log.Printf("error fetching releases for namespace %s in cluster ID %d: %v. skipping namespace ...",
-							len(releases), ns.Name, cluster.ID, err)
+							ns.Name, cluster.ID, err)
 						continue
 					}
 

+ 118 - 0
workers/utils/retry_helm_agent.go

@@ -0,0 +1,118 @@
+//go:build ee
+
+package utils
+
+import (
+	"fmt"
+	"log"
+	"os"
+	"time"
+
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/pkg/logger"
+	"helm.sh/helm/v3/pkg/release"
+)
+
+type RetryHelmAgent struct {
+	form          *helm.Form
+	l             *logger.Logger
+	agent         *helm.Agent
+	retryCount    uint
+	retryInterval time.Duration
+}
+
+func NewRetryHelmAgent(
+	form *helm.Form,
+	l *logger.Logger,
+	retryCount uint,
+	retryInterval time.Duration,
+) (*RetryHelmAgent, error) {
+	if l == nil {
+		l = logger.New(true, os.Stdout)
+	}
+
+	helmAgent, err := helm.GetAgentOutOfClusterConfig(form, l)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &RetryHelmAgent{
+		form, l, helmAgent, retryCount, retryInterval,
+	}, nil
+}
+
+func (a *RetryHelmAgent) ListReleases(
+	namespace string,
+	filter *types.ReleaseListFilter,
+) ([]*release.Release, error) {
+	for i := uint(0); i < a.retryCount; i++ {
+		releases, err := a.agent.ListReleases(namespace, filter)
+
+		if err == nil {
+			return releases, nil
+		} else {
+			log.Printf("recreating helm agent for retrying ListReleases. Error: %v", err)
+
+			a.agent, err = helm.GetAgentOutOfClusterConfig(a.form, a.l)
+
+			if err != nil {
+				return nil, fmt.Errorf("error recreating helm agent for retrying ListReleases: %w", err)
+			}
+		}
+
+		time.Sleep(a.retryInterval)
+	}
+
+	return nil, fmt.Errorf("maxiumum number of retries (%d) reached for ListReleases", a.retryCount)
+}
+
+func (a *RetryHelmAgent) GetReleaseHistory(
+	name string,
+) ([]*release.Release, error) {
+	for i := uint(0); i < a.retryCount; i++ {
+		releases, err := a.agent.GetReleaseHistory(name)
+
+		if err == nil {
+			return releases, nil
+		} else {
+			log.Printf("recreating helm agent for retrying GetReleaseHistory. Error: %v", err)
+
+			a.agent, err = helm.GetAgentOutOfClusterConfig(a.form, a.l)
+
+			if err != nil {
+				return nil, fmt.Errorf("error recreating helm agent for retrying GetReleaseHistory: %w", err)
+			}
+		}
+
+		time.Sleep(a.retryInterval)
+	}
+
+	return nil, fmt.Errorf("maxiumum number of retries (%d) reached for GetReleaseHistory", a.retryCount)
+}
+
+func (a *RetryHelmAgent) DeleteReleaseRevision(
+	name string,
+	version int,
+) error {
+	for i := uint(0); i < a.retryCount; i++ {
+		err := a.agent.DeleteReleaseRevision(name, version)
+
+		if err == nil {
+			return nil
+		} else {
+			log.Printf("recreating helm agent for retrying DeleteReleaseRevision. Error: %v", err)
+
+			a.agent, err = helm.GetAgentOutOfClusterConfig(a.form, a.l)
+
+			if err != nil {
+				return fmt.Errorf("error recreating helm agent for retrying DeleteReleaseRevision: %w", err)
+			}
+		}
+
+		time.Sleep(a.retryInterval)
+	}
+
+	return fmt.Errorf("maxiumum number of retries (%d) reached for DeleteReleaseRevision", a.retryCount)
+}