Browse Source

Merge branch 'nafees/gcp-gar' of github.com:porter-dev/porter into nafees/gcp-gar

jnfrati 3 years ago
parent
commit
7c01418c17
49 changed files with 2686 additions and 713 deletions
  1. 20 0
      api/client/registry.go
  2. 10 39
      api/server/handlers/environment/finalize_deployment.go
  3. 17 0
      api/server/handlers/infra/forms.go
  4. 1 1
      api/server/handlers/registry/create.go
  5. 63 0
      api/server/handlers/registry/get_token.go
  6. 192 0
      api/server/handlers/stack/add_application.go
  7. 152 0
      api/server/handlers/stack/add_env_group.go
  8. 16 7
      api/server/handlers/stack/create.go
  9. 132 0
      api/server/handlers/stack/remove_application.go
  10. 132 0
      api/server/handlers/stack/remove_env_group.go
  11. 28 0
      api/server/router/project.go
  12. 283 1
      api/server/router/v1/stack.go
  13. 5 1
      api/types/registry.go
  14. 5 0
      cli/cmd/deploy/create.go
  15. 51 1
      cli/cmd/docker/auth.go
  16. 1 0
      cli/cmd/preview/env_group_driver.go
  17. 44 40
      dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx
  18. 25 10
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  19. 7 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx
  20. 3 18
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  21. 5 56
      dashboard/src/main/home/cluster-dashboard/stacks/Dashboard.tsx
  22. 29 41
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx
  23. 160 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/_Settings.tsx
  24. 157 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/_TemplateSelector.tsx
  25. 27 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/index.tsx
  26. 66 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewEnvGroup.tsx
  27. 99 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/Store.tsx
  28. 22 1
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/_RevisionList.tsx
  29. 31 27
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/EnvGroups.tsx
  30. 63 0
      dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/routes.tsx
  31. 312 0
      dashboard/src/main/home/cluster-dashboard/stacks/components/NewAppResourceForm.tsx
  32. 165 0
      dashboard/src/main/home/cluster-dashboard/stacks/components/NewEnvGroupForm.tsx
  33. 54 0
      dashboard/src/main/home/cluster-dashboard/stacks/components/styles.ts
  34. 20 250
      dashboard/src/main/home/cluster-dashboard/stacks/launch/NewApp.tsx
  35. 16 142
      dashboard/src/main/home/cluster-dashboard/stacks/launch/NewEnvGroup.tsx
  36. 1 0
      dashboard/src/main/home/cluster-dashboard/stacks/launch/components/styles.tsx
  37. 4 10
      dashboard/src/main/home/cluster-dashboard/stacks/routes.tsx
  38. 15 8
      dashboard/src/main/home/cluster-dashboard/stacks/types.ts
  39. 62 0
      dashboard/src/shared/api.tsx
  40. 5 0
      dashboard/src/shared/common.tsx
  41. 29 10
      dashboard/src/shared/hooks/useChart.ts
  42. 5 4
      go.mod
  43. 34 0
      go.sum
  44. 2 0
      internal/helm/postrenderer.go
  45. 5 5
      internal/kubernetes/prometheus/metrics.go
  46. 89 29
      internal/registry/registry.go
  47. 6 1
      provisioner/server/handlers/state/create_resource.go
  48. 1 7
      workers/jobs/helm_revisions_count_tracker.go
  49. 15 2
      workers/main.go

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

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

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

@@ -599,6 +599,14 @@ tabs:
       variable: additional_private_subnets
       settings:
         default: false
+  - name: subnet_multiplicity
+    show_if: additional_private_subnets
+    contents:
+    - type: number-input
+      label: "Multiplicity of the subnet within each AZ."
+      variable: additional_private_subnets_multiplicity
+      settings:
+        default: 3
   - name: nginx_settings
     contents:
     - type: heading
@@ -608,6 +616,15 @@ tabs:
       label: Disable NGINX load balancer and expose NGINX only on a cluster IP address.
       settings:
         default: false
+  - name: prometheus_settings
+    contents:
+    - type: heading
+      label: Prometheus Settings
+    - type: checkbox
+      variable: additional_prometheus_node_group
+      label: Add an additional prometheus node group to ensure monitoring stability.
+      settings:
+        default: false
 `
 
 const gcrForm = `name: GCR

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

+ 192 - 0
api/server/handlers/stack/add_application.go

@@ -0,0 +1,192 @@
+package stack
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/handlers/release"
+	"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"
+	"github.com/porter-dev/porter/internal/stacks"
+	helmrelease "helm.sh/helm/v3/pkg/release"
+)
+
+type StackAddApplicationHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackAddApplicationHandler(
+	config *config.Config,
+	reader shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StackAddApplicationHandler {
+	return &StackAddApplicationHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, reader, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackAddApplicationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	namespace, _ := r.Context().Value(types.NamespaceScope).(string)
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+
+	req := &types.CreateStackAppResourceRequest{}
+
+	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
+	}
+
+	latestRevision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, stack.Revisions[0].RevisionNumber)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newSourceConfigs, err := stacks.CloneSourceConfigs(latestRevision.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	appResources, err := stacks.CloneAppResources(latestRevision.Resources, latestRevision.SourceConfigs, newSourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newResources, err := getResourceModels([]*types.CreateStackAppResourceRequest{req}, newSourceConfigs, p.Config().ServerConf.DefaultApplicationHelmRepoURL)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	appResources = append(appResources, newResources...)
+
+	envGroups, err := stacks.CloneEnvGroups(latestRevision.EnvGroups)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newRevision := &models.StackRevision{
+		StackID:        stack.ID,
+		RevisionNumber: latestRevision.RevisionNumber + 1,
+		Status:         string(types.StackRevisionStatusDeploying),
+		SourceConfigs:  newSourceConfigs,
+		Resources:      appResources,
+		EnvGroups:      envGroups,
+	}
+
+	revision, err := p.Repo().Stack().AppendNewRevision(newRevision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	registries, err := p.Repo().Registry().ListRegistriesByProjectID(cluster.ProjectID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	helmAgent, err := p.GetHelmAgent(r, cluster, "")
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	helmReleaseMap := make(map[string]*helmrelease.Release)
+
+	deployErrs := make([]string, 0)
+
+	for _, appResource := range newResources {
+		rel, err := applyAppResource(&applyAppResourceOpts{
+			config:     p.Config(),
+			projectID:  proj.ID,
+			namespace:  namespace,
+			cluster:    cluster,
+			registries: registries,
+			helmAgent:  helmAgent,
+			request:    req,
+		})
+
+		if err != nil {
+			deployErrs = append(deployErrs, err.Error())
+		} else {
+			helmReleaseMap[fmt.Sprintf("%s/%s", namespace, appResource.Name)] = rel
+		}
+	}
+
+	// update stack revision status
+	if len(deployErrs) > 0 {
+		revision.Status = string(types.StackRevisionStatusFailed)
+		revision.Reason = "DeployError"
+		revision.Message = strings.Join(deployErrs, " , ")
+	} else {
+		revision.Status = string(types.StackRevisionStatusDeployed)
+	}
+
+	revision, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	saveErrs := make([]string, 0)
+
+	for _, resource := range revision.Resources {
+		if rel, exists := helmReleaseMap[fmt.Sprintf("%s/%s", namespace, resource.Name)]; exists {
+			_, err = release.CreateAppReleaseFromHelmRelease(p.Config(), proj.ID, cluster.ID, resource.ID, rel)
+
+			if err != nil {
+				saveErrs = append(saveErrs, fmt.Sprintf("the resource %s/%s could not be saved right now", namespace, resource.Name))
+			}
+		}
+	}
+
+	if len(saveErrs) > 0 {
+		revision.Reason = "SaveError"
+		revision.Message = strings.Join(saveErrs, " , ")
+
+		_, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	} else {
+		revision.Reason = "AddAppSuccess"
+		revision.Message = "New application " + req.Name + " added successfully."
+
+		_, err = p.Repo().Stack().UpdateStackRevision(revision)
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+}

+ 152 - 0
api/server/handlers/stack/add_env_group.go

@@ -0,0 +1,152 @@
+package stack
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"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/kubernetes/envgroup"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
+)
+
+type StackAddEnvGroupHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackAddEnvGroupHandler(
+	config *config.Config,
+	reader shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *StackAddEnvGroupHandler {
+	return &StackAddEnvGroupHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, reader, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackAddEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	namespace, _ := r.Context().Value(types.NamespaceScope).(string)
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+
+	req := &types.CreateStackEnvGroupRequest{}
+
+	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
+	}
+
+	latestRevision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, stack.Revisions[0].RevisionNumber)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newSourceConfigs, err := stacks.CloneSourceConfigs(latestRevision.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	appResources, err := stacks.CloneAppResources(latestRevision.Resources, latestRevision.SourceConfigs, newSourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroups, err := stacks.CloneEnvGroups(latestRevision.EnvGroups)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newEnvGroups, err := getEnvGroupModels([]*types.CreateStackEnvGroupRequest{req}, proj.ID, cluster.ID, namespace)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroups = append(envGroups, newEnvGroups...)
+
+	newRevision := &models.StackRevision{
+		StackID:        stack.ID,
+		RevisionNumber: latestRevision.RevisionNumber + 1,
+		Status:         string(types.StackRevisionStatusDeployed),
+		SourceConfigs:  newSourceConfigs,
+		Resources:      appResources,
+		EnvGroups:      envGroups,
+	}
+
+	revision, err := p.Repo().Stack().AppendNewRevision(newRevision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	k8sAgent, err := p.GetAgent(r, cluster, "")
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroupDeployErrors := make([]string, 0)
+
+	cm, err := envgroup.CreateEnvGroup(k8sAgent, types.ConfigMapInput{
+		Name:            req.Name,
+		Namespace:       namespace,
+		Variables:       req.Variables,
+		SecretVariables: req.SecretVariables,
+	})
+
+	if err != nil {
+		envGroupDeployErrors = append(envGroupDeployErrors, fmt.Sprintf("error creating env group %s", req.Name))
+	}
+
+	// add each of the linked applications to the env group
+	for _, appName := range req.LinkedApplications {
+		cm, err = k8sAgent.AddApplicationToVersionedConfigMap(cm, appName)
+
+		if err != nil {
+			envGroupDeployErrors = append(envGroupDeployErrors, fmt.Sprintf("error creating env group %s", req.Name))
+		}
+	}
+
+	if len(envGroupDeployErrors) > 0 {
+		revision.Status = string(types.StackRevisionStatusFailed)
+		revision.Reason = "EnvGroupDeployErr"
+		revision.Message = strings.Join(envGroupDeployErrors, " , ")
+	} else {
+		revision.Status = string(types.StackRevisionStatusDeployed)
+		revision.Reason = "AddEnvGroupSuccess"
+		revision.Message = "Env Group " + req.Name + " added successfully."
+	}
+
+	_, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 16 - 7
api/server/handlers/stack/create.go

@@ -46,13 +46,6 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	// populate fields with defaults
-	for i, reqResource := range req.AppResources {
-		if reqResource.TemplateRepoURL == "" {
-			req.AppResources[i].TemplateRepoURL = p.Config().ServerConf.DefaultApplicationHelmRepoURL
-		}
-	}
-
 	uid, err := encryption.GenerateRandomBytes(16)
 
 	if err != nil {
@@ -231,6 +224,18 @@ func (p *StackCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
+	if revision.Status != string(types.StackRevisionStatusFailed) && len(revision.Reason) == 0 {
+		revision.Reason = "CreationSuccess"
+		revision.Message = "Stack deployed successfully"
+
+		revision, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+		if err != nil {
+			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
 	// read the stack again to get the latest revision info
 	stack, err = p.Repo().Stack().ReadStackByStringID(proj.ID, stack.UID)
 
@@ -272,6 +277,10 @@ func getResourceModels(appResources []*types.CreateStackAppResourceRequest, sour
 	res := make([]models.StackResource, 0)
 
 	for _, appResource := range appResources {
+		if appResource.TemplateRepoURL == "" {
+			appResource.TemplateRepoURL = defaultRepoURL
+		}
+
 		uid, err := encryption.GenerateRandomBytes(16)
 
 		if err != nil {

+ 132 - 0
api/server/handlers/stack/remove_application.go

@@ -0,0 +1,132 @@
+package stack
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"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/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
+)
+
+type StackRemoveApplicationHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackRemoveApplicationHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *StackRemoveApplicationHandler {
+	return &StackRemoveApplicationHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackRemoveApplicationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	namespace, _ := r.Context().Value(types.NamespaceScope).(string)
+
+	appResourceName, reqErr := requestutils.GetURLParamString(r, "app_resource_name")
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	if len(stack.Revisions) == 0 {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("no stack revisions exist"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	revision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, stack.Revisions[0].RevisionNumber)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newSourceConfigs, err := stacks.CloneSourceConfigs(revision.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	appResources, err := stacks.CloneAppResources(revision.Resources, revision.SourceConfigs, newSourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroups, err := stacks.CloneEnvGroups(revision.EnvGroups)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var newResources []models.StackResource
+
+	for _, res := range appResources {
+		if res.Name != appResourceName {
+			newResources = append(newResources, res)
+		}
+	}
+
+	newRevision := &models.StackRevision{
+		StackID:        stack.ID,
+		RevisionNumber: revision.RevisionNumber + 1,
+		Status:         string(types.StackRevisionStatusDeploying),
+		SourceConfigs:  newSourceConfigs,
+		Resources:      newResources,
+		EnvGroups:      envGroups,
+	}
+
+	revision, err = p.Repo().Stack().AppendNewRevision(newRevision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	helmAgent, err := p.GetHelmAgent(r, cluster, namespace)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	err = deleteAppResource(&deleteAppResourceOpts{
+		helmAgent: helmAgent,
+		name:      appResourceName,
+	})
+
+	if err == nil {
+		revision.Status = string(types.StackRevisionStatusDeployed)
+		revision.Reason = "RemoveAppSuccess"
+		revision.Message = "Application " + appResourceName + " removed successfully"
+	} else {
+		revision.Status = string(types.StackRevisionStatusFailed)
+		revision.Reason = "RemoveAppError"
+		revision.Message = err.Error()
+	}
+
+	_, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 132 - 0
api/server/handlers/stack/remove_env_group.go

@@ -0,0 +1,132 @@
+package stack
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"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/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes/envgroup"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/stacks"
+)
+
+type StackRemoveEnvGroupHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewStackRemoveEnvGroupHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *StackRemoveEnvGroupHandler {
+	return &StackRemoveEnvGroupHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (p *StackRemoveEnvGroupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	stack, _ := r.Context().Value(types.StackScope).(*models.Stack)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	envGroupName, reqErr := requestutils.GetURLParamString(r, "env_group_name")
+
+	if reqErr != nil {
+		p.HandleAPIError(w, r, reqErr)
+		return
+	}
+
+	if len(stack.Revisions) == 0 {
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("no stack revisions exist"), http.StatusBadRequest,
+		))
+		return
+	}
+
+	revision, err := p.Repo().Stack().ReadStackRevisionByNumber(stack.ID, stack.Revisions[0].RevisionNumber)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	newSourceConfigs, err := stacks.CloneSourceConfigs(revision.SourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	appResources, err := stacks.CloneAppResources(revision.Resources, revision.SourceConfigs, newSourceConfigs)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	envGroups, err := stacks.CloneEnvGroups(revision.EnvGroups)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var newEnvGroups []models.StackEnvGroup
+	var envGroupNS string
+
+	for _, envGroup := range envGroups {
+		if envGroup.Name != envGroupName {
+			newEnvGroups = append(newEnvGroups, envGroup)
+		} else {
+			envGroupNS = envGroup.Namespace
+		}
+	}
+
+	newRevision := &models.StackRevision{
+		StackID:        stack.ID,
+		RevisionNumber: revision.RevisionNumber + 1,
+		Status:         string(types.StackRevisionStatusDeploying),
+		SourceConfigs:  newSourceConfigs,
+		Resources:      appResources,
+		EnvGroups:      newEnvGroups,
+	}
+
+	revision, err = p.Repo().Stack().AppendNewRevision(newRevision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	k8sAgent, err := p.GetAgent(r, cluster, "")
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	err = envgroup.DeleteEnvGroup(k8sAgent, envGroupName, envGroupNS)
+
+	if err == nil {
+		revision.Status = string(types.StackRevisionStatusDeployed)
+		revision.Reason = "RemoveEnvGroupSuccess"
+		revision.Message = "EnvGroup " + envGroupName + " removed successfully"
+	} else {
+		revision.Status = string(types.StackRevisionStatusFailed)
+		revision.Reason = "RemoveEnvGroupError"
+		revision.Message = err.Error()
+	}
+
+	_, err = p.Repo().Stack().UpdateStackRevision(revision)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

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

+ 283 - 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
+// swagger:parameters getStack deleteStack putStackSource rollbackStack listStackRevisions addApplication addEnvGroup
 type stackPathParams struct {
 	// The project id
 	// in: path
@@ -65,6 +65,66 @@ type stackRevisionPathParams struct {
 	RevisionID string `json:"revision_id"`
 }
 
+// swagger:parameters removeApplication
+type stackRemoveApplicationPathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+
+	// The cluster id
+	// in: path
+	// required: true
+	// minimum: 1
+	ClusterID uint `json:"cluster_id"`
+
+	// The namespace
+	// in: path
+	// required: true
+	Namespace string `json:"namespace"`
+
+	// The stack id
+	// in: path
+	// required: true
+	StackID string `json:"stack_id"`
+
+	// The name of the application
+	// in: path
+	// required: true
+	AppResourceName string `json:"app_resource_name"`
+}
+
+// swagger:parameters removeEnvGroup
+type stackRemoveEnvGroupPathParams struct {
+	// The project id
+	// in: path
+	// required: true
+	// minimum: 1
+	ProjectID uint `json:"project_id"`
+
+	// The cluster id
+	// in: path
+	// required: true
+	// minimum: 1
+	ClusterID uint `json:"cluster_id"`
+
+	// The namespace
+	// in: path
+	// required: true
+	Namespace string `json:"namespace"`
+
+	// The stack id
+	// in: path
+	// required: true
+	StackID string `json:"stack_id"`
+
+	// The name of the environment group
+	// in: path
+	// required: true
+	EnvGroupName string `json:"env_group_name"`
+}
+
 func NewV1StackScopedRegisterer(children ...*router.Registerer) *router.Registerer {
 	return &router.Registerer{
 		GetRoutes: GetV1StackScopedRoutes,
@@ -538,5 +598,227 @@ func getV1StackRoutes(
 		Router:   r,
 	})
 
+	// PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/add_application -> stack.NewStackAddApplicationHandler
+	// swagger:operation PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/add_application addApplication
+	//
+	// Adds an application to an existing stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Add an application to a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - in: body
+	//     name: AddApplicationToStack
+	//     description: The application to add
+	//     schema:
+	//       $ref: '#/definitions/CreateStackAppResourceRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully added the application to the stack
+	//   '400':
+	//     description: Stack does not have any revisions
+	//   '403':
+	//     description: Forbidden
+	addApplicationEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPatch,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/add_application",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	addApplicationHandler := stack.NewStackAddApplicationHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: addApplicationEndpoint,
+		Handler:  addApplicationHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/remove_application/{app_resource_name} -> stack.NewStackRemoveApplicationHandler
+	// swagger:operation DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/remove_application/{app_resource_name} removeApplication
+	//
+	// Removes an existing application from a stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Remove an application from a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - name: app_resource_name
+	// responses:
+	//   '200':
+	//     description: Successfully deleted the application from the stack
+	//   '400':
+	//     description: Stack does not have any revisions
+	//   '403':
+	//     description: Forbidden
+	removeApplicationEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/remove_application/{app_resource_name}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	removeApplicationHandler := stack.NewStackRemoveApplicationHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: removeApplicationEndpoint,
+		Handler:  removeApplicationHandler,
+		Router:   r,
+	})
+
+	// PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/add_env_group -> stack.NewStackAddEnvGroupHandler
+	// swagger:operation PATCH /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/add_env_group addEnvGroup
+	//
+	// Adds an environment group to an existing stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Add an environment group to a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - in: body
+	//     name: AddEnvGroupToStack
+	//     description: The environment group to add
+	//     schema:
+	//       $ref: '#/definitions/CreateStackEnvGroupRequest'
+	// responses:
+	//   '200':
+	//     description: Successfully added the environment group to the stack
+	//   '400':
+	//     description: Stack does not have any revisions
+	//   '403':
+	//     description: Forbidden
+	addEnvGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPatch,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/add_env_group",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	addEnvGroupHandler := stack.NewStackAddEnvGroupHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: addEnvGroupEndpoint,
+		Handler:  addEnvGroupHandler,
+		Router:   r,
+	})
+
+	// DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/remove_env_group/{env_group_name} -> stack.NewStackRemoveEnvGroupHandler
+	// swagger:operation DELETE /api/v1/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/stacks/{stack_id}/remove_env_group/{env_group_name} removeEnvGroup
+	//
+	// Removes an existing environment group from a stack
+	//
+	// ---
+	// produces:
+	// - application/json
+	// summary: Remove an environment group from a stack
+	// tags:
+	// - Stacks
+	// parameters:
+	//   - name: project_id
+	//   - name: cluster_id
+	//   - name: namespace
+	//   - name: stack_id
+	//   - name: env_group_name
+	// responses:
+	//   '200':
+	//     description: Successfully deleted the environment group from the stack
+	//   '400':
+	//     description: Stack does not have any revisions
+	//   '403':
+	//     description: Forbidden
+	removeEnvGroupEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbDelete,
+			Method: types.HTTPVerbDelete,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/{stack_id}/remove_env_group/{env_group_name}",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+				types.StackScope,
+			},
+		},
+	)
+
+	removeEnvGroupHandler := stack.NewStackRemoveEnvGroupHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: removeEnvGroupEndpoint,
+		Handler:  removeEnvGroupHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 5 - 1
api/types/registry.go

@@ -160,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"`
 }
@@ -180,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"`

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

+ 51 - 1
cli/cmd/docker/auth.go

@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"io/ioutil"
+	"net/url"
 	"os"
 	"path/filepath"
 	"regexp"
@@ -49,8 +50,10 @@ type AuthGetter struct {
 }
 
 func (a *AuthGetter) GetCredentials(serverURL string) (user string, secret string, err error) {
-	if strings.Contains(serverURL, "gcr.io") || strings.Contains(serverURL, "pkg.dev") {
+	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)
 

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

@@ -92,6 +92,7 @@ func (d *EnvGroupDriver) Apply(resource *models.Resource) (*models.Resource, err
 
 			envGroupResp = &types.GetEnvGroupResponse{
 				EnvGroup: &types.EnvGroup{
+					Name:      newEnvGroup.Name,
 					Variables: newEnvGroup.Variables,
 				},
 			}

+ 44 - 40
dashboard/src/main/home/cluster-dashboard/env-groups/ExpandedEnvGroup.tsx

@@ -161,23 +161,41 @@ export const ExpandedEnvGroupFC = ({
     });
   };
 
-  const handleDeleteEnvGroup = () => {
-    const { name } = currentEnvGroup;
+  const deleteEnvGroup = async () => {
+    const { name, stack_id } = currentEnvGroup;
 
-    setIsDeleting(true);
-    setCurrentOverlay(null);
-    api
-      .deleteEnvGroup(
-        "<token>",
+    if (stack_id?.length) {
+      return api.removeStackEnvGroup(
+        "<stack>",
+        {},
         {
-          name,
-        },
-        {
-          id: currentProject.id,
+          project_id: currentProject.id,
           cluster_id: currentCluster.id,
           namespace,
+          stack_id: stack_id,
+          env_group_name: name,
         }
-      )
+      );
+    }
+
+    return api.deleteEnvGroup(
+      "<token>",
+      {
+        name,
+      },
+      {
+        id: currentProject.id,
+        cluster_id: currentCluster.id,
+        namespace,
+      }
+    );
+  };
+
+  const handleDeleteEnvGroup = () => {
+    setIsDeleting(true);
+    setCurrentOverlay(null);
+
+    deleteEnvGroup()
       .then(() => {
         closeExpanded();
         setIsDeleting(true);
@@ -551,34 +569,20 @@ const EnvGroupSettings = ({
               applications to delete.
             </Helper>
           )}
-          {envGroup.stack_id?.length ? (
-            <>
-              <Helper color="#f5cb42">
-                You have to delete the stack to remove this env group.
-              </Helper>
-              <CloneButton
-                as={DynamicLink}
-                color="#5561C0"
-                to={`/stacks/${envGroup.namespace}/${envGroup.stack_id}`}
-              >
-                Go to the stack
-              </CloneButton>
-            </>
-          ) : (
-            <Button
-              color="#b91133"
-              onClick={() => {
-                setCurrentOverlay({
-                  message: `Are you sure you want to delete ${envGroup.name}?`,
-                  onYes: handleDeleteEnvGroup,
-                  onNo: () => setCurrentOverlay(null),
-                });
-              }}
-              disabled={!canDelete}
-            >
-              Delete {envGroup.name}
-            </Button>
-          )}
+
+          <Button
+            color="#b91133"
+            onClick={() => {
+              setCurrentOverlay({
+                message: `Are you sure you want to delete ${envGroup.name}?`,
+                onYes: handleDeleteEnvGroup,
+                onNo: () => setCurrentOverlay(null),
+              });
+            }}
+            disabled={!canDelete}
+          >
+            Delete {envGroup.name}
+          </Button>
         </InnerWrapper>
       )}
     </TabWrapper>

+ 25 - 10
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -644,16 +644,31 @@ const ExpandedChart: React.FC<Props> = (props) => {
     }
 
     try {
-      await api.uninstallTemplate(
-        "<token>",
-        {},
-        {
-          namespace: currentChart.namespace,
-          name: currentChart.name,
-          id: currentProject.id,
-          cluster_id: currentCluster.id,
-        }
-      );
+      if (currentChart.stack_id) {
+        await api.removeStackAppResource(
+          "<token>",
+          {},
+          {
+            namespace: currentChart.namespace,
+            app_resource_name: currentChart.name,
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+            stack_id: currentChart.stack_id,
+          }
+        );
+      } else {
+        await api.uninstallTemplate(
+          "<token>",
+          {},
+          {
+            namespace: currentChart.namespace,
+            name: currentChart.name,
+            id: currentProject.id,
+            cluster_id: currentCluster.id,
+          }
+        );
+      }
+
       props.closeChart();
     } catch (error) {
       console.log(error);

+ 7 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedJobChart.tsx

@@ -22,7 +22,6 @@ import useAuth from "shared/auth/useAuth";
 import ExpandedJobRun from "./jobs/ExpandedJobRun";
 import { useJobs } from "./jobs/useJobs";
 import { useChart } from "shared/hooks/useChart";
-import Modal from "main/home/modals/Modal";
 import ConnectToJobInstructionsModal from "./jobs/ConnectToJobInstructionsModal";
 import CommandLineIcon from "assets/command-line-icon";
 import CronParser from "cron-parser";
@@ -258,7 +257,13 @@ export const ExpandedJobChartFC: React.FC<{
     }
 
     if (currentTab === "build-settings") {
-      return <BuildSettingsTab chart={chart} isPreviousVersion={disableForm} />;
+      return (
+        <BuildSettingsTab
+          chart={chart}
+          isPreviousVersion={disableForm}
+          onSave={refreshChart}
+        />
+      );
     }
 
     if (

+ 3 - 18
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -317,24 +317,9 @@ const SettingsSection: React.FC<PropsType> = ({
           )}
 
           <Heading>Additional Settings</Heading>
-          {currentChart.stack_id?.length ? (
-            <>
-              <Helper>
-                You have to delete the stack to remove this application.
-              </Helper>
-              <CloneButton
-                as={DynamicLink}
-                color="#5561C0"
-                to={`/stacks/${currentChart.namespace}/${currentChart.stack_id}`}
-              >
-                Go to the stack
-              </CloneButton>
-            </>
-          ) : (
-            <Button color="#b91133" onClick={() => setShowDeleteOverlay(true)}>
-              Delete {currentChart.name}
-            </Button>
-          )}
+          <Button color="#b91133" onClick={() => setShowDeleteOverlay(true)}>
+            Delete {currentChart.name}
+          </Button>
         </StyledSettingsSection>
       ) : (
         <Loading />

+ 5 - 56
dashboard/src/main/home/cluster-dashboard/stacks/Dashboard.tsx

@@ -7,6 +7,7 @@ import styled from "styled-components";
 import DashboardHeader from "../DashboardHeader";
 import NamespaceSelector from "../NamespaceSelector";
 import SortSelector from "../SortSelector";
+import { Action } from "./components/styles";
 import StackList from "./_StackList";
 const Dashboard = () => {
   const [currentNamespace, setCurrentNamespace] = useState("default");
@@ -38,11 +39,11 @@ const Dashboard = () => {
         title="Stacks"
         description="Groups of applications deployed from a shared source."
       />
-      <ActionRow>
-        <Button to={"/stacks/launch"}>
+      <Action.Row>
+        <Action.Button to={"/stacks/launch"}>
           <i className="material-icons">add</i>
           Create Stack
-        </Button>
+        </Action.Button>
         <FilterWrapper>
           <StyledSortSelector>
             <Label>
@@ -76,7 +77,7 @@ const Dashboard = () => {
             setNamespace={handleNamespaceChange}
           />
         </FilterWrapper>
-      </ActionRow>
+      </Action.Row>
       <StackList namespace={currentNamespace} sortBy={currentSort} />
     </>
   );
@@ -102,58 +103,6 @@ const StyledSortSelector = styled.div`
   margin-right: 30px;
 `;
 
-const Button = styled(DynamicLink)`
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 13px;
-  cursor: pointer;
-  font-family: "Work Sans", sans-serif;
-  border-radius: 20px;
-  color: white;
-  height: 35px;
-  padding: 0px 8px;
-  min-width: 130px;
-  padding-bottom: 1px;
-  margin-right: 10px;
-  font-weight: 500;
-  padding-right: 15px;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  box-shadow: 0 5px 8px 0px #00000010;
-  cursor: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "not-allowed" : "pointer"};
-
-  background: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "#aaaabbee" : "#616FEEcc"};
-  :hover {
-    background: ${(props: { disabled?: boolean }) =>
-      props.disabled ? "" : "#505edddd"};
-  }
-
-  > i {
-    color: white;
-    width: 18px;
-    height: 18px;
-    font-weight: 600;
-    font-size: 12px;
-    border-radius: 20px;
-    display: flex;
-    align-items: center;
-    margin-right: 5px;
-    justify-content: center;
-  }
-`;
-
-const ActionRow = styled.div`
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 35px;
-`;
-
 const FilterWrapper = styled.div`
   display: flex;
 `;

+ 29 - 41
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/ExpandedStack.tsx

@@ -2,22 +2,21 @@ import Loading from "components/Loading";
 import Placeholder from "components/Placeholder";
 import TabSelector from "components/TabSelector";
 import TitleSection from "components/TitleSection";
-import React, { useContext, useEffect, useState } from "react";
+import React, { useContext, useState } from "react";
 import backArrow from "assets/back_arrow.png";
-import { useParams } from "react-router";
+import { useParams, useRouteMatch } from "react-router";
 import api from "shared/api";
 import { Context } from "shared/Context";
 import { useRouting } from "shared/routing";
 import { readableDate } from "shared/string_utils";
 import styled from "styled-components";
 import ChartList from "../../chart/ChartList";
-import SortSelector from "../../SortSelector";
 import Status from "../components/Status";
 import {
+  Action,
   Br,
   InfoWrapper,
   LastDeployed,
-  LineBreak,
   NamespaceTag,
   SepDot,
   Text,
@@ -29,50 +28,31 @@ import RevisionList from "./_RevisionList";
 import SourceConfig from "./_SourceConfig";
 import { NavLink } from "react-router-dom";
 import Settings from "./components/Settings";
+import { ExpandedStackStore } from "./Store";
+import DynamicLink from "components/DynamicLink";
 
 const ExpandedStack = () => {
-  const { namespace, stack_id } = useParams<{
+  const { namespace } = useParams<{
     namespace: string;
     stack_id: string;
   }>();
 
+  const { stack, refreshStack } = useContext(ExpandedStackStore);
+
   const { pushFiltered } = useRouting();
 
   const { currentProject, currentCluster, setCurrentError } = useContext(
     Context
   );
 
-  const [stack, setStack] = useState<Stack>();
-  const [isLoading, setIsLoading] = useState(true);
+  const { url } = useRouteMatch();
+
   const [isDeleting, setIsDeleting] = useState(false);
   const [currentTab, setCurrentTab] = useState("apps");
 
-  const [currentRevision, setCurrentRevision] = useState<FullStackRevision>();
-
-  const getStack = async () => {
-    setIsLoading(true);
-    try {
-      const newStack = await api
-        .getStack<Stack>(
-          "<token>",
-          {},
-          {
-            project_id: currentProject.id,
-            cluster_id: currentCluster.id,
-            stack_id: stack_id,
-            namespace,
-          }
-        )
-        .then((res) => res.data);
-
-      setStack(newStack);
-      setCurrentRevision(newStack.latest_revision);
-      setIsLoading(false);
-    } catch (error) {
-      setCurrentError(error);
-      pushFiltered("/stacks", []);
-    }
-  };
+  const [currentRevision, setCurrentRevision] = useState<FullStackRevision>(
+    () => stack.latest_revision
+  );
 
   const handleDelete = () => {
     setIsDeleting(true);
@@ -96,12 +76,8 @@ const ExpandedStack = () => {
       });
   };
 
-  useEffect(() => {
-    getStack();
-  }, [stack_id]);
-
-  if (isLoading) {
-    return <Loading />;
+  if (stack === null) {
+    return null;
   }
 
   if (isDeleting) {
@@ -163,7 +139,7 @@ const ExpandedStack = () => {
         stackId={stack.id}
         stackNamespace={namespace}
         onRevisionClick={(revision) => setCurrentRevision(revision)}
-        onRollback={() => getStack()}
+        onRollback={() => refreshStack()}
       ></RevisionList>
       <Br />
       <TabSelector
@@ -175,6 +151,12 @@ const ExpandedStack = () => {
             component: (
               <>
                 <Gap></Gap>
+                <Action.Row>
+                  <Action.Button to={`${url}/new-app-resource`}>
+                    <i className="material-icons">add</i>
+                    Create App Resource
+                  </Action.Button>
+                </Action.Row>
                 {currentRevision.id !== stack.latest_revision.id ? (
                   <ChartListWrapper>
                     <Placeholder>
@@ -209,7 +191,7 @@ const ExpandedStack = () => {
                   namespace={namespace}
                   revision={currentRevision}
                   readOnly={stack.latest_revision.id !== currentRevision.id}
-                  onSourceConfigUpdate={() => getStack()}
+                  onSourceConfigUpdate={() => refreshStack()}
                 ></SourceConfig>
               </>
             ),
@@ -220,6 +202,12 @@ const ExpandedStack = () => {
             component: (
               <>
                 <Gap></Gap>
+                <Action.Row>
+                  <Action.Button to={`${url}/new-env-group`}>
+                    <i className="material-icons">add</i>
+                    Create Env Group
+                  </Action.Button>
+                </Action.Row>
                 <EnvGroups stack={stack} />
               </>
             ),

+ 160 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/_Settings.tsx

@@ -0,0 +1,160 @@
+import { AxiosError } from "axios";
+import { PopulatedEnvGroup } from "components/porter-form/types";
+import React, { useContext, useEffect, useState } from "react";
+import { useParams } from "react-router";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import NewAppResourceForm from "../../components/NewAppResourceForm";
+import { CreateStackBody } from "../../types";
+import { ExpandedStackStore } from "../Store";
+
+const parsePopulatedEnvGroup = (envGroup: PopulatedEnvGroup) => {
+  const variables = Object.entries(envGroup.variables)
+    .filter(([_, value]) => !value.includes("PORTERSECRET"))
+    .reduce(
+      (acc, [key, value]) => ({ ...acc, [key]: value }),
+      {} as Record<string, string>
+    );
+  const secret_variables = Object.entries(envGroup.variables)
+    .filter(([_, value]) => value.includes("PORTERSECRET"))
+    .reduce(
+      (acc, [key, value]) => ({ ...acc, [key]: value }),
+      {} as Record<string, string>
+    );
+
+  return {
+    name: envGroup.name,
+    variables,
+    secret_variables,
+    linked_applications: envGroup.applications as string[],
+  };
+};
+
+const Settings = () => {
+  const params = useParams<{
+    template_name: string;
+    template_version: string;
+  }>();
+  const { stack, refreshStack } = useContext(ExpandedStackStore);
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+  const [availableEnvGroups, setAvailableEnvGroups] = useState<
+    {
+      name: string;
+      variables: Record<string, string>;
+      secret_variables: Record<string, string>;
+      linked_applications: string[];
+    }[]
+  >([]);
+
+  const { pushFiltered } = useRouting();
+
+  const populateEnvGroups = async () => {
+    const stackEnvGroups = stack.latest_revision.env_groups;
+    const envGroupsPromises = stackEnvGroups.map((envGroup) =>
+      api
+        .getEnvGroup<PopulatedEnvGroup>(
+          "<token>",
+          {},
+          {
+            id: currentProject.id,
+            cluster_id: currentCluster.id,
+            name: envGroup.name,
+            namespace: stack.namespace,
+            version: envGroup.env_group_version,
+          }
+        )
+        .then((res) => res.data)
+    );
+
+    try {
+      const response = await Promise.allSettled(envGroupsPromises);
+
+      const envGroups = response
+        .map((res) => {
+          if (res.status === "fulfilled") {
+            return res.value;
+          }
+          return undefined;
+        })
+        .filter(Boolean);
+
+      return envGroups;
+    } catch (error) {
+      setCurrentError(error);
+      throw error;
+    }
+  };
+
+  useEffect(() => {
+    let isSubscribed = true;
+
+    populateEnvGroups().then((populatedEnvGroups) => {
+      if (!isSubscribed) {
+        return;
+      }
+
+      if (Array.isArray(populatedEnvGroups)) {
+        const availableEnvGroups = populatedEnvGroups.map(
+          parsePopulatedEnvGroup
+        );
+
+        setAvailableEnvGroups(availableEnvGroups);
+      }
+    });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [stack, params, currentProject, currentCluster]);
+
+  const handleSubmit = async (
+    appResource: CreateStackBody["app_resources"][0]
+  ) => {
+    try {
+      await api.addStackAppResource(
+        "<token>",
+        {
+          ...appResource,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: stack.namespace,
+          stack_id: stack.id,
+        }
+      );
+
+      await refreshStack();
+
+      pushFiltered(`/stacks/${stack.namespace}/${stack.id}`, []);
+    } catch (error) {
+      const axiosError: AxiosError = error;
+      if (axiosError.code === "409") {
+        throw "Application resource name already exists.";
+      }
+
+      throw "Unexpected error, please try again.";
+    }
+  };
+
+  return (
+    <NewAppResourceForm
+      availableEnvGroups={availableEnvGroups}
+      namespace={stack.namespace}
+      sourceConfig={stack.latest_revision.source_configs[0]}
+      templateInfo={{
+        name: params.template_name,
+        version: params.template_version,
+      }}
+      onCancel={() => {
+        pushFiltered(`../template-selector`, []);
+      }}
+      onSubmit={handleSubmit}
+    />
+  );
+};
+
+export default Settings;

+ 157 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/_TemplateSelector.tsx

@@ -0,0 +1,157 @@
+import React, { useEffect, useState } from "react";
+import api from "shared/api";
+import { PorterTemplate } from "shared/types";
+import semver from "semver";
+import Loading from "components/Loading";
+import Placeholder from "components/Placeholder";
+import { BackButton, Card } from "../../launch/components/styles";
+import DynamicLink from "components/DynamicLink";
+import { VersionSelector } from "../../launch/components/VersionSelector";
+import TitleSection from "components/TitleSection";
+
+const TemplateSelector = () => {
+  const [templates, setTemplates] = useState<PorterTemplate[]>([]);
+  const [selectedVersion, setSelectedVersion] = useState<{
+    [template_name: string]: string;
+  }>({});
+
+  const [isLoading, setIsLoading] = useState(true);
+  const [hasError, setHasError] = useState(false);
+
+  const getTemplates = async () => {
+    try {
+      const res = await api.getTemplates<PorterTemplate[]>(
+        "<token>",
+        {
+          repo_url: process.env.APPLICATION_CHART_REPO_URL,
+        },
+        {}
+      );
+      let sortedVersionData = res.data
+        .map((template: PorterTemplate) => {
+          let versions = template.versions.reverse();
+
+          versions = template.versions.sort(semver.rcompare);
+
+          return {
+            ...template,
+            versions,
+            currentVersion: versions[0],
+          };
+        })
+        .sort((a, b) => {
+          if (a.name < b.name) {
+            return -1;
+          }
+          if (a.name > b.name) {
+            return 1;
+          }
+          return 0;
+        });
+
+      return sortedVersionData;
+    } catch (err) {
+      throw err;
+    }
+  };
+
+  useEffect(() => {
+    let isSubscribed = true;
+    setIsLoading(true);
+    getTemplates()
+      .then((porterTemplates) => {
+        const latestVersions = porterTemplates.reduce((acc, template) => {
+          return {
+            ...acc,
+            [template.name]: template.versions[0],
+          };
+        }, {} as Record<string, string>);
+
+        if (isSubscribed) {
+          setTemplates(porterTemplates);
+          setSelectedVersion(latestVersions);
+        }
+      })
+      .catch(() => {
+        if (isSubscribed) {
+          setHasError(true);
+        }
+      })
+      .finally(() => {
+        if (isSubscribed) {
+          setIsLoading(false);
+        }
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, []);
+
+  if (isLoading) {
+    return <Loading />;
+  }
+
+  if (hasError) {
+    return (
+      <Placeholder>
+        <div>
+          <h2>Unexpected error</h2>
+          <p>
+            We had an error retrieving the available templates, please try
+            again.
+          </p>
+        </div>
+      </Placeholder>
+    );
+  }
+
+  return (
+    <>
+      <TitleSection>
+        <DynamicLink to={`../`}>
+          <BackButton>
+            <i className="material-icons">keyboard_backspace</i>
+          </BackButton>
+        </DynamicLink>
+        Select a template
+      </TitleSection>
+      <Card.Grid>
+        {templates.map((template) => {
+          return (
+            <Card.Wrapper
+              key={template.name}
+              as={DynamicLink}
+              to={`settings/${template.name}/${selectedVersion[template.name]}`}
+            >
+              <Card.Title>
+                New {template.name} with version:
+                <div
+                  onClickCapture={(e) => {
+                    e.preventDefault();
+                  }}
+                >
+                  <VersionSelector
+                    value={selectedVersion[template.name]}
+                    options={template.versions}
+                    onChange={(newVersion) => {
+                      setSelectedVersion((prev) => ({
+                        ...prev,
+                        [template.name]: newVersion,
+                      }));
+                    }}
+                  />
+                </div>
+              </Card.Title>
+              <Card.Actions>
+                <i className="material-icons-outlined">arrow_forward</i>
+              </Card.Actions>
+            </Card.Wrapper>
+          );
+        })}
+      </Card.Grid>
+    </>
+  );
+};
+
+export default TemplateSelector;

+ 27 - 0
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/NewAppResource/index.tsx

@@ -0,0 +1,27 @@
+import React from "react";
+import { Redirect, Route, Switch, useRouteMatch } from "react-router";
+import Settings from "./_Settings";
+import TemplateSelector from "./_TemplateSelector";
+
+const NewAppResourceRoutes = () => {
+  const { url } = useRouteMatch();
+
+  return (
+    <Switch>
+      <Route path={`${url}/template-selector`}>
+        <TemplateSelector />
+      </Route>
+      <Route path={`${url}/settings/:template_name/:template_version`}>
+        <Settings />
+      </Route>
+      <Route path="/">
+        <Redirect to={`${url}/template-selector`} />
+      </Route>
+      <Route path="*">
+        <Redirect to={url} />
+      </Route>
+    </Switch>
+  );
+};
+
+export default NewAppResourceRoutes;

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

@@ -0,0 +1,66 @@
+import { AxiosError } from "axios";
+import React, { useContext } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import NewEnvGroupForm from "../components/NewEnvGroupForm";
+import { CreateStackBody } from "../types";
+import { ExpandedStackStore } from "./Store";
+
+const NewEnvGroup = () => {
+  const { stack, refreshStack } = useContext(ExpandedStackStore);
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const { pushFiltered } = useRouting();
+
+  const createEnvGroup = async (
+    newEnvGroup: CreateStackBody["env_groups"][0]
+  ) => {
+    try {
+      await api.addStackEnvGroup(
+        "<token>",
+        {
+          ...newEnvGroup,
+        },
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: stack.namespace,
+          stack_id: stack.id,
+        }
+      );
+
+      await refreshStack();
+      pushFiltered("../" + stack.id, []);
+    } catch (error) {
+      const axiosError: AxiosError = error;
+
+      if (axiosError.code === "404" || axiosError.code === "405") {
+        throw "New env group not implemented";
+      }
+
+      if (axiosError.code === "409") {
+        throw "Name is already in use";
+      }
+
+      if (error?.message) {
+        throw error.message;
+      }
+
+      throw error;
+    }
+  };
+
+  return (
+    <>
+      <NewEnvGroupForm
+        onSubmit={createEnvGroup}
+        onCancel={() => {
+          pushFiltered("../" + stack.id, []);
+        }}
+      />
+    </>
+  );
+};
+
+export default NewEnvGroup;

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

@@ -0,0 +1,99 @@
+import Loading from "components/Loading";
+import Placeholder from "components/Placeholder";
+import React, { createContext, useContext, useEffect, useState } from "react";
+import { useParams } from "react-router";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import type { Stack } from "../types";
+
+interface StoreType {
+  stack: Stack;
+  refreshStack: () => Promise<void>;
+}
+
+const defaultValues: StoreType = {
+  stack: {} as Stack,
+  refreshStack: async () => {},
+};
+
+export const ExpandedStackStore = createContext(defaultValues);
+
+const ExpandedStackStoreProvider: React.FC = ({ children }) => {
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+
+  const [stack, setStack] = useState<Stack>(null);
+  const [isLoading, setIsLoading] = useState(true);
+
+  const { namespace, stack_id } = useParams<{
+    namespace: string;
+    stack_id: string;
+  }>();
+  const { pushFiltered } = useRouting();
+
+  const getStack = async (props: { subscribed: boolean }) => {
+    setIsLoading(true);
+    api
+      .getStack<Stack>(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace,
+          stack_id,
+        }
+      )
+      .then((res) => {
+        if (props.subscribed) {
+          setStack(res.data);
+        }
+      })
+      .catch(() => {
+        if (props.subscribed) {
+          setCurrentError("Couldn't find any stack with the given ID");
+          pushFiltered("/stacks", []);
+        }
+      })
+      .finally(() => {
+        if (props.subscribed) {
+          setIsLoading(false);
+        }
+      });
+  };
+
+  useEffect(() => {
+    let isSubscribed = { subscribed: true };
+
+    getStack(isSubscribed);
+
+    return () => {
+      isSubscribed.subscribed = false;
+    };
+  }, [currentCluster, currentProject, namespace, stack_id]);
+
+  if (isLoading) {
+    return (
+      <Placeholder>
+        <Loading />
+      </Placeholder>
+    );
+  }
+
+  return (
+    <ExpandedStackStore.Provider
+      value={{
+        stack,
+        refreshStack: async () => {
+          await getStack({ subscribed: true });
+        },
+      }}
+    >
+      {children}
+    </ExpandedStackStore.Provider>
+  );
+};
+
+export default ExpandedStackStoreProvider;

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

@@ -112,6 +112,11 @@ const _RevisionList = ({
         >
           <Td>{revision.id}</Td>
           <Td>{readableDate(revision.created_at)}</Td>
+          <Td>
+            <RevisionStatusWrapper status={revision.status}>
+              {revision.status}
+            </RevisionStatusWrapper>
+          </Td>
           <Td>
             <RollbackButton
               disabled={isCurrent}
@@ -145,7 +150,7 @@ const _RevisionList = ({
             {currentRevision.id === latestRevision.id
               ? `Current Revision`
               : `Previewing Revision (Not Deployed)`}{" "}
-              - <Revision>No. {currentRevision.id}</Revision>
+            - <Revision>No. {currentRevision.id}</Revision>
             <i className="material-icons">arrow_drop_down</i>
           </RevisionPreview>
         </RevisionHeader>
@@ -155,6 +160,7 @@ const _RevisionList = ({
               <Tr disableHover={true}>
                 <Th>Revision No.</Th>
                 <Th>Timestamp</Th>
+                <Th>Status</Th>
                 <Th>Rollback</Th>
               </Tr>
               {revisionList()}
@@ -297,3 +303,18 @@ const LoadingOverlay = styled.div`
   height: 100%;
   position: absolute;
 `;
+
+const RevisionStatusWrapper = styled.span<{ status: StackRevision["status"] }>`
+  text-transform: capitalize;
+  color: ${(props) => {
+    if (props.status === "deployed") {
+      return "#00b300";
+    }
+    if (props.status === "failed") {
+      return "#ff0000";
+    }
+    return "#ffffff";
+  }};
+  font-weight: 500;
+  font-size: 13px;
+`;

+ 31 - 27
dashboard/src/main/home/cluster-dashboard/stacks/ExpandedStack/components/EnvGroups.tsx

@@ -7,6 +7,7 @@ import sliders from "assets/sliders.svg";
 import DynamicLink from "components/DynamicLink";
 import Placeholder from "components/Placeholder";
 import Loading from "components/Loading";
+import { useRouteMatch } from "react-router";
 
 type PopulatedEnvGroup = {
   applications: string[];
@@ -22,6 +23,7 @@ const EnvGroups = ({ stack }: { stack: Stack }) => {
   const { currentProject, currentCluster } = useContext(Context);
   const [isLoading, setIsLoading] = useState(true);
   const [envGroups, setEnvGroups] = useState<PopulatedEnvGroup[]>([]);
+  const { url } = useRouteMatch();
 
   const getEnvGroups = async () => {
     const stackEnvGroups = stack.latest_revision.env_groups;
@@ -78,34 +80,36 @@ const EnvGroups = ({ stack }: { stack: Stack }) => {
   }
 
   return (
-    <Card.Grid style={{ marginTop: "0px" }}>
-      {envGroups.map((envGroup) => {
-        return (
-          <Card.Wrapper variant="unclickable">
-            <Card.Title>
-              <Card.SmallerIcon src={sliders}></Card.SmallerIcon>
-              {envGroup.name}
-            </Card.Title>
+    <>
+      <Card.Grid style={{ marginTop: "0px" }}>
+        {envGroups.map((envGroup) => {
+          return (
+            <Card.Wrapper variant="unclickable">
+              <Card.Title>
+                <Card.SmallerIcon src={sliders}></Card.SmallerIcon>
+                {envGroup.name}
+              </Card.Title>
 
-            <Card.Actions>
-              <Card.ActionButton
-                as={DynamicLink}
-                to={{
-                  pathname: "/env-groups",
-                  search: `?namespace=${stack.namespace}&selected_env_group=${
-                    envGroup.name
-                  }&redirect_url=${encodeURIComponent(
-                    window.location.pathname
-                  )}`,
-                }}
-              >
-                <i className="material-icons-outlined">launch</i>
-              </Card.ActionButton>
-            </Card.Actions>
-          </Card.Wrapper>
-        );
-      })}
-    </Card.Grid>
+              <Card.Actions>
+                <Card.ActionButton
+                  as={DynamicLink}
+                  to={{
+                    pathname: "/env-groups",
+                    search: `?namespace=${stack.namespace}&selected_env_group=${
+                      envGroup.name
+                    }&redirect_url=${encodeURIComponent(
+                      window.location.pathname
+                    )}`,
+                  }}
+                >
+                  <i className="material-icons-outlined">launch</i>
+                </Card.ActionButton>
+              </Card.Actions>
+            </Card.Wrapper>
+          );
+        })}
+      </Card.Grid>
+    </>
   );
 };
 

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

@@ -0,0 +1,63 @@
+import React from "react";
+import {
+  Redirect,
+  Route,
+  Switch,
+  useLocation,
+  useRouteMatch,
+} from "react-router";
+import styled from "styled-components";
+
+import ExpandedStack from "./ExpandedStack";
+import NewAppResourceRoutes from "./NewAppResource";
+import NewEnvGroup from "./NewEnvGroup";
+import ExpandedStackStoreProvider from "./Store";
+
+const ExpandedStackRoutes = () => {
+  const { path } = useRouteMatch();
+  const { pathname } = useLocation();
+
+  return (
+    <ExpandedStackStoreProvider>
+      <Switch>
+        <Redirect from="/:url*(/+)" to={pathname.slice(0, -1)} />
+        <Route path={`${path}/new-env-group`} exact>
+          <StyledLaunchFlow>
+            <LaunchContainer>
+              <NewEnvGroup />
+            </LaunchContainer>
+          </StyledLaunchFlow>
+        </Route>
+        <Route path={`${path}/new-app-resource`}>
+          <StyledLaunchFlow>
+            <LaunchContainer>
+              <NewAppResourceRoutes />
+            </LaunchContainer>
+          </StyledLaunchFlow>
+        </Route>
+        <Route path={`${path}`} exact>
+          <ExpandedStack />
+        </Route>
+        <Route path={`*`}>
+          <div>Not found</div>
+        </Route>
+      </Switch>
+    </ExpandedStackStoreProvider>
+  );
+};
+
+export default ExpandedStackRoutes;
+
+const LaunchContainer = styled.div`
+  margin: 0 auto;
+  width: 100%;
+`;
+
+const StyledLaunchFlow = styled.div`
+  width: calc(100% - 100px);
+  margin-left: 50px;
+  min-width: 300px;
+  margin-top: ${(props: { disableMarginTop?: boolean }) =>
+    props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
+  margin-bottom: 50px;
+`;

+ 312 - 0
dashboard/src/main/home/cluster-dashboard/stacks/components/NewAppResourceForm.tsx

@@ -0,0 +1,312 @@
+import Loading from "components/Loading";
+import { PopulatedEnvGroup } from "components/porter-form/types";
+import TitleSection from "components/TitleSection";
+import _ from "lodash";
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import { ExpandedPorterTemplate } from "shared/types";
+import styled from "styled-components";
+import { BackButton, Icon, Polymer } from "../launch/components/styles";
+import { CreateStackBody, SourceConfig } from "../types";
+import { hardcodedIcons } from "shared/hardcodedNameDict";
+import Heading from "components/form-components/Heading";
+import InputRow from "components/form-components/InputRow";
+import Helper from "components/form-components/Helper";
+import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
+
+const parseEnvGroup = (namespace: string) => (
+  envGroup: CreateStackBody["env_groups"][0]
+): PopulatedEnvGroup => {
+  const variables = envGroup?.variables || {};
+  const secretVariables = envGroup?.secret_variables || {};
+
+  return {
+    name: envGroup.name,
+    version: 1,
+    namespace,
+    applications: envGroup.linked_applications,
+    meta_version: 2,
+    variables: {
+      ...variables,
+      ...Object.keys(secretVariables).reduce((acc, key) => {
+        acc[key] = "PORTERSECRET_" + key;
+        return acc;
+      }, {} as any),
+    },
+  };
+};
+
+const NewAppResourceForm = (props: {
+  templateInfo: {
+    name: string;
+    version: string;
+  };
+  namespace: string;
+  sourceConfig: Pick<
+    SourceConfig,
+    "build" | "image_repo_uri" | "image_tag" | "name"
+  >;
+  availableEnvGroups: CreateStackBody["env_groups"];
+  onSubmit: (
+    newApp: CreateStackBody["app_resources"][0],
+    syncedEnvGroups: string[]
+  ) => Promise<void>;
+  onCancel: () => void;
+}) => {
+  const {
+    availableEnvGroups,
+    sourceConfig,
+    templateInfo,
+    namespace,
+    onCancel,
+    onSubmit,
+  } = props;
+
+  const { currentCluster } = useContext(Context);
+
+  const [hasError, setHasError] = useState(false);
+  const [isLoading, setIsLoading] = useState(true);
+  const [template, setTemplate] = useState<ExpandedPorterTemplate>();
+  const [saveButtonStatus, setSaveButtonStatus] = useState("");
+
+  const [name, setName] = useState("");
+
+  const { pushFiltered } = useRouting();
+
+  const handleSubmit = async ({
+    values: rawValues,
+    metadata,
+  }: {
+    values: any;
+    metadata: any;
+  }) => {
+    setSaveButtonStatus("loading");
+    const syncedEnvGroups =
+      metadata["container.env"]?.added?.map(
+        ({ name }: { name: string }) => name
+      ) || [];
+
+    // Convert dotted keys to nested objects
+    let values: any = {};
+    for (let key in rawValues) {
+      _.set(values, key, rawValues[key]);
+    }
+
+    const stackSourceConfig = sourceConfig;
+    if (!stackSourceConfig) {
+      return;
+    }
+
+    let url = stackSourceConfig.image_repo_uri;
+    let tag = stackSourceConfig.image_tag;
+
+    if (url?.includes(":")) {
+      let splits = url.split(":");
+      url = splits[0];
+      tag = splits[1];
+    } else if (!tag) {
+      tag = "latest";
+    }
+
+    if (!_.isEmpty(stackSourceConfig.build)) {
+      if (template?.metadata?.name === "job") {
+        url = "public.ecr.aws/o1j4x7p4/hello-porter-job";
+        tag = "latest";
+      } else {
+        url = "public.ecr.aws/o1j4x7p4/hello-porter";
+        tag = "latest";
+      }
+    }
+
+    let provider;
+    switch (currentCluster.service) {
+      case "eks":
+        provider = "aws";
+        break;
+      case "gke":
+        provider = "gcp";
+        break;
+      case "doks":
+        provider = "digitalocean";
+        break;
+      case "aks":
+        provider = "azure";
+        break;
+      case "vke":
+        provider = "vultr";
+        break;
+      default:
+        provider = "";
+    }
+
+    // Check the server URL to see if we can detect the cluster provider.
+    // There's no standard URL format for GCP that's why it's not currently included
+    if (provider === "") {
+      const server = currentCluster.server;
+
+      if (server.includes("eks")) provider = "eks";
+      else if (server.includes("ondigitalocean")) provider = "digitalocean";
+      else if (server.includes("azmk8s")) provider = "azure";
+      else if (server.includes("vultr")) provider = "vultr";
+    }
+
+    // don't overwrite for templates that already have a source (i.e. non-Docker templates)
+    if (url && tag) {
+      _.set(values, "image.repository", url);
+      _.set(values, "image.tag", tag);
+    }
+
+    _.set(values, "ingress.provider", provider);
+
+    // pause jobs automatically
+    if (template?.metadata?.name == "job") {
+      _.set(values, "paused", true);
+    }
+
+    if (name === "") {
+      setSaveButtonStatus("App name cannot be empty");
+      return;
+    }
+    try {
+      await onSubmit(
+        {
+          name: name,
+          source_config_name: sourceConfig?.name || "",
+          template_name: templateInfo.name,
+          template_version: templateInfo.version,
+          values,
+        },
+        [...syncedEnvGroups]
+      );
+
+      setSaveButtonStatus("successful");
+      setTimeout(() => {
+        setSaveButtonStatus("");
+        setName("");
+        setTemplate(undefined);
+      }, 1000);
+    } catch (error) {
+      setSaveButtonStatus(error);
+      setTimeout(() => {
+        setSaveButtonStatus("");
+      }, 2000);
+    }
+  };
+
+  useEffect(() => {
+    let isSubscribed = true;
+    if (!templateInfo.name || !templateInfo.version) {
+      return () => {
+        isSubscribed = false;
+      };
+    }
+
+    setHasError(false);
+
+    api
+      .getTemplateInfo<ExpandedPorterTemplate>(
+        "<token>",
+        {},
+        { name: templateInfo.name, version: templateInfo.version }
+      )
+      .then((res) => {
+        if (isSubscribed) {
+          setTemplate(res.data);
+        }
+      })
+      .catch((err) => {
+        setHasError(true);
+      })
+      .finally(() => {
+        if (isSubscribed) {
+          setIsLoading(false);
+        }
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [templateInfo]);
+
+  if (isLoading) {
+    return (
+      <Wrapper>
+        <Loading />
+      </Wrapper>
+    );
+  }
+
+  if (hasError) {
+    return <>Unexpected error</>;
+  }
+  return (
+    <>
+      <TitleSection>
+        <BackButton onClick={onCancel}>
+          <i className="material-icons">keyboard_backspace</i>
+        </BackButton>
+        <Polymer>
+          <Icon src={hardcodedIcons[template.metadata.name]} />
+        </Polymer>
+        Add{" "}
+        {template.metadata.name.charAt(0).toUpperCase() +
+          template.metadata.name.slice(1)}{" "}
+        to Stack
+      </TitleSection>
+      <Heading>
+        Application Name <Required>*</Required>
+      </Heading>
+      <InputRow
+        type="string"
+        value={name}
+        setValue={(val: string) => setName(val)}
+        placeholder="ex: perspective-vortex"
+        width="470px"
+      />
+
+      <div style={{ position: "relative" }}>
+        <Heading>Application Settings</Heading>
+        <Helper>Configure settings for this application.</Helper>
+        <PorterFormWrapper
+          formData={template.form}
+          onSubmit={handleSubmit}
+          isLaunch
+          saveValuesStatus={saveButtonStatus}
+          saveButtonText="Add Application"
+          valuesToOverride={{ namespace }}
+          injectedProps={{
+            "key-value-array": {
+              availableSyncEnvGroups: availableEnvGroups.map(
+                parseEnvGroup(namespace)
+              ),
+            },
+          }}
+          includeMetadata
+        />
+      </div>
+    </>
+  );
+};
+
+export default NewAppResourceForm;
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+  display: inline-block;
+`;
+
+const Wrapper = styled.div`
+  margin-top: calc(50vh - 150px);
+`;
+
+const StyledLaunchFlow = styled.div`
+  min-width: 300px;
+  width: calc(100% - 100px);
+  margin-left: 50px;
+  margin-top: ${(props: { disableMarginTop?: boolean }) =>
+    props.disableMarginTop ? "inherit" : "calc(50vh - 380px)"};
+  padding-bottom: 150px;
+`;

+ 165 - 0
dashboard/src/main/home/cluster-dashboard/stacks/components/NewEnvGroupForm.tsx

@@ -0,0 +1,165 @@
+import DynamicLink from "components/DynamicLink";
+import TitleSection from "components/TitleSection";
+import React, { useMemo, useState } from "react";
+import styled from "styled-components";
+import { BackButton, Polymer, SubmitButton } from "../launch/components/styles";
+import sliders from "assets/sliders.svg";
+import EnvGroupArray, { KeyValueType } from "../../env-groups/EnvGroupArray";
+import Heading from "components/form-components/Heading";
+import { isAlphanumeric } from "shared/common";
+import InputRow from "components/form-components/InputRow";
+import Helper from "components/form-components/Helper";
+
+const envArrayToObject = (variables: KeyValueType[]) => {
+  return variables.reduce<{ [key: string]: string }>((acc, curr) => {
+    acc[curr.key] = curr.value;
+    return acc;
+  }, {});
+};
+
+type ProcessedEnvVariables = ReturnType<typeof envArrayToObject>;
+
+const NewEnvGroupForm = (props: {
+  onSubmit: (newEnvGroup: {
+    name: string;
+    variables: ProcessedEnvVariables;
+    secret_variables: ProcessedEnvVariables;
+  }) => Promise<void>;
+  onCancel: () => void;
+}) => {
+  const { onSubmit, onCancel } = props;
+
+  const [name, setName] = useState("");
+  const [envVariables, setEnvVariables] = useState<KeyValueType[]>([]);
+  const [submitError, setSubmitError] = useState("");
+
+  const handleOnSubmit = async () => {
+    const variables = envVariables.filter(
+      (variable) => !variable.locked && !variable.hidden
+    );
+    const secret_variables = envVariables.filter(
+      (variable) => variable.locked || variable.hidden
+    );
+
+    try {
+      await onSubmit({
+        name: name,
+        variables: envArrayToObject(variables),
+        secret_variables: envArrayToObject(secret_variables),
+      });
+    } catch (error) {
+      setSubmitError(error);
+      return;
+    }
+
+    setName("");
+    setEnvVariables([]);
+    return;
+  };
+
+  const hasError = useMemo(() => {
+    if (!isAlphanumeric(name) || name === "") {
+      return { message: "Name cannot be empty." };
+    }
+
+    if (!envVariables.length) {
+      return { message: "Please add at least one environment variable." };
+    }
+
+    if (envVariables.some((variable) => !variable.value || !variable.key)) {
+      return { message: "Please fill in all environment variables." };
+    }
+
+    return null;
+  }, [name, envVariables]);
+
+  return (
+    <>
+      <TitleSection>
+        <BackButton onClick={onCancel}>
+          <i className="material-icons">keyboard_backspace</i>
+        </BackButton>
+        <Polymer>
+          <SliderIcon src={sliders} />
+        </Polymer>
+        Add a Env Group to Stack
+      </TitleSection>
+      <Heading isAtTop={true}>Name</Heading>
+      <Subtitle>
+        <Warning
+          makeFlush={true}
+          highlight={!isAlphanumeric(name) && name !== ""}
+        >
+          Lowercase letters, numbers, and "-" only.
+        </Warning>
+      </Subtitle>
+      <InputRow
+        type="text"
+        value={name}
+        setValue={(x: string) => {
+          setName(x);
+        }}
+        placeholder="ex: doctor-scientist"
+        width="100%"
+      />
+
+      <Heading>Environment Variables</Heading>
+      <Helper>
+        Set environment variables for your secrets and environment-specific
+        configuration.
+      </Helper>
+      <EnvGroupArray
+        values={envVariables}
+        setValues={(x: any) => setEnvVariables((prev) => [...x])}
+        fileUpload={true}
+        secretOption={true}
+      />
+
+      <SubmitButton
+        onClick={handleOnSubmit}
+        makeFlush
+        clearPosition
+        text="Save env group"
+        disabled={!!hasError}
+        statusPosition="left"
+        status={hasError?.message || submitError || ""}
+      />
+    </>
+  );
+};
+
+export default NewEnvGroupForm;
+
+const SliderIcon = styled.img`
+  width: 25px;
+  margin-right: 16px;
+
+  opacity: 0;
+  animation: floatIn 0.5s 0.2s;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const Subtitle = styled.div`
+  padding: 11px 0px 0px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  line-height: 1.6em;
+  display: flex;
+  align-items: center;
+`;
+
+const Warning = styled.span<{ highlight: boolean; makeFlush?: boolean }>`
+  color: ${(props) => (props.highlight ? "#f5cb42" : "")};
+  margin-left: ${(props) => (props.makeFlush ? "" : "5px")};
+`;

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

@@ -1,3 +1,4 @@
+import DynamicLink from "components/DynamicLink";
 import styled from "styled-components";
 
 const StatusBase = styled.div`
@@ -129,3 +130,56 @@ export const NamespaceTag = {
     text-overflow: ellipsis;
   `,
 };
+
+export const Action = {
+  Row: styled.div`
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 35px;
+  `,
+  Button: styled(DynamicLink)`
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    justify-content: space-between;
+    font-size: 13px;
+    cursor: pointer;
+    font-family: "Work Sans", sans-serif;
+    border-radius: 20px;
+    color: white;
+    height: 35px;
+    padding: 0px 8px;
+    min-width: 130px;
+    padding-bottom: 1px;
+    margin-right: 10px;
+    font-weight: 500;
+    padding-right: 15px;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+    box-shadow: 0 5px 8px 0px #00000010;
+    cursor: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "not-allowed" : "pointer"};
+
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "#aaaabbee" : "#616FEEcc"};
+    :hover {
+      background: ${(props: { disabled?: boolean }) =>
+        props.disabled ? "" : "#505edddd"};
+    }
+
+    > i {
+      color: white;
+      width: 18px;
+      height: 18px;
+      font-weight: 600;
+      font-size: 12px;
+      border-radius: 20px;
+      display: flex;
+      align-items: center;
+      margin-right: 5px;
+      justify-content: center;
+    }
+  `,
+};

+ 20 - 250
dashboard/src/main/home/cluster-dashboard/stacks/launch/NewApp.tsx

@@ -1,276 +1,46 @@
-import Helper from "components/form-components/Helper";
-import InputRow from "components/form-components/InputRow";
-import Loading from "components/Loading";
-import PorterFormWrapper from "components/porter-form/PorterFormWrapper";
 import _ from "lodash";
-import React, { useContext, useEffect, useState } from "react";
+import React, { useContext } from "react";
 import { useParams } from "react-router";
-import api from "shared/api";
-import { Context } from "shared/Context";
 import { useRouting } from "shared/routing";
-import { ExpandedPorterTemplate } from "shared/types";
 import { StacksLaunchContext } from "./Store";
-import DynamicLink from "components/DynamicLink";
 import styled from "styled-components";
-import Heading from "components/form-components/Heading";
-import TitleSection from "components/TitleSection";
-import { hardcodedIcons } from "shared/hardcodedNameDict";
-import { BackButton, Icon, Polymer } from "./components/styles";
-import { PopulatedEnvGroup } from "components/porter-form/types";
-import { CreateStackBody } from "../types";
+import NewAppResourceForm from "../components/NewAppResourceForm";
 
 const DEFAULT_STACK_SOURCE_CONFIG_INDEX = 0;
 
-const parseEnvGroup = (namespace: string) => (
-  envGroup: CreateStackBody["env_groups"][0]
-): PopulatedEnvGroup => {
-  const variables = envGroup?.variables || {};
-  const secretVariables = envGroup?.secret_variables || {};
-
-  return {
-    name: envGroup.name,
-    version: 1,
-    namespace,
-    applications: envGroup.linked_applications,
-    meta_version: 2,
-    variables: {
-      ...variables,
-      ...Object.keys(secretVariables).reduce((acc, key) => {
-        acc[key] = "PORTERSECRET_" + key;
-        return acc;
-      }, {} as any),
-    },
-  };
-};
-
 const NewApp = () => {
   const { addAppResource, newStack, namespace } = useContext(
     StacksLaunchContext
   );
-  const { currentCluster } = useContext(Context);
 
   const params = useParams<{
     template_name: string;
     version: string;
   }>();
 
-  const [template, setTemplate] = useState<ExpandedPorterTemplate>();
-  const [isLoading, setIsLoading] = useState(true);
-  const [hasError, setHasError] = useState(false);
-  const [saveButtonStatus, setSaveButtonStatus] = useState("");
-
-  const [appName, setAppName] = useState("");
-
   const { pushFiltered } = useRouting();
 
-  useEffect(() => {
-    let isSubscribed = true;
-    if (!params.template_name || !params.version) {
-      return () => {
-        isSubscribed = false;
-      };
-    }
-
-    setHasError(false);
-
-    api
-      .getTemplateInfo<ExpandedPorterTemplate>(
-        "<token>",
-        {},
-        { name: params.template_name, version: params.version }
-      )
-      .then((res) => {
-        if (isSubscribed) {
-          setTemplate(res.data);
-        }
-      })
-      .catch((err) => {
-        setHasError(true);
-      })
-      .finally(() => {
-        if (isSubscribed) {
-          setIsLoading(false);
-        }
-      });
-
-    return () => {
-      isSubscribed = false;
-    };
-  }, [params]);
-
-  if (isLoading) {
-    return (
-      <Wrapper>
-        <Loading />
-      </Wrapper>
-    );
-  }
-
-  if (hasError) {
-    return <>Unexpected error</>;
-  }
-
-  const handleSubmit = async ({
-    values: rawValues,
-    metadata,
-  }: {
-    values: any;
-    metadata: any;
-  }) => {
-    setSaveButtonStatus("loading");
-    const syncedEnvGroups =
-      metadata["container.env"]?.added?.map(
-        ({ name }: { name: string }) => name
-      ) || [];
-
-    // Convert dotted keys to nested objects
-    let values: any = {};
-    for (let key in rawValues) {
-      _.set(values, key, rawValues[key]);
-    }
-
-    const stackSourceConfig =
-      newStack.source_configs[DEFAULT_STACK_SOURCE_CONFIG_INDEX];
-    if (!stackSourceConfig) {
-      return;
-    }
-
-    let url = stackSourceConfig.image_repo_uri;
-    let tag = stackSourceConfig.image_tag;
-
-    if (url?.includes(":")) {
-      let splits = url.split(":");
-      url = splits[0];
-      tag = splits[1];
-    } else if (!tag) {
-      tag = "latest";
-    }
-
-    if (!_.isEmpty(stackSourceConfig.build)) {
-      if (template?.metadata?.name === "job") {
-        url = "public.ecr.aws/o1j4x7p4/hello-porter-job";
-        tag = "latest";
-      } else {
-        url = "public.ecr.aws/o1j4x7p4/hello-porter";
-        tag = "latest";
-      }
-    }
-
-    let provider;
-    switch (currentCluster.service) {
-      case "eks":
-        provider = "aws";
-        break;
-      case "gke":
-        provider = "gcp";
-        break;
-      case "doks":
-        provider = "digitalocean";
-        break;
-      case "aks":
-        provider = "azure";
-        break;
-      case "vke":
-        provider = "vultr";
-        break;
-      default:
-        provider = "";
-    }
-
-    // Check the server URL to see if we can detect the cluster provider.
-    // There's no standard URL format for GCP that's why it's not currently included
-    if (provider === "") {
-      const server = currentCluster.server;
-
-      if (server.includes("eks")) provider = "eks";
-      else if (server.includes("ondigitalocean")) provider = "digitalocean";
-      else if (server.includes("azmk8s")) provider = "azure";
-      else if (server.includes("vultr")) provider = "vultr";
-    }
-
-    // don't overwrite for templates that already have a source (i.e. non-Docker templates)
-    if (url && tag) {
-      _.set(values, "image.repository", url);
-      _.set(values, "image.tag", tag);
-    }
-
-    _.set(values, "ingress.provider", provider);
-
-    // pause jobs automatically
-    if (template?.metadata?.name == "job") {
-      _.set(values, "paused", true);
-    }
-
-    if (appName === "") {
-      setSaveButtonStatus("App name cannot be empty");
-      return;
-    }
-
-    addAppResource(
-      {
-        name: appName,
-        source_config_name: newStack.source_configs[0]?.name || "",
-        template_name: params.template_name,
-        template_version: params.version,
-        values,
-      },
-      [...syncedEnvGroups]
-    );
-
-    setSaveButtonStatus("successful");
-    setTimeout(() => {
-      setSaveButtonStatus("");
-      pushFiltered("/stacks/launch/overview", []);
-    }, 1000);
-  };
-
   return (
     <>
-      <TitleSection>
-        <DynamicLink to={`/stacks/launch/overview`}>
-          <BackButton>
-            <i className="material-icons">keyboard_backspace</i>
-          </BackButton>
-        </DynamicLink>
-        <Polymer>
-          <Icon src={hardcodedIcons[template.metadata.name]} />
-        </Polymer>
-        Add{" "}
-        {template.metadata.name.charAt(0).toUpperCase() +
-          template.metadata.name.slice(1)}{" "}
-        to Stack
-      </TitleSection>
-      <Heading>
-        Application Name <Required>*</Required>
-      </Heading>
-      <InputRow
-        type="string"
-        value={appName}
-        setValue={(val: string) => setAppName(val)}
-        placeholder="ex: perspective-vortex"
-        width="470px"
+      <NewAppResourceForm
+        sourceConfig={
+          newStack.source_configs[DEFAULT_STACK_SOURCE_CONFIG_INDEX]
+        }
+        availableEnvGroups={newStack.env_groups}
+        namespace={namespace}
+        templateInfo={{
+          name: params.template_name,
+          version: params.version,
+        }}
+        onSubmit={async (newApp, syncedEnvGroups) => {
+          addAppResource(newApp, syncedEnvGroups);
+          pushFiltered("/stacks/launch/overview", []);
+          return;
+        }}
+        onCancel={() => {
+          pushFiltered("/stacks/launch/overview", []);
+        }}
       />
-
-      <div style={{ position: "relative" }}>
-        <Heading>Application Settings</Heading>
-        <Helper>Configure settings for this application.</Helper>
-        <PorterFormWrapper
-          formData={template.form}
-          onSubmit={handleSubmit}
-          isLaunch
-          saveValuesStatus={saveButtonStatus}
-          saveButtonText="Add Application"
-          valuesToOverride={{ namespace }}
-          injectedProps={{
-            "key-value-array": {
-              availableSyncEnvGroups: newStack.env_groups.map(
-                parseEnvGroup(namespace)
-              ),
-            },
-          }}
-          includeMetadata
-        />
-      </div>
     </>
   );
 };

+ 16 - 142
dashboard/src/main/home/cluster-dashboard/stacks/launch/NewEnvGroup.tsx

@@ -1,156 +1,30 @@
-import DynamicLink from "components/DynamicLink";
-import Heading from "components/form-components/Heading";
-import Helper from "components/form-components/Helper";
-import InputRow from "components/form-components/InputRow";
-import TitleSection from "components/TitleSection";
-import React, { useContext, useMemo, useState } from "react";
-import { isAlphanumeric } from "shared/common";
+import React, { useContext } from "react";
 import { useRouting } from "shared/routing";
 import styled from "styled-components";
-import EnvGroupArray, { KeyValueType } from "../../env-groups/EnvGroupArray";
-import { BackButton, Icon, Polymer, SubmitButton } from "./components/styles";
+import NewEnvGroupForm from "../components/NewEnvGroupForm";
 import { StacksLaunchContext } from "./Store";
-import sliders from "assets/sliders.svg";
-
-const envArrayToObject = (variables: KeyValueType[]) => {
-  return variables.reduce<{ [key: string]: string }>((acc, curr) => {
-    acc[curr.key] = curr.value;
-    return acc;
-  }, {});
-};
 
 const NewEnvGroup = () => {
   const { addEnvGroup } = useContext(StacksLaunchContext);
-  const [name, setName] = useState("");
-  const [envVariables, setEnvVariables] = useState<KeyValueType[]>([]);
 
   const { pushFiltered } = useRouting();
 
-  const handleOnSubmit = () => {
-    const variables = envVariables.filter(
-      (variable) => !variable.locked && !variable.hidden
-    );
-    const secret_variables = envVariables.filter(
-      (variable) => variable.locked || variable.hidden
-    );
-
-    addEnvGroup({
-      name,
-      variables: envArrayToObject(variables),
-      secret_variables: envArrayToObject(secret_variables),
-      linked_applications: [],
-    });
-    setName("");
-    setEnvVariables([]);
-    pushFiltered("/stacks/launch/overview", []);
-    return;
-  };
-
-  const hasError = useMemo(() => {
-    if (!isAlphanumeric(name) || name === "") {
-      return { message: "Name cannot be empty." };
-    }
-
-    if (!envVariables.length) {
-      return { message: "Please add at least one environment variable." };
-    }
-
-    if (envVariables.some((variable) => !variable.value || !variable.key)) {
-      return { message: "Please fill in all environment variables." };
-    }
-
-    return null;
-  }, [name, envVariables]);
-
   return (
-    <>
-      <TitleSection>
-        <DynamicLink to={`/stacks/launch/overview`}>
-          <BackButton>
-            <i className="material-icons">keyboard_backspace</i>
-          </BackButton>
-        </DynamicLink>
-        <Polymer>
-          <SliderIcon src={sliders} />
-        </Polymer>
-        Add a Env Group to Stack
-      </TitleSection>
-      <Heading isAtTop={true}>Name</Heading>
-      <Subtitle>
-        <Warning
-          makeFlush={true}
-          highlight={!isAlphanumeric(name) && name !== ""}
-        >
-          Lowercase letters, numbers, and "-" only.
-        </Warning>
-      </Subtitle>
-      <InputRow
-        type="text"
-        value={name}
-        setValue={(x: string) => {
-          setName(x);
-        }}
-        placeholder="ex: doctor-scientist"
-        width="100%"
-      />
-
-      <Heading>Environment Variables</Heading>
-      <Helper>
-        Set environment variables for your secrets and environment-specific
-        configuration.
-      </Helper>
-      <EnvGroupArray
-        values={envVariables}
-        setValues={(x: any) => setEnvVariables((prev) => [...x])}
-        fileUpload={true}
-        secretOption={true}
-      />
-
-      <SubmitButton
-        onClick={handleOnSubmit}
-        makeFlush
-        clearPosition
-        text="Save env group"
-        disabled={!!hasError}
-        statusPosition="left"
-        status={hasError?.message || ""}
-      />
-    </>
+    <NewEnvGroupForm
+      onSubmit={async (newEnvGroup) => {
+        addEnvGroup({
+          ...newEnvGroup,
+          linked_applications: [],
+        });
+        pushFiltered("/stacks/launch/overview", []);
+        return;
+      }}
+      onCancel={() => {
+        pushFiltered("/stacks/launch/overview", []);
+        return;
+      }}
+    />
   );
 };
 
 export default NewEnvGroup;
-
-export const SliderIcon = styled.img`
-  width: 25px;
-  margin-right: 16px;
-
-  opacity: 0;
-  animation: floatIn 0.5s 0.2s;
-  animation-fill-mode: forwards;
-  @keyframes floatIn {
-    from {
-      opacity: 0;
-      transform: translateY(10px);
-    }
-    to {
-      opacity: 1;
-      transform: translateY(0px);
-    }
-  }
-`;
-
-const Subtitle = styled.div`
-  padding: 11px 0px 0px;
-  font-family: "Work Sans", sans-serif;
-  font-size: 13px;
-  color: #aaaabb;
-  line-height: 1.6em;
-  display: flex;
-  align-items: center;
-`;
-
-const Warning = styled.span<{ highlight: boolean; makeFlush?: boolean }>`
-  color: ${(props) => (props.highlight ? "#f5cb42" : "")};
-  margin-left: ${(props) => (props.makeFlush ? "" : "5px")};
-`;

+ 1 - 0
dashboard/src/main/home/cluster-dashboard/stacks/launch/components/styles.tsx

@@ -157,6 +157,7 @@ export const SelectorStyles = {
     width: 100%;
     max-height: 200px;
     overflow-y: auto;
+    z-index: 999;
   `,
   Option: styled.div`
     min-height: 35px;

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

@@ -1,14 +1,8 @@
 import React, { useContext } from "react";
-import {
-  Redirect,
-  Route,
-  Switch,
-  useLocation,
-  useRouteMatch,
-} from "react-router";
+import { Redirect, Route, Switch, useRouteMatch } from "react-router";
 import { Context } from "shared/Context";
 import Dashboard from "./Dashboard";
-import ExpandedStack from "./ExpandedStack/ExpandedStack";
+import ExpandedStackRoutes from "./ExpandedStack/routes";
 import LaunchRoutes from "./launch";
 
 const routes = () => {
@@ -25,9 +19,9 @@ const routes = () => {
         <LaunchRoutes />
       </Route>
       <Route path={`${path}/:namespace/:stack_id`}>
-        <ExpandedStack />
+        <ExpandedStackRoutes />
       </Route>
-      <Route path={`${path}/`} exact>
+      <Route path={`${path}`} exact>
         <Dashboard />
       </Route>
       <Route path={`*`}>

+ 15 - 8
dashboard/src/main/home/cluster-dashboard/stacks/types.ts

@@ -55,19 +55,26 @@ export type FullStackRevision = StackRevision & {
   env_groups: EnvGroup[];
 };
 
+type StackRevisionReason =
+  | "DeployError"
+  | "SaveError"
+  | "RollbackError"
+  | "EnvGroupUpgrade"
+  | "ApplicationUpgrade"
+  | "SourceConfigUpgrade"
+  | "Rollback"
+  | "CreationSuccess"
+  | "AddEnvGroupSuccess"
+  | "AddAppSuccess"
+  | "RemoveEnvGroupSuccess"
+  | "RemoveAppSuccess";
+
 export type StackRevision = {
   id: number;
   created_at: string;
   status: "deploying" | "deployed" | "failed"; // type with enum
   stack_id: string;
-  reason:
-    | "DeployError"
-    | "SaveError"
-    | "RollbackError"
-    | "EnvGroupUpgrade"
-    | "ApplicationUpgrade"
-    | "SourceConfigUpgrade"
-    | "Rollback";
+  reason: StackRevisionReason;
   message: string;
 };
 

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

@@ -2087,6 +2087,64 @@ const updateStackSourceConfig = baseApi<
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/source`
 );
 
+const addStackAppResource = baseApi<
+  CreateStackBody["app_resources"][0],
+  {
+    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}/add_application`
+);
+
+const removeStackAppResource = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+    app_resource_name: string;
+  }
+>(
+  "DELETE",
+  ({ project_id, cluster_id, namespace, stack_id, app_resource_name }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/remove_application/${app_resource_name}`
+);
+
+const addStackEnvGroup = baseApi<
+  CreateStackBody["env_groups"][0],
+  {
+    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}/add_env_group`
+);
+
+const removeStackEnvGroup = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    stack_id: string;
+    env_group_name: string;
+  }
+>(
+  "DELETE",
+  ({ project_id, cluster_id, namespace, stack_id, env_group_name }) =>
+    `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/remove_env_group/${env_group_name}`
+);
+
 const getGithubStatus = baseApi<{}, {}>("GET", ({}) => `/api/status/github`);
 
 // Bundle export to allow default api import (api.<method> is more readable)
@@ -2284,6 +2342,10 @@ export default {
   rollbackStack,
   deleteStack,
   updateStackSourceConfig,
+  addStackAppResource,
+  removeStackAppResource,
+  addStackEnvGroup,
+  removeStackEnvGroup,
 
   // STATUS
   getGithubStatus,

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

+ 29 - 10
dashboard/src/shared/hooks/useChart.ts

@@ -96,6 +96,33 @@ export const useChart = (oldChart: ChartType, closeChart: () => void) => {
     }
   };
 
+  const uninstallChart = async () => {
+    if (chart.stack_id) {
+      await api.removeStackAppResource(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          app_resource_name: chart.name,
+          namespace: chart.namespace,
+          stack_id: chart.stack_id,
+        }
+      );
+    } else {
+      await api.uninstallTemplate(
+        "<token>",
+        {},
+        {
+          namespace: chart.namespace,
+          name: chart.name,
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      );
+    }
+  };
+
   /**
    * Delete/Uninstall chart
    */
@@ -128,16 +155,8 @@ export const useChart = (oldChart: ChartType, closeChart: () => void) => {
         return;
       }
 
-      await api.uninstallTemplate(
-        "<token>",
-        {},
-        {
-          namespace: chart.namespace,
-          name: chart.name,
-          id: currentProject.id,
-          cluster_id: currentCluster.id,
-        }
-      );
+      await uninstallChart();
+
       setStatus("ready");
       closeChart();
       return;

+ 5 - 4
go.mod

@@ -3,7 +3,7 @@ module github.com/porter-dev/porter
 go 1.18
 
 require (
-	cloud.google.com/go v0.100.2
+	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.75.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
@@ -75,7 +75,7 @@ require (
 
 require (
 	cloud.google.com/go/artifactregistry v1.3.0 // indirect
-	cloud.google.com/go/compute v1.6.1 // 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
@@ -107,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
@@ -185,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.3.0 // 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

+ 34 - 0
go.sum

@@ -35,6 +35,8 @@ 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=
@@ -49,6 +51,8 @@ cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6m
 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=
@@ -67,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=
@@ -1035,6 +1040,9 @@ 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=
@@ -1043,11 +1051,14 @@ github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0
 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=
@@ -2303,6 +2314,7 @@ golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 h1:HVyaeDAYux4pnY+D/SiwmLOR3
 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=
@@ -2328,6 +2340,8 @@ golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j
 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=
@@ -2493,8 +2507,12 @@ golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBc
 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=
@@ -2661,6 +2679,8 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T
 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=
@@ -2708,6 +2728,11 @@ google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc
 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=
@@ -2769,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=
@@ -2809,6 +2835,13 @@ google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX
 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=
@@ -2849,6 +2882,7 @@ google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ5
 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 - 5
internal/kubernetes/prometheus/metrics.go

@@ -145,15 +145,15 @@ func QueryPrometheus(
 		netPodSelector := fmt.Sprintf(`namespace="%s",pod=~"%s",container="POD"`, opts.Namespace, selectionRegex)
 		query = fmt.Sprintf("rate(container_network_receive_bytes_total{%s}[5m])", netPodSelector)
 	} else if opts.Metric == "nginx:errors" {
-		num := fmt.Sprintf(`sum(rate(nginx_ingress_controller_requests{status=~"5.*",namespace="%s",ingress=~"%s"}[5m]) OR on() vector(0))`, opts.Namespace, selectionRegex)
-		denom := fmt.Sprintf(`sum(rate(nginx_ingress_controller_requests{namespace="%s",ingress=~"%s"}[5m]) > 0)`, opts.Namespace, selectionRegex)
+		num := fmt.Sprintf(`sum(rate(nginx_ingress_controller_requests{status=~"5.*",exported_namespace="%s",ingress=~"%s"}[5m]) OR on() vector(0))`, opts.Namespace, selectionRegex)
+		denom := fmt.Sprintf(`sum(rate(nginx_ingress_controller_requests{exported_namespace="%s",ingress=~"%s"}[5m]) > 0)`, opts.Namespace, selectionRegex)
 		query = fmt.Sprintf(`%s / %s * 100 OR on() vector(0)`, num, denom)
 	} else if opts.Metric == "nginx:latency" {
-		num := fmt.Sprintf(`sum(rate(nginx_ingress_controller_request_duration_seconds_sum{namespace=~"%s",ingress=~"%s"}[5m]) OR on() vector(0))`, opts.Namespace, selectionRegex)
-		denom := fmt.Sprintf(`sum(rate(nginx_ingress_controller_request_duration_seconds_count{namespace=~"%s",ingress=~"%s"}[5m]))`, opts.Namespace, selectionRegex)
+		num := fmt.Sprintf(`sum(rate(nginx_ingress_controller_request_duration_seconds_sum{exported_namespace=~"%s",ingress=~"%s"}[5m]) OR on() vector(0))`, opts.Namespace, selectionRegex)
+		denom := fmt.Sprintf(`sum(rate(nginx_ingress_controller_request_duration_seconds_count{exported_namespace=~"%s",ingress=~"%s"}[5m]))`, opts.Namespace, selectionRegex)
 		query = fmt.Sprintf(`%s / %s OR on() vector(0)`, num, denom)
 	} else if opts.Metric == "nginx:latency-histogram" {
-		query = fmt.Sprintf(`histogram_quantile(%f, sum(rate(nginx_ingress_controller_request_duration_seconds_bucket{status!="404",status!="500",namespace=~"%s",ingress=~"%s"}[5m])) by (le, ingress))`, opts.Percentile, opts.Namespace, selectionRegex)
+		query = fmt.Sprintf(`histogram_quantile(%f, sum(rate(nginx_ingress_controller_request_duration_seconds_bucket{status!="404",status!="500",exported_namespace=~"%s",ingress=~"%s"}[5m])) by (le, ingress))`, opts.Percentile, opts.Namespace, selectionRegex)
 	} else if opts.Metric == "cpu_hpa_threshold" {
 		// get the name of the kube hpa metric
 		metricName, hpaMetricName := getKubeHPAMetricName(clientset, service, opts, "spec_target_metric")

+ 89 - 29
internal/registry/registry.go

@@ -19,6 +19,7 @@ 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"
@@ -211,6 +212,35 @@ 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) {
@@ -223,7 +253,10 @@ func (r *Registry) listGARRepositories(
 		return nil, err
 	}
 
-	client, err := artifactregistry.NewClient(context.Background(), option.WithCredentialsJSON(gcpInt.GCPKeyData))
+	client, err := artifactregistry.NewClient(context.Background(), option.WithTokenSource(&garTokenSource{
+		reg:  r,
+		repo: repo,
+	}), option.WithScopes("roles/artifactregistry.reader"))
 
 	if err != nil {
 		return nil, err
@@ -238,9 +271,11 @@ func (r *Registry) listGARRepositories(
 		return nil, err
 	}
 
+	location := strings.TrimSuffix(parsedURL.Host, "-docker.pkg.dev")
+
 	for {
 		it := client.ListRepositories(context.Background(), &artifactregistrypb.ListRepositoriesRequest{
-			Parent:    gcpInt.GCPProjectID,
+			Parent:    fmt.Sprintf("projects/%s/locations/%s", gcpInt.GCPProjectID, location),
 			PageSize:  1000,
 			PageToken: nextToken,
 		})
@@ -254,9 +289,13 @@ func (r *Registry) listGARRepositories(
 				return nil, err
 			}
 
+			repoSlice := strings.Split(resp.GetName(), "/")
+			repoName := repoSlice[len(repoSlice)-1]
+
 			res = append(res, &ptypes.RegistryRepository{
-				Name: resp.GetName(),
-				URI:  parsedURL.Host + "/" + resp.GetName(),
+				Name:      resp.GetName(),
+				CreatedAt: resp.GetCreateTime().AsTime(),
+				URI:       parsedURL.Host + "/" + gcpInt.GCPProjectID + "/" + repoName,
 			})
 		}
 
@@ -717,7 +756,10 @@ func (r *Registry) createGARRepository(
 		return err
 	}
 
-	client, err := artifactregistry.NewClient(context.Background(), option.WithCredentialsJSON(gcpInt.GCPKeyData))
+	client, err := artifactregistry.NewClient(context.Background(), option.WithTokenSource(&garTokenSource{
+		reg:  r,
+		repo: repo,
+	}), option.WithScopes("roles/artifactregistry.admin"))
 
 	if err != nil {
 		return err
@@ -725,16 +767,24 @@ func (r *Registry) createGARRepository(
 
 	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: name,
+		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: gcpInt.GCPProjectID,
+			Parent:       fmt.Sprintf("projects/%s/locations/%s", gcpInt.GCPProjectID, location),
+			RepositoryId: name,
 			Repository: &artifactregistrypb.Repository{
-				Name:   name,
 				Format: artifactregistrypb.Repository_DOCKER,
 			},
 		})
@@ -1131,44 +1181,54 @@ func (r *Registry) listGARImages(repoName string, repo repository.Repository) ([
 		return nil, err
 	}
 
-	client, err := artifactregistry.NewClient(context.Background(), option.WithCredentialsJSON(gcpInt.GCPKeyData))
+	svc, err := v1artifactregistry.NewService(context.Background(), option.WithTokenSource(&garTokenSource{
+		reg:  r,
+		repo: repo,
+	}), option.WithScopes("roles/artifactregistry.reader"))
 
 	if err != nil {
 		return nil, err
 	}
 
-	defer client.Close()
-
 	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 {
-		it := client.ListTags(context.Background(), &artifactregistrypb.ListTagsRequest{
-			Parent:    repoName,
-			PageSize:  1000,
-			PageToken: nextToken,
-		})
+		resp, err := dockerSvc.List(fmt.Sprintf("projects/%s/locations/%s/repositories/%s",
+			gcpInt.GCPProjectID, location, repoName)).PageSize(1000).PageToken(nextToken).Do()
 
-		for {
-			resp, err := it.Next()
+		if err != nil {
+			return nil, err
+		}
 
-			if err == iterator.Done {
-				break
-			} else if err != nil {
-				return nil, err
-			}
+		for _, image := range resp.DockerImages {
+			uploadTime, _ := time.Parse(time.RFC3339, image.UploadTime)
 
-			res = append(res, &ptypes.Image{
-				RepositoryName: repoName,
-				Tag:            resp.Name,
-			})
+			for _, tag := range image.Tags {
+				res = append(res, &ptypes.Image{
+					RepositoryName: repoName,
+					Tag:            tag,
+					PushedAt:       &uploadTime,
+					Digest:         strings.Split(image.Name, "@")[1],
+				})
+			}
 		}
 
-		if it.PageInfo().Token == "" {
+		if resp.NextPageToken == "" {
 			break
 		}
 
-		nextToken = it.PageInfo().Token
+		nextToken = resp.NextPageToken
 	}
 
 	return res, nil

+ 6 - 1
provisioner/server/handlers/state/create_resource.go

@@ -279,7 +279,12 @@ func createCluster(config *config.Config, infra *models.Infra, operation *models
 		}
 	}
 
-	cluster.Name = output["cluster_name"].(string)
+	// only update the cluster name if this is during creation - we don't want to overwrite the cluster name
+	// which may have been manually set
+	if isNotFound {
+		cluster.Name = output["cluster_name"].(string)
+	}
+
 	cluster.Server = output["cluster_endpoint"].(string)
 	cluster.CertificateAuthorityData = caData
 

+ 1 - 7
workers/jobs/helm_revisions_count_tracker.go

@@ -33,7 +33,6 @@ import (
 	"github.com/porter-dev/porter/provisioner/integrations/storage/s3"
 
 	"github.com/porter-dev/porter/ee/integrations/vault"
-	"github.com/porter-dev/porter/internal/adapter"
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
@@ -77,15 +76,10 @@ type HelmRevisionsCountTrackerOpts struct {
 }
 
 func NewHelmRevisionsCountTracker(
+	db *gorm.DB,
 	enqueueTime time.Time,
 	opts *HelmRevisionsCountTrackerOpts,
 ) (*helmRevisionsCountTracker, error) {
-	db, err := adapter.New(opts.DBConf)
-
-	if err != nil {
-		return nil, err
-	}
-
 	var credBackend rcreds.CredentialStorage
 
 	if opts.DBConf.VaultAPIKey != "" && opts.DBConf.VaultServerURL != "" && opts.DBConf.VaultPrefix != "" {

+ 15 - 2
workers/main.go

@@ -16,13 +16,16 @@ import (
 	"github.com/go-chi/chi/middleware"
 	"github.com/joeshaw/envdecode"
 	"github.com/porter-dev/porter/api/server/shared/config/env"
+	"github.com/porter-dev/porter/internal/adapter"
 	"github.com/porter-dev/porter/internal/worker"
 	"github.com/porter-dev/porter/workers/jobs"
+	"gorm.io/gorm"
 )
 
 var (
 	jobQueue   chan worker.Job
 	envDecoder = EnvConf{}
+	dbConn     *gorm.DB
 )
 
 // EnvConf holds the environment variables for this binary
@@ -50,12 +53,20 @@ func main() {
 	log.Printf("setting max worker count to: %d\n", envDecoder.MaxWorkers)
 	log.Printf("setting max job queue count to: %d\n", envDecoder.MaxQueue)
 
+	db, err := adapter.New(&envDecoder.DBConf)
+
+	if err != nil {
+		log.Fatalln(err)
+	}
+
+	dbConn = db
+
 	jobQueue = make(chan worker.Job, envDecoder.MaxQueue)
 	d := worker.NewDispatcher(int(envDecoder.MaxWorkers))
 
 	log.Println("starting worker dispatcher")
 
-	err := d.Run(jobQueue)
+	err = d.Run(jobQueue)
 
 	if err != nil {
 		log.Fatalln(err)
@@ -115,6 +126,8 @@ func httpService() http.Handler {
 	r.Use(middleware.Heartbeat("/ping"))
 	r.Use(middleware.AllowContentType("application/json"))
 
+	r.Mount("/debug", middleware.Profiler())
+
 	log.Println("setting up HTTP POST endpoint to enqueue jobs")
 
 	r.Post("/enqueue/{id}", func(w http.ResponseWriter, r *http.Request) {
@@ -134,7 +147,7 @@ func httpService() http.Handler {
 
 func getJob(id string) worker.Job {
 	if id == "helm-revisions-count-tracker" {
-		newJob, err := jobs.NewHelmRevisionsCountTracker(time.Now().UTC(), &jobs.HelmRevisionsCountTrackerOpts{
+		newJob, err := jobs.NewHelmRevisionsCountTracker(dbConn, time.Now().UTC(), &jobs.HelmRevisionsCountTrackerOpts{
 			DBConf:             &envDecoder.DBConf,
 			DOClientID:         envDecoder.DOClientID,
 			DOClientSecret:     envDecoder.DOClientSecret,