Sfoglia il codice sorgente

Merge branch 'master' into fe-perm

jusrhee 2 anni fa
parent
commit
9fac4fd341
64 ha cambiato i file con 2701 aggiunte e 1175 eliminazioni
  1. 1 1
      .github/actions/porter-deploy/action.yml
  2. 2 1
      .github/workflows/app_tests_base.yml
  3. 1 1
      .github/workflows/porter_stack_porter-sandbox.yml
  4. 31 0
      api/client/env_groups.go
  5. 32 56
      api/client/porter_app.go
  6. 0 9
      api/server/handlers/datastore/create_proxy.go
  7. 117 0
      api/server/handlers/datastore/credential.go
  8. 100 20
      api/server/handlers/datastore/get.go
  9. 26 56
      api/server/handlers/datastore/list.go
  10. 0 104
      api/server/handlers/datastore/status.go
  11. 36 24
      api/server/handlers/environment_groups/create.go
  12. 104 0
      api/server/handlers/porter_app/app_instances.go
  13. 174 0
      api/server/handlers/porter_app/cloudsql.go
  14. 38 17
      api/server/handlers/porter_app/update_app.go
  15. 0 476
      api/server/handlers/porter_app/update_app_environment_group.go
  16. 176 0
      api/server/handlers/project_integration/get_cloud_provider_permissions_status.go
  17. 0 29
      api/server/router/cluster.go
  18. 60 0
      api/server/router/deployment_target.go
  19. 29 29
      api/server/router/porter_app.go
  20. 28 0
      api/server/router/project_integration.go
  21. 1 1
      cli/cmd/commands/datastore.go
  22. 218 12
      cli/cmd/commands/env.go
  23. 13 13
      dashboard/package-lock.json
  24. 1 1
      dashboard/package.json
  25. 3 0
      dashboard/src/assets/information-circle-contained.svg
  26. 1 1
      dashboard/src/components/GCPCredentialsForm.tsx
  27. 9 5
      dashboard/src/components/porter-form/field-components/ServiceRow.tsx
  28. 40 0
      dashboard/src/components/porter/PorterOperatorComponent.tsx
  29. 15 0
      dashboard/src/lib/clusters/constants.ts
  30. 1 0
      dashboard/src/lib/clusters/types.ts
  31. 3 11
      dashboard/src/lib/hooks/useCloudProvider.ts
  32. 47 0
      dashboard/src/lib/hooks/useCloudSqlSecret.ts
  33. 51 0
      dashboard/src/lib/hooks/useLatestAppRevisions.ts
  34. 41 0
      dashboard/src/lib/porter-apps/index.ts
  35. 136 1
      dashboard/src/main/home/app-dashboard/app-view/tabs/Settings.tsx
  36. 14 1
      dashboard/src/main/home/app-dashboard/apps/types.ts
  37. 23 26
      dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx
  38. 3 1
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Main.tsx
  39. 0 23
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  40. 2 3
      dashboard/src/main/home/database-dashboard/DatabaseTabs.tsx
  41. 38 53
      dashboard/src/main/home/env-dashboard/CreateEnvGroup.tsx
  42. 3 12
      dashboard/src/main/home/env-dashboard/tabs/EnvVarsTab.tsx
  43. 8 2
      dashboard/src/main/home/infrastructure-dashboard/forms/CloudProviderSelect.tsx
  44. 4 1
      dashboard/src/main/home/infrastructure-dashboard/forms/aws/ConfigureEKSCluster.tsx
  45. 88 37
      dashboard/src/main/home/infrastructure-dashboard/forms/aws/GrantAWSPermissions.tsx
  46. 1 0
      dashboard/src/main/home/infrastructure-dashboard/forms/azure/ConfigureAKSCluster.tsx
  47. 1 1
      dashboard/src/main/home/infrastructure-dashboard/forms/azure/CreateAKSClusterForm.tsx
  48. 4 1
      dashboard/src/main/home/infrastructure-dashboard/forms/gcp/ConfigureGKECluster.tsx
  49. 2 2
      dashboard/src/main/home/infrastructure-dashboard/forms/gcp/GrantGCPPermissions.tsx
  50. 12 16
      dashboard/src/main/home/infrastructure-dashboard/modals/PreflightChecksModal.tsx
  51. 352 99
      dashboard/src/main/home/infrastructure-dashboard/shared/NodeGroups.tsx
  52. 50 14
      dashboard/src/shared/api.tsx
  53. 2 2
      go.mod
  54. 6 4
      go.sum
  55. 32 0
      internal/datastore/datastore.go
  56. 4 9
      internal/helm/agent.go
  57. 25 0
      internal/kubernetes/agent.go
  58. 3 0
      internal/models/datastore.go
  59. 10 0
      internal/porter_app/revisions.go
  60. 82 0
      internal/porter_app/test/patch_test.go
  61. 63 0
      internal/porter_app/testdata/app_proto_postpatch.json
  62. 58 0
      internal/porter_app/testdata/app_proto_prepatch.json
  63. 214 0
      internal/porter_app/v2/apply_flags.go
  64. 62 0
      internal/porter_app/v2/patch.go

+ 1 - 1
.github/actions/porter-deploy/action.yml

@@ -39,7 +39,7 @@ runs:
     - name: Deploy stack
       uses: porter-dev/porter-cli-action@v0.1.0
       with:
-        command: apply
+        command: apply --wait
       env:
         PORTER_CLUSTER: "${{ inputs.cluster }}"
         PORTER_HOST: "${{ inputs.host }}"

+ 2 - 1
.github/workflows/app_tests_base.yml

@@ -27,6 +27,7 @@ jobs:
   integration-tests:
     name: Run app tests
     runs-on: ubuntu-latest
+    timeout-minutes: 7
     strategy:
       matrix:
         yaml: ['js-test-app-buildpack', 'js-test-app-dockerfile', 'nginx', 'next-test-app-dockerfile']
@@ -56,4 +57,4 @@ jobs:
         env:
           RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
         run: |
-          curl -X POST -H 'Content-type: application/json' --data "{\"text\":\"<!subteam^S05LXJ5DU9L> \`${{ env.repo }}\` integration tests failed in \`${{ inputs.STAGE }}\`: $RUN_URL \"}" ${{ secrets.slack_webhook_url }}
+          curl -X POST -H 'Content-type: application/json' --data "{\"text\":\"<!subteam^S05MGRLKF33> \`${{ env.repo }}\` integration tests failed in \`${{ inputs.STAGE }}\`: $RUN_URL \"}" ${{ secrets.slack_webhook_url }}

+ 1 - 1
.github/workflows/porter_stack_porter-sandbox.yml

@@ -43,7 +43,7 @@ jobs:
       uses: porter-dev/setup-porter@v0.1.0
     - name: Deploy stack
       timeout-minutes: 30
-      run: exec porter apply -f ./porter.yaml
+      run: exec porter apply -f ./porter.yaml --wait
       env:
         PORTER_CLUSTER: "11"
         PORTER_HOST: https://dashboard.internal-tools.porter.run

+ 31 - 0
api/client/env_groups.go

@@ -23,3 +23,34 @@ func (c *Client) GetLatestEnvGroupVariables(
 
 	return resp, err
 }
+
+// UpdateEnvGroupInput is the input for the UpdateEnvGroup method
+type UpdateEnvGroupInput struct {
+	ProjectID     uint
+	ClusterID     uint
+	EnvGroupName  string
+	Variables     map[string]string
+	Secrets       map[string]string
+	Deletions     environment_groups.EnvVariableDeletions
+	SkipRedeploys bool
+}
+
+// UpdateEnvGroup creates or updates an environment group with the provided variables
+func (c *Client) UpdateEnvGroup(
+	ctx context.Context,
+	inp UpdateEnvGroupInput,
+) error {
+	req := &environment_groups.UpdateEnvironmentGroupRequest{
+		Name:              inp.EnvGroupName,
+		Variables:         inp.Variables,
+		SecretVariables:   inp.Secrets,
+		Deletions:         inp.Deletions,
+		SkipAppAutoDeploy: inp.SkipRedeploys,
+	}
+
+	return c.postRequest(
+		fmt.Sprintf("/projects/%d/clusters/%d/environment-groups", inp.ProjectID, inp.ClusterID),
+		req,
+		nil,
+	)
+}

+ 32 - 56
api/client/porter_app.go

@@ -199,19 +199,23 @@ func (c *Client) GetAppManifests(
 
 // UpdateAppInput is the input struct to UpdateApp
 type UpdateAppInput struct {
-	ProjectID          uint
-	ClusterID          uint
-	Name               string
-	ImageTagOverride   string
-	GitSource          porter_app.GitSource
-	DeploymentTargetId string
-	CommitSHA          string
-	AppRevisionID      string
-	Base64AppProto     string
-	Base64PorterYAML   string
-	IsEnvOverride      bool
-	WithPredeploy      bool
-	Exact              bool
+	ProjectID            uint
+	ClusterID            uint
+	Name                 string
+	ImageTagOverride     string
+	GitSource            porter_app.GitSource
+	DeploymentTargetId   string
+	DeploymentTargetName string
+	CommitSHA            string
+	AppRevisionID        string
+	Base64AppProto       string
+	Base64PorterYAML     string
+	IsEnvOverride        bool
+	WithPredeploy        bool
+	Exact                bool
+	Variables            map[string]string
+	Secrets              map[string]string
+	Deletions            porter_app.Deletions
 }
 
 // UpdateApp updates a porter app
@@ -222,17 +226,21 @@ func (c *Client) UpdateApp(
 	resp := &porter_app.UpdateAppResponse{}
 
 	req := &porter_app.UpdateAppRequest{
-		Name:               inp.Name,
-		GitSource:          inp.GitSource,
-		DeploymentTargetId: inp.DeploymentTargetId,
-		CommitSHA:          inp.CommitSHA,
-		ImageTagOverride:   inp.ImageTagOverride,
-		AppRevisionID:      inp.AppRevisionID,
-		Base64AppProto:     inp.Base64AppProto,
-		Base64PorterYAML:   inp.Base64PorterYAML,
-		IsEnvOverride:      inp.IsEnvOverride,
-		WithPredeploy:      inp.WithPredeploy,
-		Exact:              inp.Exact,
+		Name:                 inp.Name,
+		GitSource:            inp.GitSource,
+		DeploymentTargetId:   inp.DeploymentTargetId,
+		DeploymentTargetName: inp.DeploymentTargetName,
+		CommitSHA:            inp.CommitSHA,
+		ImageTagOverride:     inp.ImageTagOverride,
+		AppRevisionID:        inp.AppRevisionID,
+		Base64AppProto:       inp.Base64AppProto,
+		Base64PorterYAML:     inp.Base64PorterYAML,
+		IsEnvOverride:        inp.IsEnvOverride,
+		WithPredeploy:        inp.WithPredeploy,
+		Exact:                inp.Exact,
+		Variables:            inp.Variables,
+		Secrets:              inp.Secrets,
+		Deletions:            inp.Deletions,
 	}
 
 	err := c.postRequest(
@@ -574,38 +582,6 @@ func (c *Client) ReportRevisionStatus(
 	return resp, err
 }
 
-// CreateOrUpdateAppEnvironment updates the app environment group and creates it if it doesn't exist
-func (c *Client) CreateOrUpdateAppEnvironment(
-	ctx context.Context,
-	projectID uint, clusterID uint,
-	appName string,
-	deploymentTargetID string,
-	variables map[string]string,
-	secrets map[string]string,
-	Base64AppProto string,
-) (*porter_app.UpdateAppEnvironmentResponse, error) {
-	resp := &porter_app.UpdateAppEnvironmentResponse{}
-
-	req := &porter_app.UpdateAppEnvironmentRequest{
-		DeploymentTargetID: deploymentTargetID,
-		Variables:          variables,
-		Secrets:            secrets,
-		HardUpdate:         false,
-		Base64AppProto:     Base64AppProto,
-	}
-
-	err := c.postRequest(
-		fmt.Sprintf(
-			"/projects/%d/clusters/%d/apps/%s/update-environment",
-			projectID, clusterID, appName,
-		),
-		req,
-		resp,
-	)
-
-	return resp, err
-}
-
 // PorterYamlV2Pods gets all pods for a given deployment target id and app name
 func (c *Client) PorterYamlV2Pods(
 	ctx context.Context,

+ 0 - 9
api/server/handlers/datastore/create_proxy.go

@@ -17,15 +17,6 @@ import (
 	"github.com/porter-dev/porter/internal/telemetry"
 )
 
-// Credential has all information about connecting to a datastore
-type Credential struct {
-	Host         string `json:"host"`
-	Port         int    `json:"port"`
-	Username     string `json:"username"`
-	Password     string `json:"password"`
-	DatabaseName string `json:"database_name"`
-}
-
 // CreateDatastoreProxyResponse is the response body for the create datastore proxy endpoint
 type CreateDatastoreProxyResponse struct {
 	// PodName is the name of the pod that was created

+ 117 - 0
api/server/handlers/datastore/credential.go

@@ -0,0 +1,117 @@
+package datastore
+
+import (
+	"net/http"
+
+	"connectrpc.com/connect"
+	"github.com/google/uuid"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"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/telemetry"
+)
+
+// Credential has all information about connecting to a datastore
+type Credential struct {
+	Host         string `json:"host"`
+	Port         int    `json:"port"`
+	Username     string `json:"username"`
+	Password     string `json:"password"`
+	DatabaseName string `json:"database_name"`
+}
+
+// GetDatastoreCredentialsResponse describes the datastore credentials response body
+type GetDatastoreCredentialsResponse struct {
+	// Credential is the credential that has been retrieved for this datastore
+	Credential Credential `json:"credential"`
+}
+
+// GetDatastoreCredentialsHandler is a struct for retrieving credentials for datastore
+type GetDatastoreCredentialsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewGetDatastoreCredentialsHandler returns a DatastoreCredentialsHandler
+func NewGetDatastoreCredentialsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetDatastoreCredentialsHandler {
+	return &GetDatastoreCredentialsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// ServeHTTP retrieves the credentials for a datastore
+func (c *GetDatastoreCredentialsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-get-datastore-credentials")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	if project.ID == 0 {
+		err := telemetry.Error(ctx, span, nil, "project not found")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	projectId := int64(project.ID)
+
+	var resp GetDatastoreCredentialsResponse
+
+	datastoreName, reqErr := requestutils.GetURLParamString(r, types.URLParamDatastoreName)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, nil, "error parsing datastore name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "datastore-name", Value: datastoreName})
+
+	datastoreRecord, err := c.Repo().Datastore().GetByProjectIDAndName(ctx, project.ID, datastoreName)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "datastore record not found")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if datastoreRecord == nil || datastoreRecord.ID == uuid.Nil {
+		err = telemetry.Error(ctx, span, nil, "datastore record does not exist")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	message := porterv1.CreateDatastoreProxyRequest{
+		ProjectId:   projectId,
+		DatastoreId: datastoreRecord.ID.String(),
+	}
+	req := connect.NewRequest(&message)
+	ccpResp, err := c.Config().ClusterControlPlaneClient.CreateDatastoreProxy(ctx, req)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error creating datastore proxy")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if ccpResp == nil || ccpResp.Msg == nil {
+		err = telemetry.Error(ctx, span, nil, "error creating datastore proxy")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	resp = GetDatastoreCredentialsResponse{
+		Credential: Credential{
+			Host:         ccpResp.Msg.Credential.Host,
+			Port:         int(ccpResp.Msg.Credential.Port),
+			Username:     ccpResp.Msg.Credential.Username,
+			Password:     ccpResp.Msg.Credential.Password,
+			DatabaseName: ccpResp.Msg.Credential.DatabaseName,
+		},
+	}
+
+	c.WriteResult(w, r, resp)
+}

+ 100 - 20
api/server/handlers/datastore/get.go

@@ -1,10 +1,14 @@
 package datastore
 
 import (
+	"context"
+	"encoding/base64"
 	"net/http"
 
+	"connectrpc.com/connect"
 	"github.com/aws/aws-sdk-go/aws/arn"
 	"github.com/google/uuid"
+	"github.com/porter-dev/api-contracts/generated/go/helpers"
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -14,6 +18,7 @@ import (
 	"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/datastore"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/telemetry"
 )
@@ -21,7 +26,7 @@ import (
 // GetDatastoreResponse describes the list datastores response body
 type GetDatastoreResponse struct {
 	// Datastore is the datastore that has been retrieved
-	Datastore Datastore `json:"datastore"`
+	Datastore datastore.Datastore `json:"datastore"`
 }
 
 // GetDatastoreHandler is a struct for retrieving a datastore
@@ -76,33 +81,102 @@ func (c *GetDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	datastore := Datastore{
-		Name:                              datastoreRecord.Name,
-		Type:                              datastoreRecord.Type,
-		Engine:                            datastoreRecord.Engine,
-		CreatedAtUTC:                      datastoreRecord.CreatedAt,
-		Status:                            string(datastoreRecord.Status),
-		CloudProvider:                     datastoreRecord.CloudProvider,
-		CloudProviderCredentialIdentifier: datastoreRecord.CloudProviderCredentialIdentifier,
+	// TODO: delete this branch once all datastores are on the management cluster
+	if !datastoreRecord.OnManagementCluster {
+		awsArn, err := arn.Parse(datastoreRecord.CloudProviderCredentialIdentifier)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error parsing aws account id")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		datastore, err := c.LEGACY_handleGetDatastore(ctx, project.ID, awsArn.AccountID, datastoreName)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error retrieving datastore")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+		resp.Datastore = datastore
+		c.WriteResult(w, r, resp)
+		return
 	}
 
-	if datastoreRecord.CloudProvider != SupportedDatastoreCloudProvider_AWS {
-		err = telemetry.Error(ctx, span, nil, "unsupported datastore cloud provider")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+	req := connect.NewRequest(&porterv1.ReadCloudContractRequest{
+		ProjectId: int64(project.ID),
+	})
+	ccpResp, err := c.Config().ClusterControlPlaneClient.ReadCloudContract(ctx, req)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting cloud contract")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if ccpResp.Msg == nil {
+		err = telemetry.Error(ctx, span, nil, "cloud contract not found")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusNotFound))
+		return
+	}
+
+	cloudContract := ccpResp.Msg.CloudContract
+	if cloudContract == nil {
+		err = telemetry.Error(ctx, span, nil, "cloud contract is empty")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusNotFound))
 		return
 	}
 
-	awsArn, err := arn.Parse(datastoreRecord.CloudProviderCredentialIdentifier)
+	datastores := cloudContract.Datastores
+	if datastores == nil {
+		err = telemetry.Error(ctx, span, nil, "datastores is empty")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusNotFound))
+		return
+	}
+
+	var matchingDatastore *porterv1.ManagedDatastore
+	for _, ds := range datastores {
+		if ds.Id == datastoreRecord.ID.String() {
+			matchingDatastore = ds
+			break
+		}
+	}
+	if matchingDatastore == nil {
+		err = telemetry.Error(ctx, span, nil, "datastore not found")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusNotFound))
+		return
+	}
+	encoded, err := helpers.MarshalContractObject(ctx, matchingDatastore)
 	if err != nil {
-		err = telemetry.Error(ctx, span, err, "error parsing aws account id")
+		err = telemetry.Error(ctx, span, err, "error marshaling datastore")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
+	b64 := base64.StdEncoding.EncodeToString(encoded)
+
+	datastore := datastore.Datastore{
+		Name:                              datastoreRecord.Name,
+		Type:                              datastoreRecord.Type,
+		Engine:                            datastoreRecord.Engine,
+		CreatedAtUTC:                      datastoreRecord.CreatedAt,
+		Status:                            string(datastoreRecord.Status),
+		CloudProvider:                     SupportedDatastoreCloudProvider_AWS,
+		CloudProviderCredentialIdentifier: datastoreRecord.CloudProviderCredentialIdentifier,
+		B64Proto:                          b64,
+	}
+
+	resp.Datastore = datastore
+	c.WriteResult(w, r, resp)
+}
+
+// LEGACY_handleGetDatastore retrieves the datastore in the given project for datastores that are on the customer clusters rather than the management cluster
+func (c *GetDatastoreHandler) LEGACY_handleGetDatastore(ctx context.Context, projectId uint, accountId string, datastoreName string) (datastore.Datastore, error) {
+	ctx, span := telemetry.NewSpan(ctx, "legacy-handle-get-datastore")
+	defer span.End()
+
+	var datastore datastore.Datastore
+
 	datastores, err := Datastores(ctx, DatastoresInput{
-		ProjectID: project.ID,
+		ProjectID: projectId,
 		CloudProvider: cloud_provider.CloudProvider{
-			AccountID: awsArn.AccountID,
+			AccountID: accountId,
 			Type:      porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_AWS,
 		},
 		Name:                datastoreName,
@@ -111,11 +185,17 @@ func (c *GetDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		CCPClient:           c.Config().ClusterControlPlaneClient,
 		DatastoreRepository: c.Repo().Datastore(),
 	})
-	if err == nil && len(datastores) == 1 {
-		datastore = datastores[0]
+	if err != nil {
+		return datastore, err
 	}
 
-	resp.Datastore = datastore
+	if len(datastores) != 1 {
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "datastore-count", Value: len(datastores)})
+		if len(datastores) == 0 {
+			return datastore, telemetry.Error(ctx, span, nil, "datastore not found")
+		}
+		return datastore, telemetry.Error(ctx, span, nil, "unexpected number of datastores found matching filters")
+	}
 
-	c.WriteResult(w, r, resp)
+	return datastores[0], nil
 }

+ 26 - 56
api/server/handlers/datastore/list.go

@@ -3,7 +3,6 @@ package datastore
 import (
 	"context"
 	"net/http"
-	"time"
 
 	"connectrpc.com/connect"
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
@@ -16,6 +15,7 @@ import (
 	"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/datastore"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/telemetry"
@@ -40,37 +40,7 @@ type ListDatastoresRequest struct {
 // ListDatastoresResponse describes the list datastores response body
 type ListDatastoresResponse struct {
 	// Datastores is a list of datastore entries for the http response
-	Datastores []Datastore `json:"datastores"`
-}
-
-// Datastore describes an outbound datastores response entry
-type Datastore struct {
-	// Name is the name of the datastore
-	Name string `json:"name"`
-
-	// Type is the type of the datastore
-	Type string `json:"type"`
-
-	// Engine is the engine of the datastore
-	Engine string `json:"engine,omitempty"`
-
-	// Env is the env group for the datastore
-	Env environment_groups.EnvironmentGroupListItem `json:"env,omitempty"`
-
-	// Metadata is a list of metadata objects for the datastore
-	Metadata []*porterv1.DatastoreMetadata `json:"metadata,omitempty"`
-
-	// Status is the status of the datastore
-	Status string `json:"status"`
-
-	// CreatedAtUTC is the time the datastore was created in UTC
-	CreatedAtUTC time.Time `json:"created_at"`
-
-	// CloudProvider is the cloud provider associated with the datastore
-	CloudProvider string `json:"cloud_provider"`
-
-	// CloudProviderCredentialIdentifier is the cloud provider credential identifier associated with the datastore
-	CloudProviderCredentialIdentifier string `json:"cloud_provider_credential_identifier"`
+	Datastores []datastore.Datastore `json:"datastores"`
 }
 
 // ListDatastoresHandler is a struct for listing all datastores for a given project
@@ -99,7 +69,7 @@ func (h *ListDatastoresHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
 	resp := ListDatastoresResponse{}
-	datastoreList := []Datastore{}
+	datastoreList := []datastore.Datastore{}
 
 	datastores, err := h.Repo().Datastore().ListByProjectID(ctx, project.ID)
 	if err != nil {
@@ -108,15 +78,15 @@ func (h *ListDatastoresHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		return
 	}
 
-	for _, datastore := range datastores {
-		datastoreList = append(datastoreList, Datastore{
-			Name:                              datastore.Name,
-			Type:                              datastore.Type,
-			Engine:                            datastore.Engine,
-			CreatedAtUTC:                      datastore.CreatedAt,
-			Status:                            string(datastore.Status),
-			CloudProvider:                     datastore.CloudProvider,
-			CloudProviderCredentialIdentifier: datastore.CloudProviderCredentialIdentifier,
+	for _, ds := range datastores {
+		datastoreList = append(datastoreList, datastore.Datastore{
+			Name:                              ds.Name,
+			Type:                              ds.Type,
+			Engine:                            ds.Engine,
+			CreatedAtUTC:                      ds.CreatedAt,
+			Status:                            string(ds.Status),
+			CloudProvider:                     ds.CloudProvider,
+			CloudProviderCredentialIdentifier: ds.CloudProviderCredentialIdentifier,
 		})
 	}
 
@@ -139,7 +109,7 @@ type DatastoresInput struct {
 }
 
 // Datastores returns a list of datastores associated with the specified project/cloud-provider
-func Datastores(ctx context.Context, inp DatastoresInput) ([]Datastore, error) {
+func Datastores(ctx context.Context, inp DatastoresInput) ([]datastore.Datastore, error) {
 	ctx, span := telemetry.NewSpan(ctx, "datastores-for-cloud-provider")
 	defer span.End()
 
@@ -153,7 +123,7 @@ func Datastores(ctx context.Context, inp DatastoresInput) ([]Datastore, error) {
 		telemetry.AttributeKV{Key: "project-id", Value: inp.ProjectID},
 	)
 
-	datastores := []Datastore{}
+	datastores := []datastore.Datastore{}
 
 	if inp.ProjectID == 0 {
 		return datastores, telemetry.Error(ctx, span, nil, "project id must be specified")
@@ -185,30 +155,30 @@ func Datastores(ctx context.Context, inp DatastoresInput) ([]Datastore, error) {
 		return datastores, telemetry.Error(ctx, span, nil, "missing response message from ccp")
 	}
 
-	for _, datastore := range resp.Msg.Datastores {
-		datastoreRecord, err := inp.DatastoreRepository.GetByProjectIDAndName(ctx, inp.ProjectID, datastore.Name)
+	for _, ds := range resp.Msg.Datastores {
+		datastoreRecord, err := inp.DatastoreRepository.GetByProjectIDAndName(ctx, inp.ProjectID, ds.Name)
 		if err != nil {
-			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "err-datastore-name", Value: datastore.Name})
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "err-datastore-name", Value: ds.Name})
 			return datastores, telemetry.Error(ctx, span, err, "datastore record not found")
 		}
 
-		encodedDatastore := Datastore{
-			Name:                              datastore.Name,
+		encodedDatastore := datastore.Datastore{
+			Name:                              ds.Name,
 			Type:                              datastoreRecord.Type,
 			Engine:                            datastoreRecord.Engine,
 			CreatedAtUTC:                      datastoreRecord.CreatedAt,
 			Status:                            string(datastoreRecord.Status),
-			Metadata:                          datastore.Metadata,
+			Metadata:                          ds.Metadata,
 			CloudProvider:                     datastoreRecord.CloudProvider,
 			CloudProviderCredentialIdentifier: datastoreRecord.CloudProviderCredentialIdentifier,
 		}
-		if inp.IncludeEnvGroup && datastore.Env != nil {
+		if inp.IncludeEnvGroup && ds.Env != nil {
 			encodedDatastore.Env = environment_groups.EnvironmentGroupListItem{
-				Name:               datastore.Env.Name,
-				LatestVersion:      int(datastore.Env.Version),
-				Variables:          datastore.Env.Variables,
-				SecretVariables:    datastore.Env.SecretVariables,
-				LinkedApplications: datastore.Env.LinkedApplications,
+				Name:               ds.Env.Name,
+				LatestVersion:      int(ds.Env.Version),
+				Variables:          ds.Env.Variables,
+				SecretVariables:    ds.Env.SecretVariables,
+				LinkedApplications: ds.Env.LinkedApplications,
 			}
 		}
 		datastores = append(datastores, encodedDatastore)

+ 0 - 104
api/server/handlers/datastore/status.go

@@ -1,104 +0,0 @@
-package datastore
-
-import (
-	"net/http"
-
-	"connectrpc.com/connect"
-	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
-	"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/models"
-	"github.com/porter-dev/porter/internal/telemetry"
-)
-
-// StatusRequest describes an inbound datastore status request
-type StatusRequest struct {
-	Type string `json:"type"`
-	Name string `json:"name"`
-}
-
-// StatusResponse describes an outbound datastore status response
-type StatusResponse struct {
-	Status string `json:"status"`
-}
-
-// StatusHandler is a struct for handling datastore status requests
-type StatusHandler struct {
-	handlers.PorterHandlerReadWriter
-	authz.KubernetesAgentGetter
-}
-
-// NewStatusHandler constructs a datastore StatusHandler
-func NewStatusHandler(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-	writer shared.ResultWriter,
-) *StatusHandler {
-	return &StatusHandler{
-		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
-		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
-	}
-}
-
-func (h *StatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ctx, span := telemetry.NewSpan(r.Context(), "serve-datastore-status")
-	defer span.End()
-	// read the project from context
-	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
-	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
-
-	request := &StatusRequest{}
-	if ok := h.DecodeAndValidate(w, r, request); !ok {
-		err := telemetry.Error(ctx, span, nil, "error decoding request")
-		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-
-	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "datastore-name", Value: request.Name},
-		telemetry.AttributeKV{Key: "datastore-type", Value: request.Type},
-	)
-
-	var datastoreType porterv1.EnumDatastore
-	switch request.Type {
-	case "rds-postgresql":
-		datastoreType = porterv1.EnumDatastore_ENUM_DATASTORE_RDS_POSTGRESQL
-	case "rds-postgresql-aurora":
-		datastoreType = porterv1.EnumDatastore_ENUM_DATASTORE_RDS_AURORA_POSTGRESQL
-	case "elasticache-redis":
-		datastoreType = porterv1.EnumDatastore_ENUM_DATASTORE_ELASTICACHE_REDIS
-	default:
-		err := telemetry.Error(ctx, span, nil, "invalid datastore specified")
-		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-
-	req := connect.NewRequest(&porterv1.DatastoreStatusRequest{
-		ProjectId: int64(project.ID),
-		ClusterId: int64(cluster.ID),
-		Type:      datastoreType,
-		Name:      request.Name,
-	})
-
-	resp, err := h.Config().ClusterControlPlaneClient.DatastoreStatus(ctx, req)
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error fetching datastore status from ccp")
-		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	if resp.Msg == nil {
-		err := telemetry.Error(ctx, span, err, "missing response message from ccp")
-		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "datastore-status", Value: resp.Msg.Status})
-	h.WriteResult(w, r, StatusResponse{
-		Status: resp.Msg.Status,
-	})
-}

+ 36 - 24
api/server/handlers/environment_groups/create.go

@@ -13,7 +13,6 @@ import (
 	"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/environment_groups"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/telemetry"
 )
@@ -34,6 +33,14 @@ func NewUpdateEnvironmentGroupHandler(
 	}
 }
 
+// EnvVariableDeletions is the set of keys to delete from the environment group
+type EnvVariableDeletions struct {
+	// Variables is a set of variable keys to delete from the environment group
+	Variables []string `json:"variables"`
+	// Secrets is a set of secret variable keys to delete from the environment group
+	Secrets []string `json:"secrets"`
+}
+
 type UpdateEnvironmentGroupRequest struct {
 	// Name of the env group to create or update
 	Name string `json:"name"`
@@ -49,6 +56,15 @@ type UpdateEnvironmentGroupRequest struct {
 
 	// SecretVariables are sensitive values. All values must be a string due to a kubernetes limitation.
 	SecretVariables map[string]string `json:"secret_variables"`
+
+	// IsEnvOverride is a flag to determine if provided variables should override or merge with existing variables
+	IsEnvOverride bool `json:"is_env_override"`
+
+	// Deletions is a set of keys to delete from the environment group
+	Deletions EnvVariableDeletions `json:"deletions"`
+
+	// SkipAppAutoDeploy is a flag to determine if the app should be auto deployed
+	SkipAppAutoDeploy bool `json:"skip_app_auto_deploy"`
 }
 type UpdateEnvironmentGroupResponse struct {
 	// Name of the env group to create or update
@@ -78,14 +94,6 @@ func (c *UpdateEnvironmentGroupHandler) ServeHTTP(w http.ResponseWriter, r *http
 		telemetry.AttributeKV{Key: "environment-group-type", Value: request.Type},
 	)
 
-	agent, err := c.GetAgent(r, cluster, "")
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "unable to connect to kubernetes cluster")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	var envGroup environment_groups.EnvironmentGroup
 	switch request.Type {
 	case "doppler":
 		_, err := c.Config().ClusterControlPlaneClient.CreateOrUpdateEnvGroup(ctx, connect.NewRequest(&porterv1.CreateOrUpdateEnvGroupRequest{
@@ -101,29 +109,33 @@ func (c *UpdateEnvironmentGroupHandler) ServeHTTP(w http.ResponseWriter, r *http
 			return
 		}
 
-		envGroup = environment_groups.EnvironmentGroup{
-			Name:         request.Name,
-			CreatedAtUTC: time.Now().UTC(),
-		}
 	default:
-		envGroup := environment_groups.EnvironmentGroup{
-			Name:            request.Name,
-			Variables:       request.Variables,
-			SecretVariables: request.SecretVariables,
-			CreatedAtUTC:    time.Now().UTC(),
-		}
-
-		err = environment_groups.CreateOrUpdateBaseEnvironmentGroup(ctx, agent, envGroup, nil)
+		_, err := c.Config().ClusterControlPlaneClient.CreateOrUpdateEnvGroup(ctx, connect.NewRequest(&porterv1.CreateOrUpdateEnvGroupRequest{
+			ProjectId:            int64(cluster.ProjectID),
+			ClusterId:            int64(cluster.ID),
+			EnvGroupProviderType: porterv1.EnumEnvGroupProviderType_ENUM_ENV_GROUP_PROVIDER_TYPE_PORTER,
+			EnvGroupName:         request.Name,
+			EnvVars: &porterv1.EnvGroupVariables{
+				Normal: request.Variables,
+				Secret: request.SecretVariables,
+			},
+			EnvVariableDeletions: &porterv1.EnvVariableDeletions{
+				Variables: request.Deletions.Variables,
+				Secrets:   request.Deletions.Secrets,
+			},
+			IsEnvOverride:     request.IsEnvOverride,
+			SkipAppAutoDeploy: request.SkipAppAutoDeploy,
+		}))
 		if err != nil {
-			err := telemetry.Error(ctx, span, err, "unable to create or update environment group")
+			err := telemetry.Error(ctx, span, err, "unable to create environment group")
 			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 			return
 		}
 	}
 
 	envGroupResponse := &UpdateEnvironmentGroupResponse{
-		Name:      envGroup.Name,
-		CreatedAt: envGroup.CreatedAtUTC,
+		Name:      request.Name,
+		CreatedAt: time.Now().UTC(),
 	}
 	c.WriteResult(w, r, envGroupResponse)
 }

+ 104 - 0
api/server/handlers/porter_app/app_instances.go

@@ -0,0 +1,104 @@
+package porter_app
+
+import (
+	"net/http"
+
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/porter_app"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// AppInstancesHandler is the handler for the /apps/instances endpoint
+type AppInstancesHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewAppInstancesHandler handles GET requests to the /apps/instances endpoint
+func NewAppInstancesHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *AppInstancesHandler {
+	return &AppInstancesHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// AppInstancesRequest is the request object for the /apps/instances endpoint
+type AppInstancesRequest struct {
+	DeploymentTargetID string `schema:"deployment_target_id"`
+}
+
+// AppInstancesResponse is the response object for the /apps/instances endpoint
+type AppInstancesResponse struct {
+	AppInstances []porter_app.AppInstance `json:"app_instances"`
+}
+
+// ServeHTTP translates the request into a ListAppInstancesRequest to the cluster control plane
+func (c *AppInstancesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-app-instances")
+	defer span.End()
+
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &AppInstancesRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "project-id", Value: project.ID},
+		telemetry.AttributeKV{Key: "cluster-id", Value: cluster.ID},
+		telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID},
+	)
+
+	var deploymentTargetIdentifier *porterv1.DeploymentTargetIdentifier
+	if request.DeploymentTargetID != "" {
+		deploymentTargetIdentifier = &porterv1.DeploymentTargetIdentifier{
+			Id: request.DeploymentTargetID,
+		}
+	}
+
+	listAppInstancesReq := connect.NewRequest(&porterv1.ListAppInstancesRequest{
+		ProjectId:                  int64(project.ID),
+		DeploymentTargetIdentifier: deploymentTargetIdentifier,
+	})
+
+	latestAppInstancesResp, err := c.Config().ClusterControlPlaneClient.ListAppInstances(ctx, listAppInstancesReq)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting latest app revisions")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if latestAppInstancesResp == nil || latestAppInstancesResp.Msg == nil {
+		err = telemetry.Error(ctx, span, nil, "latest app revisions response is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	var appInstances []porter_app.AppInstance
+
+	for _, instance := range latestAppInstancesResp.Msg.AppInstances {
+		appInstances = append(appInstances, porter_app.AppInstance{
+			Id: instance.Id,
+			DeploymentTarget: porter_app.DeploymentTarget{
+				ID:   instance.DeploymentTargetId,
+				Name: "",
+			},
+			Name: instance.Name,
+		})
+	}
+
+	c.WriteResult(w, r, AppInstancesResponse{AppInstances: appInstances})
+}

+ 174 - 0
api/server/handlers/porter_app/cloudsql.go

@@ -0,0 +1,174 @@
+package porter_app
+
+import (
+	"encoding/base64"
+	"fmt"
+	"net/http"
+
+	k8serrors "k8s.io/apimachinery/pkg/api/errors"
+
+	v1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"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/telemetry"
+)
+
+// GetCloudSqlSecretHandler is a handler to get the cloudsql secret
+type GetCloudSqlSecretHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewGetCloudSqlSecretHandler returns a GetCloudSqlSecretHandler
+func NewGetCloudSqlSecretHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *GetCloudSqlSecretHandler {
+	return &GetCloudSqlSecretHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// GetCloudSqlSecretResponse is the response payload for the GetCloudSqlSecretHandler
+type GetCloudSqlSecretResponse struct {
+	SecretName string `json:"secret_name"`
+}
+
+// ServeHTTP retrieves the cloudsql secret
+func (c *GetCloudSqlSecretHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	ctx, span := telemetry.NewSpan(ctx, "serve-get-cloudsql-secret")
+	defer span.End()
+
+	deploymentTarget, _ := ctx.Value(types.DeploymentTargetScope).(types.DeploymentTarget)
+
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, nil, "error parsing porter app name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "application-name", Value: appName})
+
+	cluster, err := c.Repo().Cluster().ReadCluster(deploymentTarget.ProjectID, deploymentTarget.ClusterID)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error reading cluster")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	agent, err := c.GetAgent(r, cluster, deploymentTarget.Namespace)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	secret, err := agent.GetSecret(fmt.Sprintf("cloudsql-secret-%s", appName), deploymentTarget.Namespace)
+	if err != nil && !k8serrors.IsNotFound(err) {
+		err = telemetry.Error(ctx, span, err, "error getting secret")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	var secretName string
+	if secret != nil {
+		secretName = secret.Name
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "secret-name", Value: secretName})
+
+	c.WriteResult(w, r, GetCloudSqlSecretResponse{SecretName: secretName})
+}
+
+// CreateCloudSqlSecretHandler is a handler to create the cloudsql secret
+type CreateCloudSqlSecretHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewCreateCloudSqlSecretHandler returns a CreateCloudSqlSecretHandler
+func NewCreateCloudSqlSecretHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateCloudSqlSecretHandler {
+	return &CreateCloudSqlSecretHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// CreateCloudSqlSecretRequest is the request payload for the CreateCloudSqlSecretHandler
+type CreateCloudSqlSecretRequest struct {
+	B64ServiceAccountJson string `json:"b64_service_account_json"`
+}
+
+// ServeHTTP creates the cloudsql secret
+func (c *CreateCloudSqlSecretHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	ctx, span := telemetry.NewSpan(ctx, "serve-create-cloudsql-secret")
+	defer span.End()
+
+	deploymentTarget, _ := ctx.Value(types.DeploymentTargetScope).(types.DeploymentTarget)
+
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, nil, "error parsing porter app name")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "application-name", Value: appName})
+
+	request := &CreateCloudSqlSecretRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	cluster, err := c.Repo().Cluster().ReadCluster(deploymentTarget.ProjectID, deploymentTarget.ClusterID)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error reading cluster")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	agent, err := c.GetAgent(r, cluster, deploymentTarget.Namespace)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting agent")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	decoded, err := base64.StdEncoding.DecodeString(request.B64ServiceAccountJson)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error decoding base64 service account json")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	secret := &v1.Secret{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: fmt.Sprintf("cloudsql-secret-%s", appName),
+		},
+		Data: map[string][]byte{
+			"service_account.json": decoded,
+		},
+	}
+
+	_, err = agent.CreateSecret(secret, deploymentTarget.Namespace)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error creating secret")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+}

+ 38 - 17
api/server/handlers/porter_app/update_app.go

@@ -43,13 +43,21 @@ type ServiceDeletions struct {
 	IngressAnnotationKeys []string `json:"ingress_annotation_keys"`
 }
 
+// EnvVariableDeletions is the set of keys to delete from the environment group
+type EnvVariableDeletions struct {
+	// Variables is a set of variable keys to delete from the environment group
+	Variables []string `json:"variables"`
+	// Secrets is a set of secret variable keys to delete from the environment group
+	Secrets []string `json:"secrets"`
+}
+
 // Deletions are the names of services and env variables to delete
 type Deletions struct {
-	ServiceNames     []string                    `json:"service_names"`
-	Predeploy        []string                    `json:"predeploy"`
-	EnvVariableNames []string                    `json:"env_variable_names"`
-	EnvGroupNames    []string                    `json:"env_group_names"`
-	ServiceDeletions map[string]ServiceDeletions `json:"service_deletions"`
+	ServiceNames         []string                    `json:"service_names"`
+	Predeploy            []string                    `json:"predeploy"`
+	EnvGroupNames        []string                    `json:"env_group_names"`
+	ServiceDeletions     map[string]ServiceDeletions `json:"service_deletions"`
+	EnvVariableDeletions EnvVariableDeletions        `json:"env_variable_deletions"`
 }
 
 // UpdateAppRequest is the request object for the POST /apps/update endpoint
@@ -60,6 +68,8 @@ type UpdateAppRequest struct {
 	GitSource GitSource `json:"git_source,omitempty"`
 	// DeploymentTargetId is the ID of the deployment target to apply the update to
 	DeploymentTargetId string `json:"deployment_target_id"`
+	// DeploymentTargetName is the name of the deployment target to apply the update to
+	DeploymentTargetName string `json:"deployment_target_name"`
 	// Variables is a map of environment variable names to values
 	Variables map[string]string `json:"variables"`
 	// Secrets is a map of secret names to values
@@ -116,12 +126,21 @@ func (c *UpdateAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
-	if request.DeploymentTargetId == "" {
-		err := telemetry.Error(ctx, span, nil, "deployment target id is empty")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
+
 	deploymentTargetID := request.DeploymentTargetId
+	deploymentTargetName := request.DeploymentTargetName
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "deployment-target-id", Value: deploymentTargetID},
+		telemetry.AttributeKV{Key: "deployment-target-name", Value: deploymentTargetName},
+	)
+
+	var deploymentTargetIdentifer *porterv1.DeploymentTargetIdentifier
+	if deploymentTargetID != "" || deploymentTargetName != "" {
+		deploymentTargetIdentifer = &porterv1.DeploymentTargetIdentifier{
+			Id:   deploymentTargetID,
+			Name: deploymentTargetName,
+		}
+	}
 
 	telemetry.WithAttributes(span,
 		telemetry.AttributeKV{Key: "name", Value: request.Name},
@@ -258,12 +277,11 @@ func (c *UpdateAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	}
 
 	updateReq := connect.NewRequest(&porterv1.UpdateAppRequest{
-		ProjectId: int64(project.ID),
-		DeploymentTargetIdentifier: &porterv1.DeploymentTargetIdentifier{
-			Id: deploymentTargetID,
-		},
-		App:           appProto,
-		AppRevisionId: request.AppRevisionID,
+		ProjectId:                  int64(project.ID),
+		ClusterId:                  int64(cluster.ID),
+		DeploymentTargetIdentifier: deploymentTargetIdentifer,
+		App:                        appProto,
+		AppRevisionId:              request.AppRevisionID,
 		AppEnv: &porterv1.EnvGroupVariables{
 			Normal: envVariables,
 			Secret: request.Secrets,
@@ -274,9 +292,12 @@ func (c *UpdateAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		Deletions: &porterv1.Deletions{
 			ServiceNames:     request.Deletions.ServiceNames,
 			PredeployNames:   request.Deletions.Predeploy,
-			EnvVariableNames: request.Deletions.EnvVariableNames,
 			EnvGroupNames:    request.Deletions.EnvGroupNames,
 			ServiceDeletions: serviceDeletions,
+			EnvVariableDeletions: &porterv1.EnvVariableDeletions{
+				Variables: request.Deletions.EnvVariableDeletions.Variables,
+				Secrets:   request.Deletions.EnvVariableDeletions.Secrets,
+			},
 		},
 		AppOverrides:        overrides,
 		CommitSha:           request.CommitSHA,

+ 0 - 476
api/server/handlers/porter_app/update_app_environment_group.go

@@ -1,476 +0,0 @@
-package porter_app
-
-import (
-	"context"
-	"encoding/base64"
-	"net/http"
-	"strconv"
-	"strings"
-	"time"
-
-	"github.com/porter-dev/porter/internal/deployment_target"
-	"github.com/porter-dev/porter/internal/kubernetes"
-	"github.com/porter-dev/porter/internal/porter_app"
-
-	"github.com/porter-dev/porter/api/server/shared/requestutils"
-	"github.com/porter-dev/porter/internal/kubernetes/environment_groups"
-
-	"github.com/porter-dev/api-contracts/generated/go/helpers"
-	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
-
-	"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/models"
-	"github.com/porter-dev/porter/internal/telemetry"
-)
-
-// UpdateAppEnvironmentHandler handles the /apps/{porter_app_name}/update-environment endpoint
-type UpdateAppEnvironmentHandler struct {
-	handlers.PorterHandlerReadWriter
-	authz.KubernetesAgentGetter
-}
-
-// NewUpdateAppEnvironmentHandler returns a new UpdateAppEnvironmentHandler
-func NewUpdateAppEnvironmentHandler(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-	writer shared.ResultWriter,
-) *UpdateAppEnvironmentHandler {
-	return &UpdateAppEnvironmentHandler{
-		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
-		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
-	}
-}
-
-const (
-	// LabelKey_AppName is the label key for the app name
-	LabelKey_AppName = "porter.run/app-name"
-	// LabelKey_DeploymentTargetID is the label key for the deployment target id
-	LabelKey_DeploymentTargetID = "porter.run/deployment-target-id"
-	// LabelKey_PorterManaged is the label key signifying the resource is managed by porter
-	LabelKey_PorterManaged = "porter.run/managed"
-)
-
-// UpdateAppEnvironmentRequest represents the accepted fields on a request to the /apps/{porter_app_name}/environment-group endpoint
-type UpdateAppEnvironmentRequest struct {
-	Base64AppProto     string            `json:"b64_app_proto"`
-	DeploymentTargetID string            `json:"deployment_target_id"`
-	Variables          map[string]string `json:"variables"`
-	Secrets            map[string]string `json:"secrets"`
-	// HardUpdate is used to remove any variables that are not specified in the request.  If false, the request will only update the variables specified in the request,
-	// and leave all other variables untouched.
-	HardUpdate bool `json:"remove_missing"`
-}
-
-// UpdateAppEnvironmentResponse represents the fields on the response object from the /apps/{porter_app_name}/environment-group endpoint
-type UpdateAppEnvironmentResponse struct {
-	Base64AppProto string                                `json:"b64_app_proto"`
-	EnvGroups      []environment_groups.EnvironmentGroup `json:"env_groups"`
-}
-
-// ServeHTTP updates or creates the environment group for an app
-func (c *UpdateAppEnvironmentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ctx, span := telemetry.NewSpan(r.Context(), "serve-update-app-env-group")
-	defer span.End()
-	r = r.Clone(ctx)
-	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
-	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
-
-	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
-	if reqErr != nil {
-		err := telemetry.Error(ctx, span, nil, "error parsing porter app name")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-name", Value: appName})
-
-	request := &UpdateAppEnvironmentRequest{}
-	if ok := c.DecodeAndValidate(w, r, request); !ok {
-		err := telemetry.Error(ctx, span, nil, "invalid request")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-	porterApp, err := c.Config().Repo.PorterApp().ReadPorterAppByName(cluster.ID, appName)
-	if err != nil {
-		err := telemetry.Error(ctx, span, nil, "error getting porter app by name")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-	if porterApp.ID == 0 {
-		err := telemetry.Error(ctx, span, nil, "porter app not found")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusNotFound))
-		return
-	}
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "porter-app-id", Value: porterApp.ID})
-
-	if request.DeploymentTargetID == "" {
-		err := telemetry.Error(ctx, span, nil, "must provide deployment target id")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID})
-
-	appProto := &porterv1.PorterApp{}
-
-	if request.Base64AppProto == "" {
-		if appName == "" {
-			err := telemetry.Error(ctx, span, nil, "app name is empty and no base64 proto provided")
-			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-			return
-		}
-
-		appProto.Name = appName
-	} else {
-		decoded, err := base64.StdEncoding.DecodeString(request.Base64AppProto)
-		if err != nil {
-			err := telemetry.Error(ctx, span, err, "error decoding base yaml")
-			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-			return
-		}
-
-		err = helpers.UnmarshalContractObject(decoded, appProto)
-		if err != nil {
-			err := telemetry.Error(ctx, span, err, "error unmarshalling app proto")
-			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-			return
-		}
-	}
-
-	if appProto.Name == "" {
-		err := telemetry.Error(ctx, span, nil, "app proto name is empty")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-
-	deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
-		ProjectID:          int64(project.ID),
-		ClusterID:          int64(cluster.ID),
-		DeploymentTargetID: request.DeploymentTargetID,
-		CCPClient:          c.Config().ClusterControlPlaneClient,
-	})
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error getting deployment target details")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	namespace := deploymentTarget.Namespace
-	isPreview := deploymentTarget.IsPreview
-
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "is-preview", Value: isPreview})
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "namespace", Value: namespace})
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "hard-update", Value: request.HardUpdate})
-
-	appEnvGroupName, err := porter_app.AppEnvGroupName(ctx, appName, request.DeploymentTargetID, cluster.ID, c.Repo().PorterApp())
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error getting app env group name")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	agent, err := c.GetAgent(r, cluster, "")
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "unable to connect to kubernetes cluster")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	latestEnvironmentGroup, err := environment_groups.LatestBaseEnvironmentGroup(ctx, agent, appEnvGroupName)
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "unable to get latest base environment group")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "env-group-exists", Value: latestEnvironmentGroup.Name != ""})
-
-	previewTemplateEnvName, err := porter_app.AppTemplateEnvGroupName(ctx, appName, cluster.ID, c.Repo().PorterApp())
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error getting preview template env name")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	// filter out preview template and app env groups
-	filteredEnvGroups := []*porterv1.EnvGroup{}
-	for _, envGroup := range appProto.EnvGroups {
-		if envGroup.GetName() != previewTemplateEnvName && envGroup.GetName() != appEnvGroupName {
-			filteredEnvGroups = append(filteredEnvGroups, envGroup)
-		}
-	}
-
-	if latestEnvironmentGroup.Name != "" {
-		sameEnvGroup := true
-		for key, newValue := range request.Variables {
-			if existingValue, ok := latestEnvironmentGroup.Variables[key]; !ok || existingValue != newValue {
-				sameEnvGroup = false
-			}
-		}
-		for key, newValue := range request.Secrets {
-			// We cannot check if the values are the same because the existing secrets are substituted with dummy values. However, if the new value is a dummy value, then it is unchanged.
-			if _, ok := latestEnvironmentGroup.SecretVariables[key]; !ok || newValue != environment_groups.EnvGroupSecretDummyValue {
-				sameEnvGroup = false
-			}
-		}
-		if request.HardUpdate {
-			for key := range latestEnvironmentGroup.Variables {
-				if _, ok := request.Variables[key]; !ok {
-					sameEnvGroup = false
-				}
-			}
-			for key := range latestEnvironmentGroup.SecretVariables {
-				if _, ok := request.Secrets[key]; !ok {
-					sameEnvGroup = false
-				}
-			}
-		}
-		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "same-env-group", Value: sameEnvGroup})
-
-		if sameEnvGroup {
-			// even if the env group is the same, we still need to sync the latest versions of the other env groups
-			syncInp := syncLatestEnvGroupVersionsInput{
-				envGroups:          filteredEnvGroups,
-				appName:            appName,
-				namespace:          namespace,
-				deploymentTargetID: request.DeploymentTargetID,
-				k8sAgent:           agent,
-			}
-			latestEnvGroups, err := syncLatestEnvGroupVersions(ctx, syncInp)
-			if err != nil {
-				err := telemetry.Error(ctx, span, err, "error syncing latest env group versions")
-				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-				return
-			}
-
-			latestEnvGroups = append(latestEnvGroups, environment_groups.EnvironmentGroup{
-				Name:    latestEnvironmentGroup.Name,
-				Version: latestEnvironmentGroup.Version,
-			})
-
-			var protoEnvGroups []*porterv1.EnvGroup
-			for _, envGroup := range latestEnvGroups {
-				protoEnvGroups = append(protoEnvGroups, &porterv1.EnvGroup{
-					Name:    envGroup.Name,
-					Version: int64(envGroup.Version),
-				})
-			}
-			appProto.EnvGroups = protoEnvGroups
-
-			encodedApp, err := encodeAppProto(ctx, appProto)
-			if err != nil {
-				err := telemetry.Error(ctx, span, err, "error encoding app proto")
-				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-				return
-			}
-
-			res := &UpdateAppEnvironmentResponse{
-				EnvGroups:      latestEnvGroups,
-				Base64AppProto: encodedApp,
-			}
-
-			c.WriteResult(w, r, res)
-			return
-		}
-	}
-
-	// if this app does not have a default env group for this deployment target and is a preview
-	// then use the preview template env group as the default
-	// this should only run when the app is first deployed to a given deployment target
-	if latestEnvironmentGroup.Name == "" && isPreview {
-		latestEnvironmentGroup, err = environment_groups.LatestBaseEnvironmentGroup(ctx, agent, previewTemplateEnvName)
-		if err != nil {
-			err := telemetry.Error(ctx, span, err, "unable to get latest base environment group")
-			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-			return
-		}
-	}
-
-	variables := make(map[string]string)
-	secrets := make(map[string]string)
-
-	if !request.HardUpdate {
-		for key, value := range latestEnvironmentGroup.Variables {
-			variables[key] = value
-		}
-		for key, value := range latestEnvironmentGroup.SecretVariables {
-			secrets[key] = value
-		}
-	}
-
-	for key, value := range request.Variables {
-		if len(key) > 0 && len(value) > 0 {
-			variables[key] = value
-		}
-	}
-	for key, value := range request.Secrets {
-		if len(key) > 0 && len(value) > 0 {
-			secrets[key] = value
-		}
-	}
-
-	envGroup := environment_groups.EnvironmentGroup{
-		Name:            appEnvGroupName,
-		Variables:       variables,
-		SecretVariables: secrets,
-		CreatedAtUTC:    time.Now().UTC(),
-	}
-
-	additionalEnvGroupLabels := map[string]string{
-		LabelKey_AppName:                                  appName,
-		LabelKey_DeploymentTargetID:                       request.DeploymentTargetID,
-		environment_groups.LabelKey_DefaultAppEnvironment: "true",
-		LabelKey_PorterManaged:                            "true",
-	}
-
-	err = environment_groups.CreateOrUpdateBaseEnvironmentGroup(ctx, agent, envGroup, additionalEnvGroupLabels)
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "unable to create or update base environment group")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	inp := environment_groups.SyncLatestVersionToNamespaceInput{
-		BaseEnvironmentGroupName: appEnvGroupName,
-		TargetNamespace:          namespace,
-	}
-
-	syncedAppEnvironment, err := environment_groups.SyncLatestVersionToNamespace(ctx, agent, inp, additionalEnvGroupLabels)
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "unable to create or update synced environment group")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "env-group-versioned-name", Value: syncedAppEnvironment.EnvironmentGroupVersionedName})
-
-	syncInp := syncLatestEnvGroupVersionsInput{
-		envGroups:          filteredEnvGroups,
-		appName:            appName,
-		namespace:          namespace,
-		deploymentTargetID: request.DeploymentTargetID,
-		k8sAgent:           agent,
-	}
-	latestEnvGroups, err := syncLatestEnvGroupVersions(ctx, syncInp)
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error syncing latest env group versions")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	split := strings.Split(syncedAppEnvironment.EnvironmentGroupVersionedName, ".")
-	if len(split) != 2 {
-		err := telemetry.Error(ctx, span, err, "unexpected environment group versioned name")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	version, err := strconv.Atoi(split[1])
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error converting environment group version to int")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	latestEnvGroups = append(latestEnvGroups, environment_groups.EnvironmentGroup{
-		Name:    split[0],
-		Version: version,
-	})
-
-	var protoEnvGroups []*porterv1.EnvGroup
-	for _, envGroup := range latestEnvGroups {
-		protoEnvGroups = append(protoEnvGroups, &porterv1.EnvGroup{
-			Name:    envGroup.Name,
-			Version: int64(envGroup.Version),
-		})
-	}
-	appProto.EnvGroups = protoEnvGroups
-
-	encodedApp, err := encodeAppProto(ctx, appProto)
-	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error encoding app proto")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
-	res := &UpdateAppEnvironmentResponse{
-		EnvGroups:      latestEnvGroups,
-		Base64AppProto: encodedApp,
-	}
-
-	c.WriteResult(w, r, res)
-}
-
-type syncLatestEnvGroupVersionsInput struct {
-	// envGroups is the list of env groups to sync. We only need the names and will get the latest version of each from the porter-env-group ns
-	envGroups []*porterv1.EnvGroup
-	// appName is the name of the app
-	appName string
-	// namespace is the namespace to sync the latest versions to
-	namespace string
-	// deploymentTargetID is the id of the deployment target
-	deploymentTargetID string
-	// k8sAgent is the kubernetes agent
-	k8sAgent *kubernetes.Agent
-}
-
-// syncLatestEnvGroupVersions syncs the latest versions of the env groups to the namespace where an app is deployed
-func syncLatestEnvGroupVersions(ctx context.Context, inp syncLatestEnvGroupVersionsInput) ([]environment_groups.EnvironmentGroup, error) {
-	ctx, span := telemetry.NewSpan(ctx, "sync-latest-env-group-versions")
-	defer span.End()
-
-	var envGroups []environment_groups.EnvironmentGroup
-
-	if inp.deploymentTargetID == "" {
-		return envGroups, telemetry.Error(ctx, span, nil, "deployment target id is empty")
-	}
-	if inp.appName == "" {
-		return envGroups, telemetry.Error(ctx, span, nil, "app name is empty")
-	}
-	if inp.namespace == "" {
-		return envGroups, telemetry.Error(ctx, span, nil, "namespace is empty")
-	}
-	if inp.k8sAgent == nil {
-		return envGroups, telemetry.Error(ctx, span, nil, "k8s agent is nil")
-	}
-
-	for _, envGroup := range inp.envGroups {
-		if envGroup == nil {
-			continue
-		}
-
-		additionalEnvGroupLabels := map[string]string{
-			LabelKey_AppName:            inp.appName,
-			LabelKey_DeploymentTargetID: inp.deploymentTargetID,
-			LabelKey_PorterManaged:      "true",
-		}
-
-		syncedEnvironment, err := environment_groups.SyncLatestVersionToNamespace(ctx, inp.k8sAgent, environment_groups.SyncLatestVersionToNamespaceInput{
-			TargetNamespace:          inp.namespace,
-			BaseEnvironmentGroupName: envGroup.GetName(),
-		}, additionalEnvGroupLabels)
-		if err != nil {
-			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "env-group-name", Value: envGroup.GetName()})
-			return envGroups, telemetry.Error(ctx, span, err, "error syncing latest version to namespace")
-		}
-
-		split := strings.Split(syncedEnvironment.EnvironmentGroupVersionedName, ".")
-		if len(split) != 2 {
-			return envGroups, telemetry.Error(ctx, span, err, "unexpected environment group versioned name")
-		}
-
-		version, err := strconv.Atoi(split[1])
-		if err != nil {
-			return envGroups, telemetry.Error(ctx, span, err, "error converting environment group version to int")
-		}
-
-		envGroups = append(envGroups, environment_groups.EnvironmentGroup{
-			Name:    split[0],
-			Version: version,
-		})
-	}
-
-	return envGroups, nil
-}

+ 176 - 0
api/server/handlers/project_integration/get_cloud_provider_permissions_status.go

@@ -0,0 +1,176 @@
+package project_integration
+
+import (
+	"context"
+	"net/http"
+	"strings"
+
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// CloudProviderPermissionsStatusHandler is the handler for checking the status of cloud provider permissions
+type CloudProviderPermissionsStatusHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewCloudProviderPermissionsStatusHandler returns a handler for checking the status of cloud provider permissions
+func NewCloudProviderPermissionsStatusHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CloudProviderPermissionsStatusHandler {
+	return &CloudProviderPermissionsStatusHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// CloudProviderType is a type for the cloud provider
+type CloudProviderType string
+
+const (
+	// CloudProviderAWS is the AWS cloud provider
+	CloudProviderAWS CloudProviderType = "AWS"
+	// CloudProviderGCP is the GCP cloud provider
+	CloudProviderGCP CloudProviderType = "GCP"
+	// CloudProviderAzure is the Azure cloud provider
+	CloudProviderAzure CloudProviderType = "Azure"
+)
+
+// CloudProviderPermissionsStatusRequest is the request to check the status of cloud provider permissions
+type CloudProviderPermissionsStatusRequest struct {
+	CloudProvider                     CloudProviderType `schema:"cloud_provider"`
+	CloudProviderCredentialIdentifier string            `schema:"cloud_provider_credential_identifier"`
+}
+
+// CloudProviderPermissionsStatusResponse is the response to check the status of cloud provider permissions
+type CloudProviderPermissionsStatusResponse struct {
+	PercentCompleted float32 `json:"percent_completed"`
+}
+
+// ServeHTTP checks the status of cloud provider permissions
+func (p *CloudProviderPermissionsStatusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-cloud-provider-permissions-status")
+	defer span.End()
+
+	user, _ := ctx.Value(types.UserScope).(*models.User)
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	request := &CloudProviderPermissionsStatusRequest{}
+	if ok := p.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "cloud-provider", Value: string(request.CloudProvider)},
+		telemetry.AttributeKV{Key: "cloud-provider-credential-identifier", Value: request.CloudProviderCredentialIdentifier},
+	)
+
+	if request.CloudProviderCredentialIdentifier == "" {
+		err := telemetry.Error(ctx, span, nil, "missing cloud provider credential identifier")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	if request.CloudProvider == "" {
+		err := telemetry.Error(ctx, span, nil, "missing cloud provider")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	var cloudProvider porterv1.EnumCloudProvider
+	switch request.CloudProvider {
+	case CloudProviderAWS:
+		accessErrorExists, err := p.checkSameAccountInDifferentProjects(ctx, request.CloudProviderCredentialIdentifier, user)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error checking if same account exists in different projects")
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		if accessErrorExists {
+			err = telemetry.Error(ctx, span, err, "user does not have access to all projects")
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden))
+			return
+		}
+		cloudProvider = porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_AWS
+	}
+
+	credReq := porterv1.CloudProviderPermissionsStatusRequest{
+		ProjectId:                         int64(project.ID),
+		CloudProvider:                     cloudProvider,
+		CloudProviderCredentialIdentifier: request.CloudProviderCredentialIdentifier,
+	}
+	credResp, err := p.Config().ClusterControlPlaneClient.CloudProviderPermissionsStatus(ctx, connect.NewRequest(&credReq))
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error checking cloud provider permissions status")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if credResp == nil {
+		err = telemetry.Error(ctx, span, err, "error reading cloud provider permissions response")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if credResp.Msg == nil {
+		err = telemetry.Error(ctx, span, err, "error reading cloud provider permissions message")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	res := CloudProviderPermissionsStatusResponse{
+		PercentCompleted: credResp.Msg.PercentCompleted,
+	}
+
+	p.WriteResult(w, r, res)
+}
+
+func (p *CloudProviderPermissionsStatusHandler) checkSameAccountInDifferentProjects(ctx context.Context, targetArn string, user *models.User) (bool, error) {
+	ctx, span := telemetry.NewSpan(ctx, "check-same-account-in-different-projects")
+	defer span.End()
+
+	// if a user is changing the external ID, then we need to update the external ID for all projects that use that AWS account.
+	// This is required since the same AWS account can be used across multiple projects. In order to change the external ID for a project,
+	// the user must then have access to all projects that use that AWS account.
+	// If we ever do a higher abstraction about porter projects, then we can tie the ability to access a cloud provider account to that higher abstraction.
+	awsAccountIdPrefix := strings.TrimPrefix(targetArn, "arn:aws:iam::")
+	awsAccountId := strings.TrimSuffix(awsAccountIdPrefix, ":role/porter-manager")
+	assumeRoles, err := p.Repo().AWSAssumeRoleChainer().ListByAwsAccountId(ctx, awsAccountId)
+	if err != nil {
+		return false, telemetry.Error(ctx, span, err, "error listing assume role chains")
+	}
+
+	requiredProjects := make(map[int]bool)
+	for _, role := range assumeRoles {
+		requiredProjects[role.ProjectID] = false
+	}
+
+	usersProject, err := p.Repo().Project().ListProjectsByUserID(user.ID)
+	if err != nil {
+		return false, telemetry.Error(ctx, span, err, "error listing projects by user id")
+	}
+
+	for _, project := range usersProject {
+		if _, ok := requiredProjects[int(project.ID)]; ok {
+			requiredProjects[int(project.ID)] = true
+		}
+	}
+
+	for proj, required := range requiredProjects {
+		if !required {
+			err = telemetry.Error(ctx, span, err, "user does not have access to all projects that use this AWS account")
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "missing-project", Value: proj})
+			return true, err
+		}
+	}
+
+	return false, nil
+}

+ 0 - 29
api/server/router/cluster.go

@@ -319,35 +319,6 @@ func getClusterRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/datastore/status -> datastore.NewStatusHandler
-	datastoreStatusEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbList,
-			Method: types.HTTPVerbGet,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: relPath + "/datastore/status",
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	datastoreStatusHandler := datastore.NewStatusHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &router.Route{
-		Endpoint: datastoreStatusEndpoint,
-		Handler:  datastoreStatusHandler,
-		Router:   r,
-	})
-
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/compliance/checks -> cluster.NewListComplianceChecksHandler
 	listComplianceChecksEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 60 - 0
api/server/router/deployment_target.go

@@ -1,8 +1,11 @@
 package router
 
 import (
+	"fmt"
+
 	"github.com/go-chi/chi/v5"
 	"github.com/porter-dev/porter/api/server/handlers/deployment_target"
+	"github.com/porter-dev/porter/api/server/handlers/porter_app"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/router"
@@ -85,5 +88,62 @@ func getDeploymentTargetRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/targets/{deployment_target_identifier}/apps/{porter_app_name}/cloudsql -> porter_app.GetCloudSqlSecretHandler
+	getCloudSqlSecretEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/apps/{porter_app_name}/cloudsql", relPath),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.DeploymentTargetScope,
+			},
+		},
+	)
+
+	getCloudSqlSecretHandler := porter_app.NewGetCloudSqlSecretHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getCloudSqlSecretEndpoint,
+		Handler:  getCloudSqlSecretHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/targets/{deployment_target_identifier}/apps/{porter_app_name}/cloudsql -> porter_app.CreateCloudSqlSecretHandler
+	createCloudSqlSecretEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/apps/{porter_app_name}/cloudsql", relPath),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.DeploymentTargetScope,
+			},
+		},
+	)
+
+	createCloudSqlSecretHandler := porter_app.NewCreateCloudSqlSecretHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: createCloudSqlSecretEndpoint,
+		Handler:  createCloudSqlSecretHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 29 - 29
api/server/router/porter_app.go

@@ -1036,6 +1036,35 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/instances -> porter_app.NewAppInstancesHandler
+	latestAppInstancesEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/instances", relPathV2),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	latestAppInstancesHandler := porter_app.NewAppInstancesHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: latestAppInstancesEndpoint,
+		Handler:  latestAppInstancesHandler,
+		Router:   r,
+	})
+
 	// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/subdomain -> porter_app.NewCreateSubdomainHandler
 	createSubdomainEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
@@ -1473,35 +1502,6 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
-	// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/update-environment -> porter_app.NewUpdateAppEnvironmentHandler
-	updateAppEnvironmentGroupEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbUpdate,
-			Method: types.HTTPVerbPost,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: fmt.Sprintf("/apps/{%s}/update-environment", types.URLParamPorterAppName),
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-				types.ClusterScope,
-			},
-		},
-	)
-
-	updateAppEnvironmentGroupHandler := porter_app.NewUpdateAppEnvironmentHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &router.Route{
-		Endpoint: updateAppEnvironmentGroupEndpoint,
-		Handler:  updateAppEnvironmentGroupHandler,
-		Router:   r,
-	})
-
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/revisions/{app_revision_id}/env -> porter_app.NewGetAppEnvHandler
 	getAppEnvEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

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

@@ -679,5 +679,33 @@ func getProjectIntegrationRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/integrations/cloud-permissions -> project_integration.NewCloudProviderPermissionsStatusHandler
+	cloudPermissionsStatusEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/cloud-permissions",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	cloudPermissionsStatusHandler := project_integration.NewCloudProviderPermissionsStatusHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: cloudPermissionsStatusEndpoint,
+		Handler:  cloudPermissionsStatusHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 1 - 1
cli/cmd/commands/datastore.go

@@ -157,7 +157,7 @@ func datastoreConnect(ctx context.Context, _ *types.GetAuthenticatedUserResponse
 
 	req := config.RestClient.Post().
 		Resource("pods").
-		Namespace(namespace).
+		Namespace(resp.Namespace).
 		Name(proxyPod.Name).
 		SubResource("portforward")
 

+ 218 - 12
cli/cmd/commands/env.go

@@ -4,9 +4,13 @@ import (
 	"context"
 	"fmt"
 	"os"
+	"time"
 
+	"github.com/briandowns/spinner"
 	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/api/server/handlers/environment_groups"
+	"github.com/porter-dev/porter/api/server/handlers/porter_app"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/cli/cmd/config"
 	"github.com/spf13/cobra"
@@ -23,16 +27,39 @@ type envVariables struct {
 	Secrets   map[string]string `json:"secrets"`
 }
 
+type envVariableDeletions struct {
+	Variables []string `json:"variables"`
+	Secrets   []string `json:"secrets"`
+}
+
 func registerCommand_Env(cliConf config.CLIConfig) *cobra.Command {
 	envCmd := &cobra.Command{
 		Use:   "env",
-		Args:  cobra.MinimumNArgs(1),
+		Args:  cobra.MinimumNArgs(0),
 		Short: "Manage environment variables for a project",
+		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
+			if len(cmd.Commands()) == 1 {
+				return nil
+			}
+
+			if appName == "" && envGroupName == "" {
+				return fmt.Errorf("must specify either --app or --group")
+			}
+			if appName != "" && envGroupName != "" {
+				return fmt.Errorf("only one of --app or --group can be specified")
+			}
+
+			return nil
+		},
 		RunE: func(cmd *cobra.Command, args []string) error {
 			return cmd.Help()
 		},
 	}
 
+	envCmd.PersistentFlags().StringVarP(&appName, "app", "a", "", "app name")
+	envCmd.PersistentFlags().StringVarP(&envGroupName, "group", "g", "", "environment group name")
+	envCmd.PersistentFlags().StringVarP(&deploymentTargetName, "target", "x", "", "the name of the deployment target for the app")
+
 	pullCommand := &cobra.Command{
 		Use:   "pull",
 		Short: "Pull environment variables for an app or environment group",
@@ -44,25 +71,48 @@ Optionally, specify a file to write the environment variables to. Otherwise the
 			return checkLoginAndRunWithConfig(cmd, cliConf, args, pullEnv)
 		},
 	}
-
-	pullCommand.Flags().StringVarP(&appName, "app", "a", "", "app name")
-	pullCommand.Flags().StringVarP(&envGroupName, "group", "g", "", "environment group name")
 	pullCommand.Flags().StringVarP(&envFilePath, "file", "f", "", "file to write environment variables to")
-	pullCommand.Flags().StringVarP(&deploymentTargetName, "target", "x", "", "the name of the deployment target for the app")
+
+	setCommand := &cobra.Command{
+		Use:   "set",
+		Short: "Set environment variables for an app or environment group",
+		Long: `Set environment variables for an app or environment group.
+
+Both variables and secrets can be specified as key-value pairs.
+When updating an environment group, all apps linked to the environment group will be re-deployed, unless the --skip-redeploys flag is used.`,
+		Args: cobra.NoArgs,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return checkLoginAndRunWithConfig(cmd, cliConf, args, setEnv)
+		},
+	}
+	setCommand.Flags().StringToStringP("variables", "v", nil, "variables to set")
+	setCommand.Flags().StringToStringP("secrets", "s", nil, "secrets to set")
+	setCommand.Flags().Bool("skip-redeploys", false, "skip re-deploying apps linked to the environment group")
+
+	unsetCommand := &cobra.Command{
+		Use:   "unset",
+		Short: "Unset environment variables for an app or environment group",
+		Long: `Unset environment variables for an app or environment group.
+
+Both variables and secrets can be specified as keys.
+When updating an environment group, all apps linked to the environment group will be re-deployed, unless the --skip-redeploys flag is used.`,
+		Args: cobra.NoArgs,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return checkLoginAndRunWithConfig(cmd, cliConf, args, unsetEnv)
+		},
+	}
+	unsetCommand.Flags().StringSliceP("variables", "v", nil, "variables to unset")
+	unsetCommand.Flags().StringSliceP("secrets", "s", nil, "secrets to unset")
+	unsetCommand.Flags().Bool("skip-redeploys", false, "skip re-deploying apps linked to the environment group")
 
 	envCmd.AddCommand(pullCommand)
+	envCmd.AddCommand(setCommand)
+	envCmd.AddCommand(unsetCommand)
 
 	return envCmd
 }
 
 func pullEnv(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, cmd *cobra.Command, args []string) error {
-	if appName == "" && envGroupName == "" {
-		return fmt.Errorf("must specify either --app or --group")
-	}
-	if appName != "" && envGroupName != "" {
-		return fmt.Errorf("only one of --app or --group can be specified")
-	}
-
 	var envVars envVariables
 
 	if appName != "" {
@@ -117,6 +167,162 @@ func pullEnv(ctx context.Context, user *types.GetAuthenticatedUserResponse, clie
 	return nil
 }
 
+func setEnv(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, cmd *cobra.Command, args []string) error {
+	var envVars envVariables
+
+	variables, err := cmd.Flags().GetStringToString("variables")
+	if err != nil {
+		return fmt.Errorf("could not get variables: %w", err)
+	}
+
+	secrets, err := cmd.Flags().GetStringToString("secrets")
+	if err != nil {
+		return fmt.Errorf("could not get secrets: %w", err)
+	}
+
+	skipRedeploys, err := cmd.Flags().GetBool("skip-redeploys")
+	if err != nil {
+		return fmt.Errorf("could not get skip-redeploys: %w", err)
+	}
+
+	envVars = envVariables{
+		Variables: variables,
+		Secrets:   secrets,
+	}
+
+	s := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
+	s.Color("cyan") // nolint:errcheck,gosec
+
+	if appName != "" {
+		s.Suffix = fmt.Sprintf(" Setting environment variables for app %s...", appName)
+
+		s.Start()
+		_, err := client.UpdateApp(ctx, api.UpdateAppInput{
+			ProjectID:            cliConf.Project,
+			ClusterID:            cliConf.Cluster,
+			Name:                 appName,
+			DeploymentTargetName: deploymentTargetName,
+			Variables:            envVars.Variables,
+			Secrets:              envVars.Secrets,
+		})
+		if err != nil {
+			return fmt.Errorf("could not set app env variables: %w", err)
+		}
+		s.Stop()
+
+		color.New(color.FgGreen).Printf("Updated environment variable keys in app %s:\n", appName) // nolint:errcheck,gosec
+	}
+
+	if envGroupName != "" {
+		s.Suffix = fmt.Sprintf(" Setting environment variables for environment group %s...", envGroupName)
+
+		s.Start()
+		err := client.UpdateEnvGroup(ctx, api.UpdateEnvGroupInput{
+			ProjectID:     cliConf.Project,
+			ClusterID:     cliConf.Cluster,
+			EnvGroupName:  envGroupName,
+			Variables:     envVars.Variables,
+			Secrets:       envVars.Secrets,
+			SkipRedeploys: skipRedeploys,
+		})
+		if err != nil {
+			return fmt.Errorf("could not set env group env variables: %w", err)
+		}
+		s.Stop()
+
+		color.New(color.FgGreen).Printf("Updated keys in environment group %s:\n", envGroupName) // nolint:errcheck,gosec
+	}
+
+	for k, v := range envVars.Variables {
+		color.New(color.FgBlue).Printf("%s=%s\n", k, v) // nolint:errcheck,gosec
+	}
+	for k := range envVars.Secrets {
+		color.New(color.FgBlue).Printf("%s=********\n", k) // nolint:errcheck,gosec
+	}
+
+	return nil
+}
+
+func unsetEnv(ctx context.Context, user *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, cmd *cobra.Command, args []string) error {
+	var envVarDeletions envVariableDeletions
+
+	variables, err := cmd.Flags().GetStringSlice("variables")
+	if err != nil {
+		return fmt.Errorf("could not get variables: %w", err)
+	}
+
+	secrets, err := cmd.Flags().GetStringSlice("secrets")
+	if err != nil {
+		return fmt.Errorf("could not get secrets: %w", err)
+	}
+
+	skipRedeploys, err := cmd.Flags().GetBool("skip-redeploys")
+	if err != nil {
+		return fmt.Errorf("could not get skip-redeploys: %w", err)
+	}
+
+	envVarDeletions = envVariableDeletions{
+		Variables: variables,
+		Secrets:   secrets,
+	}
+
+	s := spinner.New(spinner.CharSets[9], 100*time.Millisecond)
+	s.Color("cyan") // nolint:errcheck,gosec
+
+	if appName != "" {
+		s.Suffix = fmt.Sprintf(" Unsetting environment variables for app %s...", appName)
+
+		s.Start()
+		_, err := client.UpdateApp(ctx, api.UpdateAppInput{
+			ProjectID:            cliConf.Project,
+			ClusterID:            cliConf.Cluster,
+			Name:                 appName,
+			DeploymentTargetName: deploymentTargetName,
+			Deletions: porter_app.Deletions{
+				EnvVariableDeletions: porter_app.EnvVariableDeletions{
+					Variables: envVarDeletions.Variables,
+					Secrets:   envVarDeletions.Secrets,
+				},
+			},
+		})
+		if err != nil {
+			return fmt.Errorf("could not unset app env variables: %w", err)
+		}
+		s.Stop()
+
+		color.New(color.FgGreen).Printf("Unset environment variable keys in app %s:\n", appName) // nolint:errcheck,gosec
+	}
+
+	if envGroupName != "" {
+		s.Suffix = fmt.Sprintf(" Unsetting environment variables for environment group %s...", envGroupName)
+
+		err := client.UpdateEnvGroup(ctx, api.UpdateEnvGroupInput{
+			ProjectID:    cliConf.Project,
+			ClusterID:    cliConf.Cluster,
+			EnvGroupName: envGroupName,
+			Deletions: environment_groups.EnvVariableDeletions{
+				Variables: envVarDeletions.Variables,
+				Secrets:   envVarDeletions.Secrets,
+			},
+			SkipRedeploys: skipRedeploys,
+		})
+		if err != nil {
+			return fmt.Errorf("could not unset env group env variables: %w", err)
+		}
+
+		color.New(color.FgGreen).Printf("Unset the keys in environment group %s:\n", envGroupName) // nolint:errcheck,gosec
+	}
+
+	for _, v := range envVarDeletions.Variables {
+		color.New(color.FgBlue).Printf("%s\n", v) // nolint:errcheck,gosec
+	}
+	for _, v := range envVarDeletions.Secrets {
+		color.New(color.FgBlue).Printf("%s\n", v) // nolint:errcheck,gosec
+	}
+
+	return nil
+}
+
 func writeEnvFile(envFilePath string, envVars envVariables) error {
 	// open existing file or create new file: https://pkg.go.dev/os#example-OpenFile-Append
 	envFile, err := os.OpenFile(envFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) // nolint:gosec

+ 13 - 13
dashboard/package-lock.json

@@ -95,7 +95,7 @@
         "@babel/preset-typescript": "^7.15.0",
         "@ianvs/prettier-plugin-sort-imports": "^4.1.1",
         "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
-        "@porter-dev/api-contracts": "^0.2.113",
+        "@porter-dev/api-contracts": "^0.2.118",
         "@testing-library/jest-dom": "^4.2.4",
         "@testing-library/react": "^9.3.2",
         "@testing-library/user-event": "^7.1.2",
@@ -2072,9 +2072,9 @@
       }
     },
     "node_modules/@bufbuild/protobuf": {
-      "version": "1.3.3",
-      "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.3.3.tgz",
-      "integrity": "sha512-AoHSiIpTFF97SQgmQni4c+Tyr0CDhkaRaR2qGEJTEbauqQwLRpLrd9yVv//wVHOSxr/b4FJcL54VchhY6710xA==",
+      "version": "1.7.2",
+      "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.7.2.tgz",
+      "integrity": "sha512-i5GE2Dk5ekdlK1TR7SugY4LWRrKSfb5T1Qn4unpIMbfxoeGKERKQ59HG3iYewacGD10SR7UzevfPnh6my4tNmQ==",
       "dev": true
     },
     "node_modules/@discoveryjs/json-ext": {
@@ -2754,9 +2754,9 @@
       }
     },
     "node_modules/@porter-dev/api-contracts": {
-      "version": "0.2.113",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.113.tgz",
-      "integrity": "sha512-pk6JMuY/qSVMIcC7lw28PGPHcHT7qCn1xosug8TvpJ3fMNav1seotgBpqPh4CUQ8b1cF5PtAZWvEN+dx4bt/qg==",
+      "version": "0.2.118",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.118.tgz",
+      "integrity": "sha512-A5cPRfTNKfC7qQ6gHFLyLRWU1bTDj4mHIB2XL4l3CqUl3KsX6p7EgwjEI3YX5sVwoUcGnlatiZ+BqgrLhlf4cg==",
       "dev": true,
       "dependencies": {
         "@bufbuild/protobuf": "^1.1.0"
@@ -19584,9 +19584,9 @@
       }
     },
     "@bufbuild/protobuf": {
-      "version": "1.3.3",
-      "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.3.3.tgz",
-      "integrity": "sha512-AoHSiIpTFF97SQgmQni4c+Tyr0CDhkaRaR2qGEJTEbauqQwLRpLrd9yVv//wVHOSxr/b4FJcL54VchhY6710xA==",
+      "version": "1.7.2",
+      "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.7.2.tgz",
+      "integrity": "sha512-i5GE2Dk5ekdlK1TR7SugY4LWRrKSfb5T1Qn4unpIMbfxoeGKERKQ59HG3iYewacGD10SR7UzevfPnh6my4tNmQ==",
       "dev": true
     },
     "@discoveryjs/json-ext": {
@@ -20056,9 +20056,9 @@
       "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
     },
     "@porter-dev/api-contracts": {
-      "version": "0.2.113",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.113.tgz",
-      "integrity": "sha512-pk6JMuY/qSVMIcC7lw28PGPHcHT7qCn1xosug8TvpJ3fMNav1seotgBpqPh4CUQ8b1cF5PtAZWvEN+dx4bt/qg==",
+      "version": "0.2.118",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.118.tgz",
+      "integrity": "sha512-A5cPRfTNKfC7qQ6gHFLyLRWU1bTDj4mHIB2XL4l3CqUl3KsX6p7EgwjEI3YX5sVwoUcGnlatiZ+BqgrLhlf4cg==",
       "dev": true,
       "requires": {
         "@bufbuild/protobuf": "^1.1.0"

+ 1 - 1
dashboard/package.json

@@ -102,7 +102,7 @@
     "@babel/preset-typescript": "^7.15.0",
     "@ianvs/prettier-plugin-sort-imports": "^4.1.1",
     "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
-    "@porter-dev/api-contracts": "^0.2.113",
+    "@porter-dev/api-contracts": "^0.2.118",
     "@testing-library/jest-dom": "^4.2.4",
     "@testing-library/react": "^9.3.2",
     "@testing-library/user-event": "^7.1.2",

+ 3 - 0
dashboard/src/assets/information-circle-contained.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.9999 11.9999L11.9999 16.7999M11.9999 8.44209V8.3999M2.3999 11.9999C2.3999 6.69797 6.69797 2.3999 11.9999 2.3999C17.3018 2.3999 21.5999 6.69797 21.5999 11.9999C21.5999 17.3018 17.3018 21.5999 11.9999 21.5999C6.69797 21.5999 2.3999 17.3018 2.3999 11.9999Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 1 - 1
dashboard/src/components/GCPCredentialsForm.tsx

@@ -157,7 +157,7 @@ const GCPCredentialsForm: React.FC<Props> = ({ goBack, proceed }) => {
           <>
             <Text size={16}> Create the service account </Text>
             <Spacer y={.5} />
-            <Link onClick={incrementStep} to="https://docs.porter.run/standard/getting-started/provisioning-on-gcp" target="_blank">
+            <Link onClick={incrementStep} to="https://docs.porter.run/provision/provisioning-on-gcp" target="_blank">
               Follow the steps in the Porter docs to generate your service account credentials
             </Link>
             <Spacer y={.5} />

+ 9 - 5
dashboard/src/components/porter-form/field-components/ServiceRow.tsx

@@ -1,7 +1,7 @@
 import React, { Component } from "react";
 import styled from "styled-components";
 import { Context } from "shared/Context";
-import { hardcodedIcons, hardcodedNames } from "shared/hardcodedNameDict";
+import { hardcodedIcons } from "shared/hardcodedNameDict";
 
 type PropsType = {
   service: {
@@ -16,6 +16,13 @@ type PropsType = {
 
 type StateType = any;
 
+const getIcon = (type: string) => {
+  if (hardcodedIcons[type]) {
+    return hardcodedIcons[type];
+  }
+  return hardcodedIcons["web"];
+}
+
 export default class ServiceRow extends Component<PropsType, StateType> {
   render() {
     let { clusterIP, name, namespace, type, app, release } = this.props.service;
@@ -25,13 +32,10 @@ export default class ServiceRow extends Component<PropsType, StateType> {
       <>
         {name &&
           type &&
-          hardcodedNames[type] &&
-          hardcodedIcons[type] &&
           namespace !== "kube-system" && (
             <StyledServiceRow>
               <Flex>
-                <Icon src={hardcodedIcons[type]} />
-                <Type>{hardcodedNames[type]}</Type>
+                <Icon src={getIcon(type)} />
                 <Name>{name}</Name> <Dash>-</Dash> <IP>{clusterIP}</IP>
               </Flex>
               <TagWrapper>

+ 40 - 0
dashboard/src/components/porter/PorterOperatorComponent.tsx

@@ -0,0 +1,40 @@
+import React, { useContext } from "react";
+import styled from "styled-components";
+
+import Text from "components/porter/Text";
+
+import { Context } from "shared/Context";
+import info from "assets/information-circle-contained.svg";
+
+import Container from "./Container";
+import Icon from "./Icon";
+import Spacer from "./Spacer";
+
+type Props = {
+  children: JSX.Element;
+};
+const PorterOperatorComponent: React.FC<Props> = ({ children }) => {
+  const { user } = useContext(Context);
+
+  if (!user?.email?.endsWith("@porter.run")) {
+    return null;
+  }
+  return (
+    <StyledContainer>
+      <Container row>
+        <Icon src={info} height={"14px"} />
+        <Spacer inline x={0.5} />
+        <Text>This is only visible to Porter operators</Text>
+      </Container>
+      <div style={{ marginTop: "10px" }}>{children}</div>
+    </StyledContainer>
+  );
+};
+
+export default PorterOperatorComponent;
+
+const StyledContainer = styled.div`
+  background-color: rgba(128, 128, 128, 0.2);
+  padding: 20px;
+  border-radius: 5px;
+`;

+ 15 - 0
dashboard/src/lib/clusters/constants.ts

@@ -1368,6 +1368,16 @@ const AWS_VCPUS_QUOTA_RESOLUTION: PreflightCheckResolution = {
     },
   ],
 };
+const UNAVAILABLE_AVAILABILITY_ZONES: PreflightCheckResolution = {
+  title: "Addressing unavailable availability zones",
+  subtitle:
+    "You will need to select another region, as your AWS account does not support enough Availability Zones in the region specified.",
+  steps: [
+    {
+      text: "Select another region from the region dropdown and retry the provision.",
+    },
+  ],
+};
 const OVERLAPPING_CIDR_RESOLUTION: PreflightCheckResolution = {
   title: "Preventing Overlapping CIDR Blocks",
   subtitle:
@@ -1485,6 +1495,11 @@ const SUPPORTED_AWS_PREFLIGHT_CHECKS: PreflightCheck[] = [
     displayName: "Overlapping CIDR blocks",
     resolution: OVERLAPPING_CIDR_RESOLUTION,
   },
+  {
+    name: "availabilityZone",
+    displayName: "Unavailable Availability Zones in region",
+    resolution: UNAVAILABLE_AVAILABILITY_ZONES,
+  },
 ];
 
 const SUPPORTED_AZURE_PREFLIGHT_CHECKS: PreflightCheck[] = [

+ 1 - 0
dashboard/src/lib/clusters/types.ts

@@ -532,6 +532,7 @@ const preflightCheckKeyValidator = z.enum([
   "iamPermissions",
   "authz",
   "enforceCidrUniqueness",
+  "availabilityZone",
 ]);
 type PreflightCheckKey = z.infer<typeof preflightCheckKeyValidator>;
 export const preflightCheckValidator = z.object({

+ 3 - 11
dashboard/src/lib/hooks/useCloudProvider.ts

@@ -2,8 +2,7 @@ import { z } from "zod";
 
 import api from "shared/api";
 
-// TODO: refactor this to match "connectTo.." syntax
-export const isAWSArnAccessible = async ({
+export const connectToAwsAccount = async ({
   targetArn,
   externalId,
   projectId,
@@ -11,8 +10,8 @@ export const isAWSArnAccessible = async ({
   targetArn: string;
   externalId: string;
   projectId: number;
-}): Promise<number> => {
-  const res = await api.createAWSIntegration(
+}): Promise<void> => {
+  await api.createAWSIntegration(
     "<token>",
     {
       aws_target_arn: targetArn,
@@ -20,13 +19,6 @@ export const isAWSArnAccessible = async ({
     },
     { id: projectId }
   );
-  const parsed = await z
-    .object({
-      percent_completed: z.number(),
-    })
-    .parseAsync(res.data);
-
-  return parsed.percent_completed;
 };
 
 export const connectToAzureAccount = async ({

+ 47 - 0
dashboard/src/lib/hooks/useCloudSqlSecret.ts

@@ -0,0 +1,47 @@
+import { useQuery } from "@tanstack/react-query";
+import { z } from "zod";
+
+import api from "shared/api";
+
+export function useCloudSqlSecret({
+  appName,
+  deploymentTargetId,
+  projectId,
+}: {
+  appName: string;
+  deploymentTargetId: string;
+  projectId: number;
+}): boolean {
+  const { data } = useQuery(
+    [
+      "getCloudSqlSecret",
+      projectId,
+      appName,
+      deploymentTargetId,
+    ],
+    async () => {
+      const res = await api.getCloudSqlSecret(
+        "<token>",
+        {},
+        {
+          project_id: projectId,
+          deployment_target_id: deploymentTargetId,
+          app_name: appName,
+        }
+      );
+
+      const secret = await z
+        .object({
+          secret_name: z.string(),
+        })
+        .parseAsync(res.data);
+      return secret;
+    },
+    {
+      refetchInterval: 5000,
+      refetchOnWindowFocus: false,
+    }
+  );
+
+  return data !== undefined && data.secret_name !== "";
+}

+ 51 - 0
dashboard/src/lib/hooks/useLatestAppRevisions.ts

@@ -2,7 +2,9 @@ import { useQuery } from "@tanstack/react-query";
 import { z } from "zod";
 
 import {
+  appInstanceValidator,
   appRevisionWithSourceValidator,
+  type AppInstance,
   type AppRevisionWithSource,
 } from "main/home/app-dashboard/apps/types";
 
@@ -57,3 +59,52 @@ export const useLatestAppRevisions = ({
     revisions: apps,
   };
 };
+
+// use this hook to get the latest revision of every app in the project/cluster
+export const useAppInstances = ({
+  projectId,
+  clusterId,
+}: {
+  projectId: number;
+  clusterId: number;
+}): {
+  instances: AppInstance[];
+} => {
+  const { data: appInstances = [] } = useQuery(
+    [
+      "getAppInstances",
+      {
+        cluster_id: clusterId,
+        project_id: projectId,
+      },
+    ],
+    async () => {
+      if (clusterId === -1 || projectId === -1) {
+        return;
+      }
+
+      const res = await api.getAppInstances(
+        "<token>",
+        {
+          deployment_target_id: undefined,
+        },
+        { cluster_id: clusterId, project_id: projectId }
+      );
+
+      const apps = await z
+        .object({
+          app_instances: z.array(appInstanceValidator),
+        })
+        .parseAsync(res.data);
+
+      return apps.app_instances;
+    },
+    {
+      refetchOnWindowFocus: false,
+      enabled: clusterId !== 0 && projectId !== 0,
+    }
+  );
+  return {
+    instances: appInstances,
+  };
+};

+ 41 - 0
dashboard/src/lib/porter-apps/index.ts

@@ -1,6 +1,7 @@
 import {
   AutoRollback,
   Build,
+  CloudSql,
   EFS,
   HelmOverrides,
   PorterApp,
@@ -81,6 +82,19 @@ export const clientAppValidator = z.object({
     enabled: z.boolean(),
     readOnly: z.boolean().optional(),
   }),
+  cloudSql: z
+    .object({
+      enabled: z.boolean(),
+      connectionName: z.string(),
+      dbPort: z.coerce.number(),
+      serviceAccountJsonSecret: z.string(),
+    })
+    .default({
+      enabled: false,
+      connectionName: "",
+      dbPort: 5432,
+      serviceAccountJsonSecret: "",
+    }),
   envGroups: z
     .object({ name: z.string(), version: z.bigint() })
     .array()
@@ -325,6 +339,13 @@ export function clientAppToProto(data: PorterAppFormData): PorterApp {
           efsStorage: new EFS({
             enabled: app.efsStorage.enabled,
           }),
+          cloudSql: new CloudSql({
+            enabled: app.cloudSql.enabled,
+            connectionName: app.cloudSql?.connectionName ?? "",
+            serviceAccountJsonSecret:
+              app.cloudSql?.serviceAccountJsonSecret ?? "",
+            dbPort: app.cloudSql?.dbPort ?? 5432,
+          }),
           requiredApps: app.requiredApps.map((app) => ({
             name: app.name,
           })),
@@ -357,6 +378,13 @@ export function clientAppToProto(data: PorterAppFormData): PorterApp {
           efsStorage: new EFS({
             enabled: app.efsStorage.enabled,
           }),
+          cloudSql: new CloudSql({
+            enabled: app.cloudSql.enabled,
+            connectionName: app.cloudSql?.connectionName ?? "",
+            serviceAccountJsonSecret:
+              app.cloudSql?.serviceAccountJsonSecret ?? "",
+            dbPort: app.cloudSql?.dbPort ?? 5432,
+          }),
           requiredApps: app.requiredApps.map((app) => ({
             name: app.name,
           })),
@@ -510,6 +538,13 @@ export function clientAppFromProto({
       efsStorage: new EFS({
         enabled: proto.efsStorage?.enabled ?? false,
       }),
+      cloudSql: {
+        enabled: proto.cloudSql?.enabled ?? false,
+        connectionName: proto.cloudSql?.connectionName ?? "",
+        serviceAccountJsonSecret:
+          proto.cloudSql?.serviceAccountJsonSecret ?? "",
+        dbPort: proto.cloudSql?.dbPort ?? 5432,
+      },
       requiredApps: proto.requiredApps.map((app) => ({
         name: app.name,
       })),
@@ -556,6 +591,12 @@ export function clientAppFromProto({
     },
     helmOverrides,
     efsStorage: { enabled: proto.efsStorage?.enabled ?? false },
+    cloudSql: {
+      enabled: proto.cloudSql?.enabled ?? false,
+      connectionName: proto.cloudSql?.connectionName ?? "",
+      serviceAccountJsonSecret: proto.cloudSql?.serviceAccountJsonSecret ?? "",
+      dbPort: proto.cloudSql?.dbPort ?? 5432,
+    },
     requiredApps: proto.requiredApps.map((app) => ({
       name: app.name,
     })),

+ 136 - 1
dashboard/src/main/home/app-dashboard/app-view/tabs/Settings.tsx

@@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";
 import { Controller, useFormContext } from "react-hook-form";
 import { useHistory } from "react-router";
 import styled from "styled-components";
+import { z } from "zod";
 
 import Button from "components/porter/Button";
 import Checkbox from "components/porter/Checkbox";
@@ -16,6 +17,12 @@ import api from "shared/api";
 import { Context } from "shared/Context";
 import document from "assets/document.svg";
 
+import UploadArea from "components/form-components/UploadArea";
+import Container from "components/porter/Container";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Input from "components/porter/Input";
+import { useCloudSqlSecret } from "lib/hooks/useCloudSqlSecret";
+import { useDeploymentTarget } from "shared/DeploymentTargetContext";
 import DeleteApplicationModal from "../../expanded-app/DeleteApplicationModal";
 import { useLatestRevision } from "../LatestRevisionContext";
 import ExportAppModal from "./ExportAppModal";
@@ -29,7 +36,7 @@ const Settings: React.FC = () => {
   const { porterApp, clusterId, projectId } = useLatestRevision();
   const { updateAppStep } = useAppAnalytics();
   const [isDeleting, setIsDeleting] = useState(false);
-  const { control } = useFormContext<PorterAppFormData>();
+  const { control, register, watch } = useFormContext<PorterAppFormData>();
   const [githubWorkflowFilename, setGithubWorkflowFilename] = useState(
     `porter_stack_${porterApp.name}.yml`
   );
@@ -175,6 +182,7 @@ const Settings: React.FC = () => {
           </Checkbox>
         )}
       />
+      {currentCluster?.cloud_provider === "GCP" && <CloudSql />}
       <Spacer y={1} />
       {currentCluster?.cloud_provider === "AWS" &&
         currentProject?.efs_enabled && (
@@ -272,6 +280,133 @@ const Settings: React.FC = () => {
 
 export default Settings;
 
+const CloudSql: React.FC = () => {
+  const { register, control, watch, setValue } =
+    useFormContext<PorterAppFormData>();
+  const { currentDeploymentTarget } = useDeploymentTarget();
+  const [created, setCreated] = useState(false);
+
+  if (!currentDeploymentTarget) {
+    return null;
+  }
+
+  const cloudSqlEnabled = watch(`app.cloudSql.enabled`);
+  const appName = watch(`app.name.value`);
+
+  const secretExists = useCloudSqlSecret({
+    projectId: currentDeploymentTarget.project_id,
+    deploymentTargetId: currentDeploymentTarget.id,
+    appName,
+  });
+
+  const handleLoadJSON = async (data: string): Promise<void> => {
+    try {
+      await api.createCloudSqlSecret(
+        "<token>",
+        {
+          b64_service_account_json: btoa(data),
+        },
+        {
+          project_id: currentDeploymentTarget.project_id,
+          deployment_target_id: currentDeploymentTarget.id,
+          app_name: appName,
+        }
+      );
+      setCreated(true);
+    } catch (err) {}
+  };
+
+  const enabled = watch(`app.cloudSql.enabled`);
+
+  useEffect(() => {
+    if (enabled) {
+      setValue(
+        `app.cloudSql.serviceAccountJsonSecret`,
+        `cloudsql-secret-${appName}`
+      );
+    }
+  }, [enabled]);
+
+  return (
+    <>
+      <Spacer y={1} />
+      <Text>CloudSQL proxy</Text>
+      <Spacer y={0.25} />
+      <Text color="helper">
+        When enabled, Porter will automatically deploy a CloudSQL proxy with
+        your application, allowing all your services to securely access your
+        CloudSQL instance.
+      </Text>
+      <Spacer y={0.5} />
+      <Controller
+        name={`app.cloudSql.enabled`}
+        control={control}
+        render={({ field: { value, onChange } }) => (
+          <Checkbox
+            checked={value}
+            toggleChecked={() => {
+              onChange(!value);
+            }}
+          >
+            <Text color="helper">Enable CloudSQL Proxy</Text>
+          </Checkbox>
+        )}
+      />
+      {cloudSqlEnabled && (
+        <>
+          <Spacer y={0.75} />
+          <Text color="helper">Connection Name</Text>
+          <Spacer y={0.25} />
+          <ControlledInput
+            type="text"
+            placeholder="ex: project:us-east1:instance"
+            {...register(`app.cloudSql.connectionName`)}
+          />
+          <Spacer y={0.5} />
+          <Text color="helper">Port</Text>
+          <Spacer y={0.25} />
+          <Controller
+            name={`app.cloudSql.dbPort`}
+            control={control}
+            render={({ field: { value, onChange } }) => (
+              <Input
+                placeholder={"ex: 5432"}
+                value={value.toString()}
+                setValue={(x: string) => {
+                  onChange(z.coerce.number().parse(x));
+                }}
+              />
+            )}
+          />
+          <Spacer y={0.5} />
+          <Container row>
+            <Text color={"helper"}>Service Account JSON</Text>
+            <Spacer inline x={0.5} />
+            {secretExists && created && (
+              <i className="material-icons">done</i>
+            )}{" "}
+          </Container>
+          <UploadArea
+            setValue={(x: string) => {
+              handleLoadJSON(x).catch(() => {});
+            }}
+            label=""
+            placeholder={
+              (secretExists
+                ? "To update your credentials, "
+                : "To enable the CloudSql Proxy, ") +
+              "drag a GCP Service Account JSON here, or click to browse."
+            }
+            width="100%"
+            height="100%"
+            isRequired={false}
+          />
+        </>
+      )}
+    </>
+  );
+};
+
 const StyledSettingsTab = styled.div`
   width: 100%;
 `;

+ 14 - 1
dashboard/src/main/home/app-dashboard/apps/types.ts

@@ -1,5 +1,7 @@
-import { appRevisionValidator } from "lib/revisions/types";
 import { z } from "zod";
+
+import { appRevisionValidator } from "lib/revisions/types";
+
 import { porterAppValidator } from "../app-view/AppView";
 
 export const appRevisionWithSourceValidator = z.object({
@@ -10,3 +12,14 @@ export const appRevisionWithSourceValidator = z.object({
 export type AppRevisionWithSource = z.infer<
   typeof appRevisionWithSourceValidator
 >;
+
+export const appInstanceValidator = z.object({
+  id: z.string(),
+  name: z.string(),
+  deployment_target: z.object({
+    id: z.string().optional(),
+    name: z.string().optional(),
+  }),
+});
+
+export type AppInstance = z.infer<typeof appInstanceValidator>;

+ 23 - 26
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -42,6 +42,10 @@ import { Context } from "shared/Context";
 import { valueExists } from "shared/util";
 import applicationGrad from "assets/application-grad.svg";
 
+import {
+  useAppInstances,
+  useLatestAppRevisions,
+} from "../../../../lib/hooks/useLatestAppRevisions";
 import ImageSettings from "../image-settings/ImageSettings";
 import GithubActionModal from "../new-app-flow/GithubActionModal";
 import SourceSelector from "../new-app-flow/SourceSelector";
@@ -82,34 +86,27 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
     secrets: {},
   });
 
-  const { data: porterApps = [] } = useQuery<string[]>(
-    ["getPorterApps", currentProject?.id, currentCluster?.id],
-    async () => {
-      if (!currentProject?.id || !currentCluster?.id) {
-        return await Promise.resolve([]);
-      }
+  const { revisions: appsWithRevisions } = useLatestAppRevisions({
+    projectId: currentProject?.id ?? 0,
+    clusterId: currentCluster?.id ?? 0,
+  });
 
-      const res = await api.getPorterApps(
-        "<token>",
-        {},
-        {
-          project_id: currentProject?.id,
-          cluster_id: currentCluster?.id,
-        }
-      );
+  const { instances: appInstances } = useAppInstances({
+    projectId: currentProject?.id ?? 0,
+    clusterId: currentCluster?.id ?? 0,
+  });
 
-      const apps = await z
-        .object({
-          name: z.string(),
-        })
-        .array()
-        .parseAsync(res.data);
-      return apps.map((app) => app.name);
-    },
-    {
-      enabled: !!currentProject?.id && !!currentCluster?.id,
-    }
-  );
+  const porterApps = useMemo((): string[] => {
+    return appsWithRevisions.reduce(function (result: string[], app) {
+      const instances = appInstances.filter(
+        (instance) => instance.id === app.app_revision.app_instance_id
+      );
+      if (instances.length > 0) {
+        return result.concat(instances[0].name);
+      }
+      return result;
+    }, []);
+  }, [appsWithRevisions, appInstances]);
 
   const { data: baseEnvGroups = [] } = useQuery(
     ["getAllEnvGroups", currentProject?.id, currentCluster?.id],

+ 3 - 1
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Main.tsx

@@ -38,7 +38,9 @@ const MainTab: React.FC<MainTabProps> = ({
   const getScheduleDescription = useCallback((cron: string) => {
     try {
       return (
-        <Text color="helper">This job runs: {cronstrue.toString(cron)}</Text>
+        <Text color="helper">
+          This job runs: {cronstrue.toString(cron)} UTC
+        </Text>
       );
     } catch (err) {
       return (

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

@@ -115,26 +115,6 @@ const ExpandedChart: React.FC<Props> = (props) => {
     setOverrideCurrentTab("logs");
   };
 
-  const updateDatabaseStatuses = async (): Promise<void> => {
-    try {
-
-      const statusRes = await api.getDatabaseStatus("<token>", {
-        name: currentChart.name,
-        type: currentChart.chart.metadata.name
-      }, {
-        project_id: currentProject?.id ?? 0,
-        cluster_id: currentCluster?.id ?? 0,
-      });
-      if (statusRes.data.status === "available") {
-        setDatabaseStatus(true);
-      }
-      else {
-        setDatabaseStatus(false);
-      }
-    } catch (err) {
-      setDatabaseStatus(false);
-    }
-  };
   // Retrieve full chart data (includes form and values)
   const getChartData = async (chart: ChartType) => {
     setIsLoadingChartData(true);
@@ -850,9 +830,6 @@ const ExpandedChart: React.FC<Props> = (props) => {
           });
       });
     });
-    if (templateWhitelist.includes(currentChart.chart.metadata.name)) {
-      void updateDatabaseStatuses()
-    }
     return () => {
       closeAllWebsockets();
     };

+ 2 - 3
dashboard/src/main/home/database-dashboard/DatabaseTabs.tsx

@@ -7,7 +7,6 @@ import TabSelector from "components/TabSelector";
 
 import { useDatastoreContext } from "./DatabaseContextProvider";
 import DatastoreProvisioningIndicator from "./DatastoreProvisioningIndicator";
-import ConfigurationTab from "./tabs/ConfigurationTab";
 import ConnectedAppsTab from "./tabs/ConnectedAppsTab";
 import ConnectTab from "./tabs/ConnectTab";
 import MetricsTab from "./tabs/MetricsTab";
@@ -46,7 +45,7 @@ const DatabaseTabs: React.FC<DbTabProps> = ({ tabParam }) => {
     return [
       { label: "Connect", value: "connect" },
       { label: "Connected Apps", value: "connected-apps" },
-      { label: "Configuration", value: "configuration" },
+      // { label: "Configuration", value: "configuration" },
       { label: "Settings", value: "settings" },
     ];
   }, []);
@@ -70,7 +69,7 @@ const DatabaseTabs: React.FC<DbTabProps> = ({ tabParam }) => {
         .with("connect", () => <ConnectTab />)
         .with("settings", () => <SettingsTab />)
         .with("metrics", () => <MetricsTab />)
-        .with("configuration", () => <ConfigurationTab />)
+        // .with("configuration", () => <ConfigurationTab />)
         .with("connected-apps", () => <ConnectedAppsTab />)
         .otherwise(() => null)}
       <Spacer y={2} />

+ 38 - 53
dashboard/src/main/home/env-dashboard/CreateEnvGroup.tsx

@@ -1,26 +1,29 @@
-import React, { useState, useEffect, useContext, useMemo } from 'react';
-import styled from 'styled-components';
+import React, { useContext, useEffect, useMemo, useState } from "react";
 import { zodResolver } from "@hookform/resolvers/zod";
+import axios from "axios";
 import { FormProvider, useForm } from "react-hook-form";
 import { withRouter, type RouteComponentProps } from "react-router";
+import styled from "styled-components";
 
-import api from 'shared/api';
-import { Context } from 'shared/Context';
-import { type EnvGroupFormData, envGroupFormValidator } from 'lib/env-groups/types';
+import Back from "components/porter/Back";
+import Button from "components/porter/Button";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Error from "components/porter/Error";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import VerticalSteps from "components/porter/VerticalSteps";
+import {
+  envGroupFormValidator,
+  type EnvGroupFormData,
+} from "lib/env-groups/types";
 
-import envGrad from 'assets/env-group-grad.svg';
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { envGroupPath } from "shared/util";
+import envGrad from "assets/env-group-grad.svg";
 
-import Error from "components/porter/Error";
-import Back from "components/porter/Back";
-import DashboardHeader from '../cluster-dashboard/DashboardHeader';
-import VerticalSteps from 'components/porter/VerticalSteps';
-import Text from 'components/porter/Text';
-import Spacer from 'components/porter/Spacer';
-import { ControlledInput } from 'components/porter/ControlledInput';
-import Button from 'components/porter/Button';
-import EnvGroupArray, { type KeyValueType } from './EnvGroupArray';
-import axios from 'axios';
-import {envGroupPath} from "shared/util";
+import DashboardHeader from "../cluster-dashboard/DashboardHeader";
+import EnvGroupArray, { type KeyValueType } from "./EnvGroupArray";
 
 const CreateEnvGroup: React.FC<RouteComponentProps> = ({ history }) => {
   const { currentProject, currentCluster } = useContext(Context);
@@ -37,12 +40,12 @@ const CreateEnvGroup: React.FC<RouteComponentProps> = ({ history }) => {
           hidden: false,
           locked: false,
           deleted: false,
-        }
-      ]
-    }
+        },
+      ],
+    },
   });
 
-  const { 
+  const {
     formState: { isValidating, isSubmitting, errors },
     register,
     watch,
@@ -77,30 +80,6 @@ const CreateEnvGroup: React.FC<RouteComponentProps> = ({ history }) => {
     const secretEnvVariables: Record<string, string> = {};
     const envVariable = data.envVariables;
     try {
-
-      // Create env group namespace if it doesn't exist
-      const res = await api.getNamespaces(
-        "<token>",
-        {},
-        {
-          id: currentProject?.id ?? -1,
-          cluster_id: currentCluster?.id ?? -1,
-        }
-      );
-      const namespaceExists = res.data.some((n: { name: string }) => n.name === "porter-env-group");
-      if (!namespaceExists) {
-        await api.createNamespace(
-          "<token>",
-          {
-            name: "porter-env-group",
-          },
-          {
-            id: currentProject?.id ?? -1,
-            cluster_id: currentCluster?.id ?? -1,
-          }
-        );
-      }
-
       // Old env var create logic
       envVariable
         .filter((envVar: KeyValueType, index: number, self: KeyValueType[]) => {
@@ -137,14 +116,17 @@ const CreateEnvGroup: React.FC<RouteComponentProps> = ({ history }) => {
           name: data.name,
           variables: apiEnvVariables,
           secret_variables: secretEnvVariables,
+          is_env_override: true,
         },
         {
           id: currentProject?.id ?? -1,
           cluster_id: currentCluster?.id ?? -1,
         }
-      )
-        
-      history.push(envGroupPath(currentProject, `/${data.name}/env-vars?created=true`));
+      );
+
+      history.push(
+        envGroupPath(currentProject, `/${data.name}/env-vars?created=true`)
+      );
     } catch (err) {
       const errorMessage =
         axios.isAxiosError(err) && err.response?.data?.error
@@ -192,7 +174,9 @@ const CreateEnvGroup: React.FC<RouteComponentProps> = ({ history }) => {
                       placeholder="ex: academic-sophon-db"
                       type="text"
                       width="320px"
-                      error={name?.length > 0 ? errors.name?.message : undefined}
+                      error={
+                        name?.length > 0 ? errors.name?.message : undefined
+                      }
                       {...register("name")}
                     />
                   </>,
@@ -200,7 +184,8 @@ const CreateEnvGroup: React.FC<RouteComponentProps> = ({ history }) => {
                     <Text size={16}>Environment variables</Text>
                     <Spacer y={0.5} />
                     <Text color="helper">
-                      Set environment-specific configuration including evironment variables and secrets.
+                      Set environment-specific configuration including
+                      evironment variables and secrets.
                     </Text>
                     <Spacer height="15px" />
                     <EnvGroupArray
@@ -220,7 +205,7 @@ const CreateEnvGroup: React.FC<RouteComponentProps> = ({ history }) => {
                     width="140px"
                   >
                     Deploy env group
-                  </Button>
+                  </Button>,
                 ]}
               />
             </form>
@@ -230,7 +215,7 @@ const CreateEnvGroup: React.FC<RouteComponentProps> = ({ history }) => {
       </Div>
     </CenterWrapper>
   );
-}
+};
 
 export default withRouter(CreateEnvGroup);
 
@@ -270,4 +255,4 @@ const CenterWrapper = styled.div`
 const DarkMatter = styled.div<{ antiHeight?: string }>`
   width: 100%;
   margin-top: ${(props) => props.antiHeight || "-5px"};
-`;
+`;

+ 3 - 12
dashboard/src/main/home/env-dashboard/tabs/EnvVarsTab.tsx

@@ -71,8 +71,8 @@ const EnvVarsTab: React.FC<Props> = ({ envGroup, fetchEnvGroup }) => {
     ).map(([key, value]) => ({
       key,
       value,
-      hidden: (value as string).includes("PORTERSECRET"),
-      locked: (value as string).includes("PORTERSECRET"),
+      hidden: (value ).includes("PORTERSECRET"),
+      locked: (value ).includes("PORTERSECRET"),
       deleted: false,
     }));
     const secretVariables = Object.entries(
@@ -139,6 +139,7 @@ const EnvVarsTab: React.FC<Props> = ({ envGroup, fetchEnvGroup }) => {
             name: envGroup.name,
             variables: apiEnvVariables,
             secret_variables: secretEnvVariables,
+            is_env_override: true,
           },
           {
             id: currentProject?.id ?? -1,
@@ -147,16 +148,6 @@ const EnvVarsTab: React.FC<Props> = ({ envGroup, fetchEnvGroup }) => {
         );
       };
      
-      await api.updateAppsLinkedToEnvironmentGroup(
-        "<token>",
-        {
-          name: envGroup?.name,
-        },
-        {
-          id: currentProject?.id || -1,
-          cluster_id: currentCluster?.id || -1,
-        }
-      );
       fetchEnvGroup();
       setButtonStatus("success");
     } catch (err) {

+ 8 - 2
dashboard/src/main/home/infrastructure-dashboard/forms/CloudProviderSelect.tsx

@@ -1,4 +1,4 @@
-import React, { useState } from "react";
+import React, { useContext, useState } from "react";
 import styled from "styled-components";
 
 import Button from "components/porter/Button";
@@ -14,6 +14,7 @@ import {
 } from "lib/clusters/constants";
 import { type ClientCloudProvider } from "lib/clusters/types";
 
+import { Context } from "shared/Context";
 import bolt from "assets/bolt.svg";
 
 import CostConsentModal from "../modals/cost-consent/CostConsentModal";
@@ -25,6 +26,7 @@ const CloudProviderSelect: React.FC<Props> = ({ onComplete }) => {
   const [cloudProvider, setCloudProvider] = useState<
     ClientCloudProvider | undefined
   >(undefined);
+  const { user } = useContext(Context);
 
   return (
     <div>
@@ -43,7 +45,11 @@ const CloudProviderSelect: React.FC<Props> = ({ onComplete }) => {
                 <Block
                   key={i}
                   onClick={() => {
-                    setCloudProvider(provider);
+                    if (user?.isPorterUser) {
+                      onComplete(provider);
+                    } else {
+                      setCloudProvider(provider);
+                    }
                   }}
                 >
                   <Icon src={provider.icon} />

+ 4 - 1
dashboard/src/main/home/infrastructure-dashboard/forms/aws/ConfigureEKSCluster.tsx

@@ -131,7 +131,10 @@ const ConfigureEKSCluster: React.FC<Props> = ({ goBack }) => {
               </a>
             </Text>
             <Spacer y={1} />
-            <NodeGroups availableMachineTypes={CloudProviderAWS.machineTypes} />
+            <NodeGroups
+              availableMachineTypes={CloudProviderAWS.machineTypes}
+              isCreating
+            />
           </>,
           <>
             <Text size={16}>Provision cluster</Text>

+ 88 - 37
dashboard/src/main/home/infrastructure-dashboard/forms/aws/GrantAWSPermissions.tsx

@@ -1,6 +1,7 @@
 import React, { useCallback, useEffect, useMemo, useState } from "react";
 import { useQuery } from "@tanstack/react-query";
 import axios from "axios";
+import AnimateHeight from "react-animate-height";
 import styled from "styled-components";
 import { v4 as uuidv4 } from "uuid";
 import { z } from "zod";
@@ -18,11 +19,14 @@ import Text from "components/porter/Text";
 import VerticalSteps from "components/porter/VerticalSteps";
 import { type ButtonStatus } from "main/home/app-dashboard/app-view/AppDataContainer";
 import { CloudProviderAWS } from "lib/clusters/constants";
-import { isAWSArnAccessible } from "lib/hooks/useCloudProvider";
+import { connectToAwsAccount } from "lib/hooks/useCloudProvider";
 import { useClusterAnalytics } from "lib/hooks/useClusterAnalytics";
 import { useIntercom } from "lib/hooks/useIntercom";
 
+import api from "shared/api";
+
 import GrantAWSPermissionsHelpModal from "../../modals/help/permissions/GrantAWSPermissionsHelpModal";
+import { CheckItem } from "../../modals/PreflightChecksModal";
 
 type Props = {
   goBack: () => void;
@@ -95,12 +99,22 @@ const GrantAWSPermissions: React.FC<Props> = ({
     ],
     async () => {
       try {
-        const res = await isAWSArnAccessible({
-          targetArn: `arn:aws:iam::${AWSAccountID}:role/porter-manager`,
-          externalId,
-          projectId,
-        });
-        return res;
+        const res = await api.getCloudProviderPermissionsStatus(
+          "<token>",
+          {
+            cloud_provider: "AWS",
+            cloud_provider_credential_identifier: `arn:aws:iam::${AWSAccountID}:role/porter-manager`,
+          },
+          {
+            project_id: projectId,
+          }
+        );
+        const parsed = z
+          .object({
+            percent_completed: z.number(),
+          })
+          .parse(res.data);
+        return parsed.percent_completed;
       } catch (err) {
         return 0;
       }
@@ -126,11 +140,22 @@ const GrantAWSPermissions: React.FC<Props> = ({
   const checkIfAlreadyAccessible = async (): Promise<void> => {
     setAccountIdContinueButtonStatus("loading");
     try {
-      const awsIntegrationPercentCompleted = await isAWSArnAccessible({
-        targetArn: `arn:aws:iam::${AWSAccountID}:role/porter-manager`,
-        externalId,
-        projectId,
-      });
+      const res = await api.getCloudProviderPermissionsStatus(
+        "<token>",
+        {
+          cloud_provider: "AWS",
+          cloud_provider_credential_identifier: `arn:aws:iam::${AWSAccountID}:role/porter-manager`,
+        },
+        {
+          project_id: projectId,
+        }
+      );
+      const parsed = z
+        .object({
+          percent_completed: z.number(),
+        })
+        .parse(res.data);
+      const awsIntegrationPercentCompleted = parsed.percent_completed;
       if (awsIntegrationPercentCompleted > 0) {
         // this indicates the permission check is already in place; no need to re-create cloudformation stack
         setCurrentStep(3);
@@ -187,6 +212,17 @@ const GrantAWSPermissions: React.FC<Props> = ({
   };
 
   const directToCloudFormation = useCallback(async () => {
+    try {
+      // this sends an async connection request on the backend
+      await connectToAwsAccount({
+        targetArn: `arn:aws:iam::${AWSAccountID}:role/porter-manager`,
+        externalId,
+        projectId,
+      });
+    } catch (err) {
+      // todo: handle error here
+    }
+
     const trustArn = process.env.TRUST_ARN
       ? process.env.TRUST_ARN
       : "arn:aws:iam::108458755588:role/CAPIManagement";
@@ -362,32 +398,47 @@ const GrantAWSPermissions: React.FC<Props> = ({
           <>
             <Text size={16}>Check permissions</Text>
             <Spacer y={1} />
-            <StatusBar
-              icon={CloudProviderAWS.icon}
-              title={"AWS permissions setup"}
-              titleDescriptor={awsPermissionsLoadingMessage}
-              subtitle={
-                permissionsGrantCompletionPercentage === 100
-                  ? "Porter can access your account! You may now continue."
-                  : "Porter is creating roles and policies to access your account. This can take up to 15 minutes. Please stay on this page."
-              }
-              percentCompleted={Math.max(
-                permissionsGrantCompletionPercentage,
-                5
-              )}
-            />
-            <Spacer y={0.5} />
-            <Link
-              hasunderline
-              onClick={() => {
-                showIntercomWithMessage({
-                  message: "I need help with AWS permissions setup.",
-                  delaySeconds: 0,
-                });
-              }}
+            <AnimateHeight
+              height={permissionsGrantCompletionPercentage === 100 ? 0 : "auto"}
             >
-              Need help?
-            </Link>
+              <StatusBar
+                icon={CloudProviderAWS.icon}
+                title={"AWS permissions setup"}
+                titleDescriptor={awsPermissionsLoadingMessage}
+                subtitle={
+                  permissionsGrantCompletionPercentage === 100
+                    ? "Porter can access your account! You may now continue."
+                    : "Porter is creating roles and policies to access your account. This can take up to 15 minutes. Please stay on this page."
+                }
+                percentCompleted={Math.max(
+                  permissionsGrantCompletionPercentage,
+                  5
+                )}
+              />
+              <Spacer y={0.5} />
+              <Link
+                hasunderline
+                onClick={() => {
+                  showIntercomWithMessage({
+                    message: "I need help with AWS permissions setup.",
+                    delaySeconds: 0,
+                  });
+                }}
+              >
+                Need help?
+              </Link>
+            </AnimateHeight>
+            <AnimateHeight
+              height={permissionsGrantCompletionPercentage === 100 ? "auto" : 0}
+            >
+              <CheckItem
+                preflightCheck={{
+                  title:
+                    "AWS account is accessible by Porter! You may continue.",
+                  status: "success",
+                }}
+              />
+            </AnimateHeight>
             <Spacer y={1} />
             <Container row>
               <Button

+ 1 - 0
dashboard/src/main/home/infrastructure-dashboard/forms/azure/ConfigureAKSCluster.tsx

@@ -162,6 +162,7 @@ const ConfigureAKSCluster: React.FC<Props> = ({ goBack }) => {
               availableMachineTypes={CloudProviderAzure.machineTypes.filter(
                 (mt) => mt.supportedRegions.includes(region)
               )}
+              isCreating
             />
           </>,
           <>

+ 1 - 1
dashboard/src/main/home/infrastructure-dashboard/forms/azure/CreateAKSClusterForm.tsx

@@ -58,7 +58,7 @@ const CreateAKSClusterForm: React.FC<Props> = ({
             },
             {
               nodeGroupType: "MONITORING" as const,
-              instanceType: "Standard_B2als_v2",
+              instanceType: "Standard_B2as_v2",
               minInstances: 1,
               maxInstances: 3,
             },

+ 4 - 1
dashboard/src/main/home/infrastructure-dashboard/forms/gcp/ConfigureGKECluster.tsx

@@ -145,7 +145,10 @@ const ConfigureGKECluster: React.FC<Props> = ({ goBack }) => {
               </a>
             </Text>
             <Spacer y={1} />
-            <NodeGroups availableMachineTypes={CloudProviderGCP.machineTypes} />
+            <NodeGroups
+              availableMachineTypes={CloudProviderGCP.machineTypes}
+              isCreating
+            />
           </>,
           <>
             <Text size={16}>Provision cluster</Text>

+ 2 - 2
dashboard/src/main/home/infrastructure-dashboard/forms/gcp/GrantGCPPermissions.tsx

@@ -139,7 +139,7 @@ const GrantGCPPermissions: React.FC<Props> = ({
             <Text size={16}> Create the service account </Text>
             <Spacer y={0.5} />
             <Link
-              to="https://docs.porter.run/standard/getting-started/provisioning-on-gcp"
+              to="https://docs.porter.run/provision/provisioning-on-gcp"
               target="_blank"
             >
               Follow the steps in the Porter docs to generate your service
@@ -227,7 +227,7 @@ const GrantGCPPermissions: React.FC<Props> = ({
             <Container row>
               <Button
                 onClick={() => {
-                  setCurrentStep(2);
+                  setCurrentStep(1);
                 }}
                 color="#222222"
               >

+ 12 - 16
dashboard/src/main/home/infrastructure-dashboard/modals/PreflightChecksModal.tsx

@@ -1,4 +1,4 @@
-import React, { useContext } from "react";
+import React from "react";
 import styled from "styled-components";
 import { match } from "ts-pattern";
 
@@ -8,14 +8,13 @@ import Container from "components/porter/Container";
 import { Error as ErrorComponent } from "components/porter/Error";
 import Expandable from "components/porter/Expandable";
 import Modal from "components/porter/Modal";
+import PorterOperatorComponent from "components/porter/PorterOperatorComponent";
 import ShowIntercomButton from "components/porter/ShowIntercomButton";
 import Spacer from "components/porter/Spacer";
 import StatusDot from "components/porter/StatusDot";
 import Text from "components/porter/Text";
 import { type ClientPreflightCheck } from "lib/clusters/types";
 
-import { Context } from "shared/Context";
-
 import { useClusterFormContext } from "../ClusterFormContextProvider";
 import ResolutionStepsModalContents from "./help/preflight/ResolutionStepsModalContents";
 
@@ -88,7 +87,6 @@ const PreflightChecksModal: React.FC<Props> = ({
   onClose,
   preflightChecks,
 }) => {
-  const { user } = useContext(Context);
   const { submitSkippingPreflightChecks } = useClusterFormContext();
 
   return (
@@ -118,18 +116,16 @@ const PreflightChecksModal: React.FC<Props> = ({
           >
             Talk to support
           </ShowIntercomButton>
-          {user.email?.endsWith("@porter.run") && (
-            <>
-              <Button
-                onClick={async () => {
-                  await submitSkippingPreflightChecks();
-                }}
-                color="red"
-              >
-                (Porter operators only) Skip preflight checks
-              </Button>
-            </>
-          )}
+          <PorterOperatorComponent>
+            <Button
+              onClick={async () => {
+                await submitSkippingPreflightChecks();
+              }}
+              color="red"
+            >
+              Skip preflight checks
+            </Button>
+          </PorterOperatorComponent>
         </Container>
       </AppearingDiv>
     </Modal>

+ 352 - 99
dashboard/src/main/home/infrastructure-dashboard/shared/NodeGroups.tsx

@@ -8,6 +8,7 @@ import Container from "components/porter/Container";
 import Expandable from "components/porter/Expandable";
 import Image from "components/porter/Image";
 import Input from "components/porter/Input";
+import PorterOperatorComponent from "components/porter/PorterOperatorComponent";
 import Select from "components/porter/Select";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
@@ -23,17 +24,19 @@ import world from "assets/world.svg";
 type Props = {
   availableMachineTypes: ClientMachineType[];
   isDefaultExpanded?: boolean;
+  isCreating?: boolean;
 };
 const NodeGroups: React.FC<Props> = ({
   availableMachineTypes,
   isDefaultExpanded = true,
+  isCreating = false,
 }) => {
   const { control } = useFormContext<ClientClusterContract>();
   const { currentProject } = useContext(Context);
   const {
     fields: nodeGroups,
     append,
-    // remove,
+    remove,
   } = useFieldArray({
     control,
     name: "cluster.config.nodeGroups",
@@ -88,48 +91,249 @@ const NodeGroups: React.FC<Props> = ({
                     }}
                     label="Machine type"
                   />
-                  <Spacer y={1} />
-                  <Text color="helper">
-                    Minimum number of application nodes
-                  </Text>
-                  <Spacer y={0.5} />
-                  <Input
-                    width="75px"
-                    type="number"
-                    disabled={false}
-                    value={value.minInstances.toString()}
-                    setValue={(newMinInstances: string) => {
-                      onChange({
-                        ...value,
-                        minInstances: parseInt(newMinInstances),
-                      });
-                    }}
-                    placeholder="ex: 1"
-                  />
-                  <Spacer y={1} />
-                  <Text color="helper">
-                    Maximum number of application nodes
-                  </Text>
-                  <Spacer y={0.5} />
-                  <Input
-                    width="75px"
-                    type="number"
-                    disabled={false}
-                    value={value.maxInstances.toString()}
-                    setValue={(newMaxInstances: string) => {
-                      onChange({
-                        ...value,
-                        maxInstances: parseInt(newMaxInstances),
-                      });
-                    }}
-                    placeholder="ex: 10"
-                  />
+                  {isCreating ? (
+                    <>
+                      <Spacer y={1} />
+                      <PorterOperatorComponent>
+                        <>
+                          <Text color="helper">
+                            Minimum number of application nodes
+                          </Text>
+                          <Spacer y={0.5} />
+                          <Input
+                            width="75px"
+                            type="number"
+                            disabled={false}
+                            value={value.minInstances.toString()}
+                            setValue={(newMinInstances: string) => {
+                              onChange({
+                                ...value,
+                                minInstances: parseInt(newMinInstances),
+                              });
+                            }}
+                            placeholder="ex: 1"
+                          />
+                          <Spacer y={1} />
+                          <Text color="helper">
+                            Maximum number of application nodes
+                          </Text>
+                          <Spacer y={0.5} />
+                          <Input
+                            width="75px"
+                            type="number"
+                            disabled={false}
+                            value={value.maxInstances.toString()}
+                            setValue={(newMaxInstances: string) => {
+                              onChange({
+                                ...value,
+                                maxInstances: parseInt(newMaxInstances),
+                              });
+                            }}
+                            placeholder="ex: 10"
+                          />
+                        </>
+                      </PorterOperatorComponent>
+                    </>
+                  ) : (
+                    <>
+                      <Spacer y={1} />
+                      <Text color="helper">
+                        Minimum number of application nodes
+                      </Text>
+                      <Spacer y={0.5} />
+                      <Input
+                        width="75px"
+                        type="number"
+                        disabled={false}
+                        value={value.minInstances.toString()}
+                        setValue={(newMinInstances: string) => {
+                          onChange({
+                            ...value,
+                            minInstances: parseInt(newMinInstances),
+                          });
+                        }}
+                        placeholder="ex: 1"
+                      />
+                      <Spacer y={1} />
+                      <Text color="helper">
+                        Maximum number of application nodes
+                      </Text>
+                      <Spacer y={0.5} />
+                      <Input
+                        width="75px"
+                        type="number"
+                        disabled={false}
+                        value={value.maxInstances.toString()}
+                        setValue={(newMaxInstances: string) => {
+                          onChange({
+                            ...value,
+                            maxInstances: parseInt(newMaxInstances),
+                          });
+                        }}
+                        placeholder="ex: 10"
+                      />
+                    </>
+                  )}
                 </>
               )}
             />
           </Expandable>
         );
       })}
+      <PorterOperatorComponent>
+        <>
+          {displayableNodeGroups.MONITORING?.map((ng) => {
+            return (
+              <Expandable
+                preExpanded={isDefaultExpanded}
+                key={ng.nodeGroup.id}
+                header={
+                  <Container row>
+                    <Image src={world} />
+                    <Spacer inline x={1} />
+                    Monitoring node group
+                  </Container>
+                }
+              >
+                <Controller
+                  name={`cluster.config.nodeGroups.${ng.idx}`}
+                  control={control}
+                  render={({ field: { value, onChange } }) => (
+                    <>
+                      <Select
+                        width="300px"
+                        options={availableMachineTypes
+                          .filter((t) => !t.isGPU)
+                          .map((t) => ({
+                            value: t.name,
+                            label: t.displayName,
+                          }))}
+                        value={value.instanceType}
+                        setValue={(newInstanceType: string) => {
+                          onChange({
+                            ...value,
+                            instanceType: newInstanceType,
+                          });
+                        }}
+                        label="Machine type"
+                      />
+                      <Spacer y={1} />
+                      <Text color="helper">
+                        Minimum number of monitoring nodes
+                      </Text>
+                      <Spacer y={0.5} />
+                      <Input
+                        width="75px"
+                        type="number"
+                        disabled={false}
+                        value={value.minInstances.toString()}
+                        setValue={(newMinInstances: string) => {
+                          onChange({
+                            ...value,
+                            minInstances: parseInt(newMinInstances),
+                          });
+                        }}
+                        placeholder="ex: 1"
+                      />
+                      <Spacer y={1} />
+                      <Text color="helper">
+                        Maximum number of monitoring nodes
+                      </Text>
+                      <Spacer y={0.5} />
+                      <Input
+                        width="75px"
+                        type="number"
+                        disabled={false}
+                        value={value.maxInstances.toString()}
+                        setValue={(newMaxInstances: string) => {
+                          onChange({
+                            ...value,
+                            maxInstances: parseInt(newMaxInstances),
+                          });
+                        }}
+                        placeholder="ex: 10"
+                      />
+                    </>
+                  )}
+                />
+              </Expandable>
+            );
+          })}
+          {displayableNodeGroups.SYSTEM?.map((ng) => {
+            return (
+              <Expandable
+                preExpanded={isDefaultExpanded}
+                key={ng.nodeGroup.id}
+                header={
+                  <Container row>
+                    <Image src={world} />
+                    <Spacer inline x={1} />
+                    System node group
+                  </Container>
+                }
+              >
+                <Controller
+                  name={`cluster.config.nodeGroups.${ng.idx}`}
+                  control={control}
+                  render={({ field: { value, onChange } }) => (
+                    <>
+                      <Select
+                        width="300px"
+                        options={availableMachineTypes
+                          .filter((t) => !t.isGPU)
+                          .map((t) => ({
+                            value: t.name,
+                            label: t.displayName,
+                          }))}
+                        value={value.instanceType}
+                        setValue={(newInstanceType: string) => {
+                          onChange({
+                            ...value,
+                            instanceType: newInstanceType,
+                          });
+                        }}
+                        label="Machine type"
+                      />
+                      <Spacer y={1} />
+                      <Text color="helper">Minimum number of system nodes</Text>
+                      <Spacer y={0.5} />
+                      <Input
+                        width="75px"
+                        type="number"
+                        disabled={false}
+                        value={value.minInstances.toString()}
+                        setValue={(newMinInstances: string) => {
+                          onChange({
+                            ...value,
+                            minInstances: parseInt(newMinInstances),
+                          });
+                        }}
+                        placeholder="ex: 1"
+                      />
+                      <Spacer y={1} />
+                      <Text color="helper">Maximum number of system nodes</Text>
+                      <Spacer y={0.5} />
+                      <Input
+                        width="75px"
+                        type="number"
+                        disabled={false}
+                        value={value.maxInstances.toString()}
+                        setValue={(newMaxInstances: string) => {
+                          onChange({
+                            ...value,
+                            maxInstances: parseInt(newMaxInstances),
+                          });
+                        }}
+                        placeholder="ex: 10"
+                      />
+                    </>
+                  )}
+                />
+              </Expandable>
+            );
+          })}
+        </>
+      </PorterOperatorComponent>
       {displayableNodeGroups.CUSTOM?.map((ng) => {
         return (
           <Expandable
@@ -142,16 +346,18 @@ const NodeGroups: React.FC<Props> = ({
                   <Spacer inline x={1} />
                   GPU node group
                 </Container>
-                {/* <Container row>
-                  <ActionButton
-                    onClick={(e) => {
-                      e.stopPropagation();
-                      remove(ng.idx);
-                    }}
-                  >
-                    <span className="material-icons">delete</span>
-                  </ActionButton>
-                </Container> */}
+                {isCreating && (
+                  <Container row>
+                    <ActionButton
+                      onClick={(e) => {
+                        e.stopPropagation();
+                        remove(ng.idx);
+                      }}
+                    >
+                      <span className="material-icons">delete</span>
+                    </ActionButton>
+                  </Container>
+                )}
               </Container>
             }
           >
@@ -177,38 +383,85 @@ const NodeGroups: React.FC<Props> = ({
                     }}
                     label="Machine type"
                   />
-                  <Spacer y={1} />
-                  <Text color="helper">Minimum number of GPU nodes</Text>
-                  <Spacer y={0.5} />
-                  <Input
-                    width="75px"
-                    type="number"
-                    disabled={false}
-                    value={value.minInstances.toString()}
-                    setValue={(newMinInstances: string) => {
-                      onChange({
-                        ...value,
-                        minInstances: parseInt(newMinInstances),
-                      });
-                    }}
-                    placeholder="ex: 1"
-                  />
-                  <Spacer y={1} />
-                  <Text color="helper">Maximum number of GPU nodes</Text>
-                  <Spacer y={0.5} />
-                  <Input
-                    width="75px"
-                    type="number"
-                    disabled={false}
-                    value={value.maxInstances.toString()}
-                    setValue={(newMaxInstances: string) => {
-                      onChange({
-                        ...value,
-                        maxInstances: parseInt(newMaxInstances),
-                      });
-                    }}
-                    placeholder="ex: 10"
-                  />
+                  {isCreating ? (
+                    <>
+                      <Spacer y={1} />
+                      <PorterOperatorComponent>
+                        <>
+                          <Text color="helper">
+                            Minimum number of GPU nodes
+                          </Text>
+                          <Spacer y={0.5} />
+                          <Input
+                            width="75px"
+                            type="number"
+                            disabled={false}
+                            value={value.minInstances.toString()}
+                            setValue={(newMinInstances: string) => {
+                              onChange({
+                                ...value,
+                                minInstances: parseInt(newMinInstances),
+                              });
+                            }}
+                            placeholder="ex: 1"
+                          />
+                          <Spacer y={1} />
+                          <Text color="helper">
+                            Maximum number of GPU nodes
+                          </Text>
+                          <Spacer y={0.5} />
+                          <Input
+                            width="75px"
+                            type="number"
+                            disabled={false}
+                            value={value.maxInstances.toString()}
+                            setValue={(newMaxInstances: string) => {
+                              onChange({
+                                ...value,
+                                maxInstances: parseInt(newMaxInstances),
+                              });
+                            }}
+                            placeholder="ex: 10"
+                          />
+                        </>
+                      </PorterOperatorComponent>
+                    </>
+                  ) : (
+                    <>
+                      <Spacer y={1} />
+                      <Text color="helper">Minimum number of GPU nodes</Text>
+                      <Spacer y={0.5} />
+                      <Input
+                        width="75px"
+                        type="number"
+                        disabled={false}
+                        value={value.minInstances.toString()}
+                        setValue={(newMinInstances: string) => {
+                          onChange({
+                            ...value,
+                            minInstances: parseInt(newMinInstances),
+                          });
+                        }}
+                        placeholder="ex: 1"
+                      />
+                      <Spacer y={1} />
+                      <Text color="helper">Maximum number of GPU nodes</Text>
+                      <Spacer y={0.5} />
+                      <Input
+                        width="75px"
+                        type="number"
+                        disabled={false}
+                        value={value.maxInstances.toString()}
+                        setValue={(newMaxInstances: string) => {
+                          onChange({
+                            ...value,
+                            maxInstances: parseInt(newMaxInstances),
+                          });
+                        }}
+                        placeholder="ex: 10"
+                      />
+                    </>
+                  )}
                 </>
               )}
             />
@@ -260,24 +513,24 @@ const I = styled.i`
   }
 `;
 
-// const ActionButton = styled.button`
-//   position: relative;
-//   border: none;
-//   background: none;
-//   color: white;
-//   padding: 5px;
-//   display: flex;
-//   justify-content: center;
-//   align-items: center;
-//   border-radius: 50%;
-//   cursor: pointer;
-//   color: #aaaabb;
-//   :hover {
-//     color: white;
-//   }
+const ActionButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  cursor: pointer;
+  color: #aaaabb;
+  :hover {
+    color: white;
+  }
 
-//   > span {
-//     font-size: 20px;
-//   }
-//   margin-right: 5px;
-// `;
+  > span {
+    font-size: 20px;
+  }
+  margin-right: 5px;
+`;

+ 50 - 14
dashboard/src/shared/api.tsx

@@ -1229,6 +1229,18 @@ const getLatestAppRevisions = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/apps/revisions`;
 });
 
+const getAppInstances = baseApi<
+  {
+    deployment_target_id: string | undefined;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>("GET", ({ project_id, cluster_id }) => {
+  return `/api/projects/${project_id}/clusters/${cluster_id}/apps/instances`;
+});
+
 const listDeploymentTargets = baseApi<
   {
     preview: boolean;
@@ -1967,19 +1979,6 @@ const updateDatabaseStatus = baseApi<
 >("POST", (pathParams) => {
   return `/api/projects/${pathParams.project_id}/infras/${pathParams.infra_id}/database`;
 });
-// GET /api/projects/{project_id}/clusters/{cluster_id}/datastore/status
-const getDatabaseStatus = baseApi<
-  {
-    name: string;
-    type: string;
-  },
-  {
-    project_id: number;
-    cluster_id: number;
-  }
->("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/datastore/status`;
-});
 
 const getRepoIntegrations = baseApi("GET", "/api/integrations/repo");
 
@@ -2283,6 +2282,7 @@ const createEnvironmentGroups = baseApi<
     secret_variables?: Record<string, string>;
     type?: string;
     auth_token?: string;
+    is_env_override?: boolean;
   },
   {
     id: number;
@@ -3451,6 +3451,38 @@ const createSecretAndOpenGitHubPullRequest = baseApi<
     `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/pr`
 );
 
+const getCloudProviderPermissionsStatus = baseApi<
+  {
+    cloud_provider: string;
+    cloud_provider_credential_identifier: string;
+  },
+  { project_id: number }
+>(
+  "GET",
+  ({ project_id }) =>
+    `/api/projects/${project_id}/integrations/cloud-permissions`
+);
+
+const getCloudSqlSecret = baseApi<
+  {},
+  { project_id: number; deployment_target_id: string; app_name: string }
+>(
+  "GET",
+  ({ project_id, deployment_target_id, app_name }) =>
+    `/api/projects/${project_id}/targets/${deployment_target_id}/apps/${app_name}/cloudsql`
+);
+
+const createCloudSqlSecret = baseApi<
+  {
+    b64_service_account_json: string;
+  },
+  { project_id: number; deployment_target_id: string; app_name: string }
+>(
+  "POST",
+  ({ project_id, deployment_target_id, app_name }) =>
+    `/api/projects/${project_id}/targets/${deployment_target_id}/apps/${app_name}/cloudsql`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -3604,6 +3636,7 @@ export default {
   getRevision,
   listAppRevisions,
   getLatestAppRevisions,
+  getAppInstances,
   listDeploymentTargets,
   createDeploymentTarget,
   getDeploymentTarget,
@@ -3740,5 +3773,8 @@ export default {
 
   // STATUS
   getGithubStatus,
-  getDatabaseStatus,
+  getCloudProviderPermissionsStatus,
+
+  getCloudSqlSecret,
+  createCloudSqlSecret,
 };

+ 2 - 2
go.mod

@@ -83,7 +83,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.2.114
+	github.com/porter-dev/api-contracts v0.2.124
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d
@@ -228,7 +228,7 @@ require (
 	github.com/docker/go-units v0.4.0 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/emirpasic/gods v1.18.1 // indirect
-	github.com/evanphx/json-patch v5.6.0+incompatible // indirect
+	github.com/evanphx/json-patch v5.9.0+incompatible
 	github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
 	github.com/fsnotify/fsnotify v1.5.4 // indirect
 	github.com/gdamore/encoding v1.0.0 // indirect

+ 6 - 4
go.sum

@@ -586,8 +586,8 @@ github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHj
 github.com/ettle/strcase v0.1.1/go.mod h1:hzDLsPC7/lwKyBOywSHEP89nt2pDgdy+No1NBA9o9VY=
 github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
 github.com/evanphx/json-patch v4.11.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
-github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U=
-github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
+github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls=
+github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
 github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4=
 github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc=
 github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
@@ -1523,8 +1523,10 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.2.114 h1:qfEq70BJ8xXTkiZU7ygzOSGnMCqJHOa5Lbkfu4OzQBI=
-github.com/porter-dev/api-contracts v0.2.114/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.123 h1:bDtyC2ueirKmu9NN1YEClv2qVrMjvu913HGibG7ISRQ=
+github.com/porter-dev/api-contracts v0.2.123/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.124 h1:0ChXriR88KanBMMJfDWIabEvPqt9eLsmOScDbuJucBQ=
+github.com/porter-dev/api-contracts v0.2.124/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

+ 32 - 0
internal/datastore/datastore.go

@@ -0,0 +1,32 @@
+package datastore
+
+import (
+	"time"
+
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/api/server/handlers/environment_groups"
+)
+
+// Datastore describes an outbound datastores response entry
+type Datastore struct {
+	// Name is the name of the datastore
+	Name string `json:"name"`
+	// Type is the type of the datastore
+	Type string `json:"type"`
+	// Engine is the engine of the datastore
+	Engine string `json:"engine,omitempty"`
+	// Env is the env group for the datastore
+	Env environment_groups.EnvironmentGroupListItem `json:"env,omitempty"`
+	// Metadata is a list of metadata objects for the datastore - TODO: remove this field, it is unnecessary
+	Metadata []*porterv1.DatastoreMetadata `json:"metadata,omitempty"`
+	// Status is the status of the datastore
+	Status string `json:"status"`
+	// CreatedAtUTC is the time the datastore was created in UTC
+	CreatedAtUTC time.Time `json:"created_at"`
+	// CloudProvider is the cloud provider associated with the datastore
+	CloudProvider string `json:"cloud_provider"`
+	// CloudProviderCredentialIdentifier is the cloud provider credential identifier associated with the datastore
+	CloudProviderCredentialIdentifier string `json:"cloud_provider_credential_identifier"`
+	// B64Proto is the base64 encoded datastore proto. Note that this is only populated for datastores created with the new cloud contract flow
+	B64Proto string `json:"b64_proto"`
+}

+ 4 - 9
internal/helm/agent.go

@@ -54,15 +54,16 @@ func (a *Agent) ListReleases(
 	ctx, span := telemetry.NewSpan(ctx, "helm-list-releases")
 	defer span.End()
 
+	stringFilter := strings.Join(filter.StatusFilter, ",")
 	telemetry.WithAttributes(span,
 		telemetry.AttributeKV{Key: "namespace", Value: namespace},
+		telemetry.AttributeKV{Key: "filter", Value: stringFilter},
 	)
 
-	lsel := fmt.Sprintf("owner=helm,status in (%s)", strings.Join(filter.StatusFilter, ","))
+	lsel := fmt.Sprintf("owner=helm,status in (%s)", stringFilter)
 
-	// list secrets
 	secretList, err := a.K8sAgent.Clientset.CoreV1().Secrets(namespace).List(
-		context.Background(),
+		ctx,
 		v1.ListOptions{
 			LabelSelector: lsel,
 		},
@@ -365,7 +366,6 @@ func (a *Agent) UpgradeReleaseByValues(
 		doAuth,
 		disablePullSecretsInjection,
 	)
-
 	if err != nil {
 		return nil, telemetry.Error(ctx, span, err, "error getting porter postrenderer")
 	}
@@ -411,14 +411,12 @@ func (a *Agent) UpgradeReleaseByValues(
 					rel.Info.Status = release.StatusFailed
 
 					err = helmSecrets.Update(mostRecentSecret.GetName(), rel)
-
 					if err != nil {
 						return nil, telemetry.Error(ctx, span, err, "error updating helm secrets")
 					}
 
 					// retry upgrade
 					res, err = cmd.Run(conf.Name, ch, conf.Values)
-
 					if err != nil {
 						return nil, telemetry.Error(ctx, span, err, "error running upgrade after updating helm secrets")
 					}
@@ -485,7 +483,6 @@ func (a *Agent) UpgradeReleaseByValues(
 				helmSecrets := driver.NewSecrets(a.K8sAgent.Clientset.CoreV1().Secrets(rel.Namespace))
 
 				err = helmSecrets.Update(mostRecentSecret.GetName(), rel)
-
 				if err != nil {
 					return nil, telemetry.Error(ctx, span, err, "error updating helm secret")
 				}
@@ -592,7 +589,6 @@ func (a *Agent) InstallChart(
 		doAuth,
 		disablePullSecretsInjection,
 	)
-
 	if err != nil {
 		return nil, telemetry.Error(ctx, span, err, "error getting post renderer")
 	}
@@ -658,7 +654,6 @@ func (a *Agent) UpgradeInstallChart(
 		doAuth,
 		disablePullSecretsInjection,
 	)
-
 	if err != nil {
 		return nil, telemetry.Error(ctx, span, err, "error getting post renderer")
 	}

+ 25 - 0
internal/kubernetes/agent.go

@@ -587,6 +587,31 @@ func (a *Agent) GetSecret(name string, namespace string) (*v1.Secret, error) {
 	)
 }
 
+// CreateSecret creates the secret given its name and namespace
+func (a *Agent) CreateSecret(secret *v1.Secret, namespace string) (*v1.Secret, error) {
+	_, err := a.Clientset.CoreV1().Secrets(namespace).Get(
+		context.TODO(),
+		secret.Name,
+		metav1.GetOptions{},
+	)
+	if err != nil {
+		if !errors.IsNotFound(err) {
+			return nil, err
+		}
+		return a.Clientset.CoreV1().Secrets(namespace).Create(
+			context.TODO(),
+			secret,
+			metav1.CreateOptions{},
+		)
+	}
+
+	return a.Clientset.CoreV1().Secrets(namespace).Update(
+		context.TODO(),
+		secret,
+		metav1.UpdateOptions{},
+	)
+}
+
 // ListConfigMaps simply lists namespaces
 func (a *Agent) ListConfigMaps(namespace string) (*v1.ConfigMapList, error) {
 	return a.Clientset.CoreV1().ConfigMaps(namespace).List(

+ 3 - 0
internal/models/datastore.go

@@ -46,4 +46,7 @@ type Datastore struct {
 
 	// Status describes the status of a datastore
 	Status DatastoreStatus `json:"status"`
+
+	// OnManagementCluster is a flag that indicates whether the datastore is hosted on the management cluster or on the customer's cluster
+	OnManagementCluster bool `json:"on_management_cluster" gorm:"not null;default:false"`
 }

+ 10 - 0
internal/porter_app/revisions.go

@@ -41,6 +41,16 @@ type Revision struct {
 	AppInstanceID uuid.UUID `json:"app_instance_id"`
 }
 
+// AppInstance represents the data for an app instance
+type AppInstance struct {
+	// Id is the app instance id
+	Id string `json:"id"`
+	// DeploymentTargetID is the id of the deployment target the revision is associated with
+	DeploymentTarget DeploymentTarget `json:"deployment_target"`
+	// Name is the name of the app instance
+	Name string `json:"name"`
+}
+
 // RevisionProgress describes the progress of a revision in its lifecycle
 type RevisionProgress struct {
 	// PredeployStarted is true if the predeploy process has started

+ 82 - 0
internal/porter_app/test/patch_test.go

@@ -0,0 +1,82 @@
+package test
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"testing"
+
+	"github.com/matryer/is"
+	"github.com/porter-dev/api-contracts/generated/go/helpers"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	v2 "github.com/porter-dev/porter/internal/porter_app/v2"
+)
+
+func TestPatchApp(t *testing.T) {
+	tests := []struct {
+		haveFileName string
+		wantFileName string
+	}{
+		{"app_proto_prepatch", "app_proto_postpatch"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.haveFileName, func(t *testing.T) {
+			is := is.New(t)
+
+			wantBytes, err := os.ReadFile(fmt.Sprintf("../testdata/%s.json", tt.wantFileName))
+			is.NoErr(err) // no error expected reading test file
+
+			want := &porterv1.PorterApp{}
+			err = helpers.UnmarshalContractObject(wantBytes, want)
+			is.NoErr(err) // no error expected unmarshalling test file
+
+			inputBytes, err := os.ReadFile(fmt.Sprintf("../testdata/%s.json", tt.haveFileName))
+			is.NoErr(err) // no error expected reading test file
+
+			input := &porterv1.PorterApp{}
+			err = helpers.UnmarshalContractObject(inputBytes, input)
+			is.NoErr(err) // no error expected unmarshalling test file
+
+			flags := []v2.ApplyFlag{
+				v2.AttachBuildpacks{
+					Buildpacks: []string{"heroku/python"},
+				},
+				v2.SetBuildContext{
+					Context: "./app",
+				},
+				v2.SetBuildMethod{
+					Method: "docker",
+				},
+				v2.SetBuildDockerfile{
+					Dockerfile: "Dockerfile",
+				},
+				v2.SetBuilder{
+					Builder: "heroku/buildpacks:20",
+				},
+				v2.AttachEnvGroupsFlag{
+					EnvGroups: []string{"foo-group"},
+				},
+				v2.SetImageRepo{
+					Repo: "ghcr.io/porter-dev",
+				},
+				v2.SetImageTag{
+					Tag: "a-new-tag",
+				},
+				v2.SetName{
+					Name: "js-test-app1",
+				},
+			}
+
+			var opts []v2.PatchOperation
+			for _, flag := range flags {
+				opts = append(opts, flag.AsPatchOperations()...)
+			}
+
+			got, err := v2.PatchApp(context.Background(), input, opts)
+			is.NoErr(err) // no error expected patching app
+
+			diffProtoWithFailTest(t, is, want, got)
+		})
+	}
+}

+ 63 - 0
internal/porter_app/testdata/app_proto_postpatch.json

@@ -0,0 +1,63 @@
+{
+  "name": "js-test-app1",
+  "build": {
+    "context": "./app",
+    "method": "docker",
+    "dockerfile": "Dockerfile",
+    "builder": "heroku/buildpacks:20",
+    "buildpacks": [
+      "heroku/nodejs",
+      "heroku/python"
+    ]
+  },
+  "image": {
+    "repository": "ghcr.io/porter-dev",
+    "tag": "a-new-tag"
+  },
+  "envGroups": [
+    {
+      "name": "sponsor",
+      "version": "9"
+    },
+    {
+      "name": "2-230178",
+      "version": "5"
+    },
+    {
+      "name": "foo-group"
+    }
+  ],
+  "helmOverrides": {},
+  "serviceList": [
+    {
+      "run": "/cnb/lifecycle/launcher node index.js",
+      "instances": 1,
+      "port": 3000,
+      "cpuCores": 0.19,
+      "ramMegabytes": 400,
+      "webConfig": {
+        "autoscaling": {
+          "minInstances": 1,
+          "maxInstances": 10,
+          "cpuThresholdPercent": 50,
+          "memoryThresholdPercent": 50
+        },
+        "domains": [],
+        "healthCheck": {
+          "httpPath": "/healthz",
+          "timeoutSeconds": 1,
+          "initialDelaySeconds": 15
+        },
+        "private": false,
+        "disableTls": false
+      },
+      "type": "SERVICE_TYPE_WEB",
+      "smartOptimization": false,
+      "runOptional": "/cnb/lifecycle/launcher node index.js",
+      "name": "web",
+      "instancesOptional": 1,
+      "gpu": {},
+      "terminationGracePeriodSeconds": 30
+    }
+  ]
+}

+ 58 - 0
internal/porter_app/testdata/app_proto_prepatch.json

@@ -0,0 +1,58 @@
+{
+  "name": "js-test-app",
+  "build": {
+    "context": "./",
+    "method": "pack",
+    "builder": "heroku/buildpacks:18",
+    "buildpacks": [
+      "heroku/nodejs"
+    ]
+  },
+  "image": {
+    "repository": "1234567890.dkr.ecr.us-east-1.amazonaws.com/js-test-app",
+    "tag": "0cfc7dba6ab859d33309c8717a63f4eeac95669a"
+  },
+  "envGroups": [
+    {
+      "name": "sponsor",
+      "version": "9"
+    },
+    {
+      "name": "2-230178",
+      "version": "5"
+    }
+  ],
+  "helmOverrides": {},
+  "serviceList": [
+    {
+      "run": "/cnb/lifecycle/launcher node index.js",
+      "instances": 1,
+      "port": 3000,
+      "cpuCores": 0.19,
+      "ramMegabytes": 400,
+      "webConfig": {
+        "autoscaling": {
+          "minInstances": 1,
+          "maxInstances": 10,
+          "cpuThresholdPercent": 50,
+          "memoryThresholdPercent": 50
+        },
+        "domains": [],
+        "healthCheck": {
+          "httpPath": "/healthz",
+          "timeoutSeconds": 1,
+          "initialDelaySeconds": 15
+        },
+        "private": false,
+        "disableTls": false
+      },
+      "type": "SERVICE_TYPE_WEB",
+      "smartOptimization": false,
+      "runOptional": "/cnb/lifecycle/launcher node index.js",
+      "name": "web",
+      "instancesOptional": 1,
+      "gpu": {},
+      "terminationGracePeriodSeconds": 30
+    }
+  ]
+}

+ 214 - 0
internal/porter_app/v2/apply_flags.go

@@ -0,0 +1,214 @@
+package v2
+
+import (
+	"encoding/json"
+)
+
+// OperationType represents a JSON patch operation type
+type OperationType string
+
+const (
+	// AddOperation indicates that a value should be added to a JSON object or array
+	AddOperation OperationType = "add"
+	// RemoveOperation indicates that a value should be removed from a JSON object or array
+	RemoveOperation OperationType = "remove"
+	// ReplaceOperation indicates that a value should be replaced in a JSON object or array
+	ReplaceOperation OperationType = "replace"
+	// MoveOperation indicates that a value should be moved within a JSON object or array
+	MoveOperation OperationType = "move"
+	// CopyOperation indicates that a value should be copied within a JSON object or array
+	CopyOperation OperationType = "copy"
+)
+
+// PatchOperation represents a full JSON patch operation
+type PatchOperation struct {
+	// Operation is the type of operation to perform
+	Operation OperationType `json:"op"`
+	// Path is the JSON pointer to the value to be operated on
+	Path string `json:"path"`
+	// Value is the value to be added, replaced, or moved
+	Value interface{} `json:"value,omitempty"`
+}
+
+func (op PatchOperation) String() (string, error) {
+	var res string
+
+	by, err := json.Marshal(op)
+	if err != nil {
+		return res, err
+	}
+
+	return string(by), nil
+}
+
+// ApplyFlag is an interface that represents a flag that can be applied to a PorterApp
+// removal operations are handled separately
+type ApplyFlag interface {
+	AsPatchOperations() []PatchOperation
+}
+
+// SetName is a flag that can be applied to a PorterApp to set the name
+type SetName struct {
+	Name string
+}
+
+// AsPatchOperations returns the patch operations to set the name
+func (f SetName) AsPatchOperations() []PatchOperation {
+	return []PatchOperation{
+		{
+			Operation: ReplaceOperation,
+			Path:      "/name",
+			Value:     f.Name,
+		},
+	}
+}
+
+// AttachEnvGroupsFlag is a flag that can be applied to a PorterApp to attach environment groups
+type AttachEnvGroupsFlag struct {
+	EnvGroups []string
+}
+
+// envGroupWithoutVersion is a struct that represents an environment group without a version
+// the version will be auto added on hydrate
+type envGroupWithoutVersion struct {
+	Name string `json:"name"`
+}
+
+// AsPatchOperations returns the patch operations to attach the environment groups
+func (f AttachEnvGroupsFlag) AsPatchOperations() []PatchOperation {
+	var envGroups []envGroupWithoutVersion
+
+	for _, envGroup := range f.EnvGroups {
+		envGroups = append(envGroups, envGroupWithoutVersion{
+			Name: envGroup,
+		})
+	}
+
+	var ops []PatchOperation
+
+	for _, envGroup := range envGroups {
+		ops = append(ops, PatchOperation{
+			Operation: AddOperation,
+			Path:      "/envGroups/-",
+			Value:     envGroup,
+		})
+	}
+
+	return ops
+}
+
+// SetBuildContext is a flag that can be applied to a PorterApp to set the build context
+type SetBuildContext struct {
+	Context string
+}
+
+// AsPatchOperations returns the patch operations to set the build context
+func (f SetBuildContext) AsPatchOperations() []PatchOperation {
+	return []PatchOperation{
+		{
+			Operation: ReplaceOperation,
+			Path:      "/build/context",
+			Value:     f.Context,
+		},
+	}
+}
+
+// SetBuildMethod is a flag that can be applied to a PorterApp to set the build method
+type SetBuildMethod struct {
+	Method string
+}
+
+// AsPatchOperations returns the patch operations to set the build method
+func (f SetBuildMethod) AsPatchOperations() []PatchOperation {
+	return []PatchOperation{
+		{
+			Operation: ReplaceOperation,
+			Path:      "/build/method",
+			Value:     f.Method,
+		},
+	}
+}
+
+// SetBuildDockerfile is a flag that can be applied to a PorterApp to set the build Dockerfile
+type SetBuildDockerfile struct {
+	Dockerfile string
+}
+
+// AsPatchOperations returns the patch operations to set the build Dockerfile
+func (f SetBuildDockerfile) AsPatchOperations() []PatchOperation {
+	return []PatchOperation{
+		{
+			Operation: ReplaceOperation,
+			Path:      "/build/dockerfile",
+			Value:     f.Dockerfile,
+		},
+	}
+}
+
+// AttachBuildpacks is a flag that can be applied to a PorterApp to attach buildpacks
+type AttachBuildpacks struct {
+	Buildpacks []string
+}
+
+// AsPatchOperations returns the patch operations to attach the buildpacks
+func (f AttachBuildpacks) AsPatchOperations() []PatchOperation {
+	var ops []PatchOperation
+
+	for _, buildpack := range f.Buildpacks {
+		ops = append(ops, PatchOperation{
+			Operation: AddOperation,
+			Path:      "/build/buildpacks/-",
+			Value:     buildpack,
+		})
+	}
+
+	return ops
+}
+
+// SetBuilder is a flag that can be applied to a PorterApp to set the builder
+type SetBuilder struct {
+	Builder string
+}
+
+// AsPatchOperations returns the patch operations to set the builder
+func (f SetBuilder) AsPatchOperations() []PatchOperation {
+	return []PatchOperation{
+		{
+			Operation: ReplaceOperation,
+			Path:      "/build/builder",
+			Value:     f.Builder,
+		},
+	}
+}
+
+// SetImageRepo is a flag that can be applied to a PorterApp to set the image repo
+type SetImageRepo struct {
+	Repo string
+}
+
+// AsPatchOperations returns the patch operations to set the image repo
+func (f SetImageRepo) AsPatchOperations() []PatchOperation {
+	return []PatchOperation{
+		{
+			Operation: ReplaceOperation,
+			Path:      "/image/repository",
+			Value:     f.Repo,
+		},
+	}
+}
+
+// SetImageTag is a flag that can be applied to a PorterApp to set the image tag
+type SetImageTag struct {
+	Tag string
+}
+
+// AsPatchOperations returns the patch operations to set the image tag
+func (f SetImageTag) AsPatchOperations() []PatchOperation {
+	return []PatchOperation{
+		{
+			Operation: ReplaceOperation,
+			Path:      "/image/tag",
+			Value:     f.Tag,
+		},
+	}
+}

+ 62 - 0
internal/porter_app/v2/patch.go

@@ -0,0 +1,62 @@
+package v2
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	jsonpatch "github.com/evanphx/json-patch"
+	"github.com/porter-dev/api-contracts/generated/go/helpers"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// PatchApp applies a set of JSON patch operations to an app proto and returns the modified proto
+func PatchApp(ctx context.Context, app *porterv1.PorterApp, ops []PatchOperation) (*porterv1.PorterApp, error) {
+	ctx, span := telemetry.NewSpan(ctx, "v2-patch-app")
+	defer span.End()
+
+	var patchedApp *porterv1.PorterApp
+
+	if app == nil {
+		return patchedApp, telemetry.Error(ctx, span, nil, "no app provided")
+	}
+
+	by, err := helpers.MarshalContractObject(ctx, app)
+	if err != nil {
+		return patchedApp, telemetry.Error(ctx, span, err, "failed to marshal app")
+	}
+
+	var opStrs []string
+
+	for _, op := range ops {
+		opAsJSON, err := op.String()
+		if err != nil {
+			return patchedApp, telemetry.Error(ctx, span, err, "failed to convert patch operation to string")
+		}
+
+		opStrs = append(opStrs, fmt.Sprintf("\t%s", opAsJSON))
+	}
+
+	patchJson := fmt.Sprintf("[\n%s\n]", strings.Join(opStrs, ",\n"))
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "patch-json", Value: patchJson})
+
+	patch, err := jsonpatch.DecodePatch([]byte(patchJson))
+	if err != nil {
+		return patchedApp, telemetry.Error(ctx, span, err, "failed to decode patch")
+	}
+
+	modified, err := patch.Apply(by)
+	if err != nil {
+		return patchedApp, telemetry.Error(ctx, span, err, "failed to apply patch")
+	}
+
+	patchedApp = &porterv1.PorterApp{}
+
+	err = helpers.UnmarshalContractObject(modified, patchedApp)
+	if err != nil {
+		return patchedApp, telemetry.Error(ctx, span, err, "failed to unmarshal patched app")
+	}
+
+	return patchedApp, nil
+}