Explorar el Código

Merge branch 'master' of github.com:porter-dev/porter into debug-tab

Feroze Mohideen hace 2 años
padre
commit
6eb596fd2c

+ 106 - 0
api/server/handlers/addons/list.go

@@ -0,0 +1,106 @@
+package addons
+
+import (
+	"encoding/base64"
+	"net/http"
+
+	"connectrpc.com/connect"
+	"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/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"
+)
+
+// LatestAddonsHandler handles requests to the /addons/latest endpoint
+type LatestAddonsHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewLatestAddonsHandler returns a new LatestAddonsHandler
+func NewLatestAddonsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *LatestAddonsHandler {
+	return &LatestAddonsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// LatestAddonsRequest represents the request for the /addons/latest endpoint
+type LatestAddonsRequest struct {
+	DeploymentTargetID string `schema:"deployment_target_id"`
+}
+
+// LatestAddonsResponse represents the response from the /addons/latest endpoint
+type LatestAddonsResponse struct {
+	Base64Addons []string `json:"base64_addons"`
+}
+
+func (c *LatestAddonsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-addons")
+	defer span.End()
+
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &LatestAddonsRequest{}
+	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: "deployment-target-id", Value: request.DeploymentTargetID},
+	)
+
+	var deploymentTargetIdentifier *porterv1.DeploymentTargetIdentifier
+	if request.DeploymentTargetID != "" {
+		deploymentTargetIdentifier = &porterv1.DeploymentTargetIdentifier{
+			Id: request.DeploymentTargetID,
+		}
+	}
+
+	latestAddonsReq := connect.NewRequest(&porterv1.LatestAddonsRequest{
+		ProjectId:                  int64(project.ID),
+		ClusterId:                  int64(cluster.ID),
+		DeploymentTargetIdentifier: deploymentTargetIdentifier,
+	})
+
+	latestAddonsResp, err := c.Config().ClusterControlPlaneClient.LatestAddons(ctx, latestAddonsReq)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting latest addons")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if latestAddonsResp == nil || latestAddonsResp.Msg == nil {
+		err = telemetry.Error(ctx, span, nil, "latest addons response is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	res := &LatestAddonsResponse{
+		Base64Addons: []string{},
+	}
+
+	for _, addon := range latestAddonsResp.Msg.Addons {
+		by, err := helpers.MarshalContractObject(ctx, addon)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error marshaling addon")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		encoded := base64.StdEncoding.EncodeToString(by)
+		res.Base64Addons = append(res.Base64Addons, encoded)
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 94 - 0
api/server/handlers/cloud_provider/list_aws.go

@@ -0,0 +1,94 @@
+package cloud_provider
+
+import (
+	"net/http"
+
+	"github.com/aws/aws-sdk-go/aws/arn"
+	"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"
+)
+
+// ListAwsAccountsResponse describes an outbound response for listing aws accounts on
+// a given project.
+type ListAwsAccountsResponse struct {
+	// Accounts is a list of aws account objects
+	Accounts []AwsAccount `json:"accounts"`
+}
+
+// AwsAccount describes an outbound response for listing aws accounts on
+// a given project.
+//
+// The shape of the object is "generic" as there will be similar endpoints in
+// the future for other cloud providers.
+type AwsAccount struct {
+	// CloudProviderID is the cloud provider id - for AWS, this is an account
+	CloudProviderID string `json:"cloud_provider_id"`
+
+	// ProjectID is the project the account is associated with
+	ProjectID uint `json:"project_id"`
+}
+
+// ListAwsAccountsHandler is a struct for handling an aws cloud provider list request
+type ListAwsAccountsHandler struct {
+	handlers.PorterHandlerWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewListAwsAccountsHandler constructs a ListAwsAccountsHandler
+func NewListAwsAccountsHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ListAwsAccountsHandler {
+	return &ListAwsAccountsHandler{
+		PorterHandlerWriter:   handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// ServeHTTP returns a list of AWS Accounts
+//
+// todo: Move this logic down into CCP
+func (c *ListAwsAccountsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-cloud-provider-list-aws")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	res := ListAwsAccountsResponse{
+		Accounts: []AwsAccount{},
+	}
+	if !project.GetFeatureFlag(models.CapiProvisionerEnabled, c.Config().LaunchDarklyClient) {
+		err := telemetry.Error(ctx, span, nil, "listing cloud providers not available on non-capi clusters")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	dblinks, err := c.Repo().AWSAssumeRoleChainer().List(ctx, project.ID)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "unable to find assume role chain links")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	for _, link := range dblinks {
+		targetArn, err := arn.Parse(link.TargetARN)
+		if err != nil {
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "err-target-arn", Value: link.TargetARN})
+			err := telemetry.Error(ctx, span, err, "unable to parse target arn")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+
+		res.Accounts = append(res.Accounts, AwsAccount{
+			CloudProviderID: targetArn.AccountID,
+			ProjectID:       uint(link.ProjectID),
+		})
+	}
+	c.WriteResult(w, r, res)
+}

+ 175 - 0
api/server/handlers/datastore/list.go

@@ -0,0 +1,175 @@
+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/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// ListDatastoresRequest is a struct that represents the various filter options used for
+// retrieving the datastores
+type ListDatastoresRequest struct {
+	// Name is the name of the datastore to filter by
+	Name string `schema:"name"`
+
+	// Type is the type of the datastore to filter by
+	Type string `schema:"type"`
+
+	// IncludeEnvGroup controls whether to include the datastore env group or not
+	IncludeEnvGroup bool `schema:"include_env_group"`
+
+	// IncludeMetadata controls whether to include datastore metadata or not
+	IncludeMetadata bool `schema:"include_metadata"`
+}
+
+// ListDatastoresResponse describes the list datastores response body
+type ListDatastoresResponse struct {
+	// Datastores is a list of datastore entries for the http response
+	Datastores []DatastoresResponseEntry `json:"datastores"`
+}
+
+// DatastoresResponseEntry describes an outbound datastores response entry
+type DatastoresResponseEntry struct {
+	// Name is the name of the datastore
+	Name string `json:"name"`
+
+	// Type is the type of the datastore
+	Type string `json:"type"`
+
+	// Env is the env group for the datastore
+	Env *porterv1.EnvGroup `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,omitempty"`
+}
+
+// ListDatastoresHandler is a struct for handling datastore status requests
+type ListDatastoresHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewListDatastoresHandler constructs a datastore ListHandler
+func NewListDatastoresHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListDatastoresHandler {
+	return &ListDatastoresHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// ServeHTTP returns a list of datastores associated with the specified project/cloud-provider
+func (h *ListDatastoresHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-datastore-list")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	request := &ListDatastoresRequest{}
+	if ok := h.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	cloudProviderType, err := requestutils.GetURLParamString(r, types.URLParamCloudProviderType)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error parsing cloud provider type")
+		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "cloud-provider-type", Value: cloudProviderType})
+
+	cloudProviderID, err := requestutils.GetURLParamString(r, types.URLParamCloudProviderID)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error parsing cloud provider id")
+		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "cloud-provider-id", Value: cloudProviderID})
+
+	datastoreType := porterv1.EnumDatastore_ENUM_DATASTORE_UNSPECIFIED
+	switch request.Type {
+	case "elasticache-redis":
+		datastoreType = porterv1.EnumDatastore_ENUM_DATASTORE_ELASTICACHE_REDIS
+	case "rds-postgresql":
+		datastoreType = porterv1.EnumDatastore_ENUM_DATASTORE_RDS_POSTGRESQL
+	case "rds-postgresql-aurora":
+		datastoreType = porterv1.EnumDatastore_ENUM_DATASTORE_RDS_AURORA_POSTGRESQL
+	case "":
+		datastoreType = porterv1.EnumDatastore_ENUM_DATASTORE_UNSPECIFIED
+	default:
+		err := telemetry.Error(ctx, span, err, "invalid datastore type specified")
+		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},
+		telemetry.AttributeKV{Key: "include-env-group", Value: request.IncludeEnvGroup},
+		telemetry.AttributeKV{Key: "include-metadata", Value: request.IncludeMetadata},
+	)
+
+	var cloudProvider porterv1.EnumCloudProvider
+	switch cloudProviderType {
+	case "aws":
+		cloudProvider = porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_AWS
+	default:
+		err := telemetry.Error(ctx, span, nil, "unsupported cloud provider")
+		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	message := porterv1.ListDatastoresRequest{
+		ProjectId:              int64(project.ID),
+		CloudProvider:          cloudProvider,
+		CloudProviderAccountId: cloudProviderID,
+		Name:                   request.Name,
+		IncludeEnvGroup:        request.IncludeEnvGroup,
+		IncludeMetadata:        request.IncludeMetadata,
+	}
+	if datastoreType != porterv1.EnumDatastore_ENUM_DATASTORE_UNSPECIFIED {
+		message.Type = &datastoreType
+	}
+	req := connect.NewRequest(&message)
+	resp, ccpErr := h.Config().ClusterControlPlaneClient.ListDatastores(ctx, req)
+	if ccpErr != nil {
+		err := telemetry.Error(ctx, span, ccpErr, "error listing datastores 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
+	}
+
+	response := ListDatastoresResponse{
+		Datastores: []DatastoresResponseEntry{},
+	}
+	for _, datastore := range resp.Msg.Datastores {
+		response.Datastores = append(response.Datastores, DatastoresResponseEntry{
+			Name:     datastore.Name,
+			Type:     datastore.Type.Enum().String(),
+			Metadata: datastore.Metadata,
+			Env:      datastore.Env,
+		})
+	}
+
+	h.WriteResult(w, r, response)
+}

+ 20 - 3
api/server/handlers/environment_groups/list.go

@@ -5,6 +5,9 @@ import (
 	"strings"
 	"time"
 
+	"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"
@@ -134,15 +137,29 @@ func (c *ListEnvironmentGroupsHandler) ServeHTTP(w http.ResponseWriter, r *http.
 				linkedApplications = append(linkedApplications, porterAppName)
 			}
 		} else {
-			applications, err := environmentgroups.LinkedApplications(ctx, agent, latestVersion.Name, false)
+			appsLinkedToEnvGroupReq := connect.NewRequest(&porterv1.AppsLinkedToEnvGroupRequest{
+				ProjectId:     int64(project.ID),
+				ClusterId:     int64(cluster.ID),
+				EnvGroupName:  envGroupName,
+				IgnorePreview: true,
+			})
+
+			appsLinkedToEnvGroupResp, err := c.Config().ClusterControlPlaneClient.AppsLinkedToEnvGroup(ctx, appsLinkedToEnvGroupReq)
 			if err != nil {
 				err = telemetry.Error(ctx, span, err, "unable to get linked applications")
 				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 				return
 			}
+			if appsLinkedToEnvGroupResp == nil || appsLinkedToEnvGroupResp.Msg == nil {
+				err = telemetry.Error(ctx, span, err, "ccp resp is nil")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+				return
+			}
 
-			for _, app := range applications {
-				linkedApplications = append(linkedApplications, app.Name)
+			for _, app := range appsLinkedToEnvGroupResp.Msg.LinkedApps {
+				if app != nil {
+					linkedApplications = append(linkedApplications, app.Name)
+				}
 			}
 		}
 

+ 90 - 0
api/server/router/addons.go

@@ -0,0 +1,90 @@
+package router
+
+import (
+	"fmt"
+
+	"github.com/go-chi/chi/v5"
+	"github.com/porter-dev/porter/api/server/handlers/addons"
+	"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"
+	"github.com/porter-dev/porter/api/types"
+)
+
+// NewAddonScopedRegisterer returns the router for addon-scoped requests
+func NewAddonScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
+		GetRoutes: GetAddonScopedRoutes,
+		Children:  children,
+	}
+}
+
+// GetAddonScopedRoutes returns the router for addon-scoped requests
+func GetAddonScopedRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+	children ...*router.Registerer,
+) []*router.Route {
+	routes, projPath := getAddonRoutes(r, config, basePath, factory)
+
+	if len(children) > 0 {
+		r.Route(projPath.RelativePath, func(r chi.Router) {
+			for _, child := range children {
+				childRoutes := child.GetRoutes(r, config, basePath, factory, child.Children...)
+
+				routes = append(routes, childRoutes...)
+			}
+		})
+	}
+
+	return routes
+}
+
+func getAddonRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+) ([]*router.Route, *types.Path) {
+	relPath := "/addons"
+
+	newPath := &types.Path{
+		Parent:       basePath,
+		RelativePath: relPath,
+	}
+
+	var routes []*router.Route
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/addons/latest -> addons.LatestAddonsHandler
+	latestAddonsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/latest", relPath),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	latestAddonsHandler := addons.NewLatestAddonsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: latestAddonsEndpoint,
+		Handler:  latestAddonsHandler,
+		Router:   r,
+	})
+
+	return routes, newPath
+}

+ 88 - 0
api/server/router/cloud_provider.go

@@ -0,0 +1,88 @@
+package router
+
+import (
+	"fmt"
+
+	"github.com/go-chi/chi/v5"
+	"github.com/porter-dev/porter/api/server/handlers/cloud_provider"
+	"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"
+	"github.com/porter-dev/porter/api/types"
+)
+
+// NewCloudProviderScopedRegisterer returns a scoped route registerer for CloudProvider routes
+func NewCloudProviderScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
+		GetRoutes: GetCloudProviderScopedRoutes,
+		Children:  children,
+	}
+}
+
+// GetCloudProviderScopedRoutes returns scoped CloudProvider routes with mounted child registerers
+func GetCloudProviderScopedRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+	children ...*router.Registerer,
+) []*router.Route {
+	routes, projPath := getCloudProviderRoutes(r, config, basePath, factory)
+
+	if len(children) > 0 {
+		r.Route(projPath.RelativePath, func(r chi.Router) {
+			for _, child := range children {
+				childRoutes := child.GetRoutes(r, config, basePath, factory, child.Children...)
+
+				routes = append(routes, childRoutes...)
+			}
+		})
+	}
+
+	return routes
+}
+
+// getCloudProviderRoutes returns CloudProvider routes
+func getCloudProviderRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+) ([]*router.Route, *types.Path) {
+	relPath := "/cloud-providers"
+
+	newPath := &types.Path{
+		Parent:       basePath,
+		RelativePath: relPath,
+	}
+	routes := make([]*router.Route, 0)
+
+	// GET /api/projects/{project_id}/cloud-providers/aws -> cloud_provider.NewListAwsHandler
+	listAwsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/aws", relPath),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listAwsHandler := cloud_provider.NewListAwsAccountsHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listAwsEndpoint,
+		Handler:  listAwsHandler,
+		Router:   r,
+	})
+
+	return routes, newPath
+}

+ 118 - 0
api/server/router/datastore.go

@@ -0,0 +1,118 @@
+package router
+
+import (
+	"fmt"
+
+	"github.com/go-chi/chi/v5"
+	"github.com/porter-dev/porter/api/server/handlers/datastore"
+	"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"
+	"github.com/porter-dev/porter/api/types"
+)
+
+// NewDatastoreScopedRegisterer returns a scoped route registerer for Datastore routes
+func NewDatastoreScopedRegisterer(children ...*router.Registerer) *router.Registerer {
+	return &router.Registerer{
+		GetRoutes: GetDatastoreScopedRoutes,
+		Children:  children,
+	}
+}
+
+// GetDatastoreScopedRoutes returns scoped Datastore routes with mounted child registerers
+func GetDatastoreScopedRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+	children ...*router.Registerer,
+) []*router.Route {
+	routes, projPath := getDatastoreRoutes(r, config, basePath, factory)
+
+	if len(children) > 0 {
+		r.Route(projPath.RelativePath, func(r chi.Router) {
+			for _, child := range children {
+				childRoutes := child.GetRoutes(r, config, basePath, factory, child.Children...)
+
+				routes = append(routes, childRoutes...)
+			}
+		})
+	}
+
+	return routes
+}
+
+// getDatastoreRoutes returns Datastore routes
+func getDatastoreRoutes(
+	r chi.Router,
+	config *config.Config,
+	basePath *types.Path,
+	factory shared.APIEndpointFactory,
+) ([]*router.Route, *types.Path) {
+	// empty path as this is mounted onto the datastore endpoints
+	relPath := ""
+
+	newPath := &types.Path{
+		Parent:       basePath,
+		RelativePath: relPath,
+	}
+	routes := make([]*router.Route, 0)
+
+	// GET /api/projects/{project_id}/cloud-providers/{cloud_provider_type}/{cloud_provider_id}/datastores -> cloud_provider.NewListHandler
+	listEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/{%s}/datastores", relPath, types.URLParamCloudProviderType, types.URLParamCloudProviderID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listHandler := datastore.NewListDatastoresHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listEndpoint,
+		Handler:  listHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/cloud-providers/{cloud_provider_type}/{cloud_provider_id}/datastores/{datastore_type}/{datastore_name} -> cloud_provider.NewListHandler
+	getEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/{%s}/datastores/{%s}/{%s}", relPath, types.URLParamCloudProviderType, types.URLParamCloudProviderID, types.URLParamDatastoreType, types.URLParamDatastoreName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	getHandler := datastore.NewListDatastoresHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getEndpoint,
+		Handler:  getHandler,
+		Router:   r,
+	})
+
+	return routes, newPath
+}

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

@@ -30,10 +30,13 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 
 	releaseRegisterer := NewReleaseScopedRegisterer()
 	namespaceRegisterer := NewNamespaceScopedRegisterer(releaseRegisterer)
+	datastoreRegisterer := NewDatastoreScopedRegisterer()
+	cloudProviderRegisterer := NewCloudProviderScopedRegisterer(datastoreRegisterer)
 	clusterIntegrationRegisterer := NewClusterIntegrationScopedRegisterer()
 	stackRegisterer := NewPorterAppScopedRegisterer()
+	addonRegisterer := NewAddonScopedRegisterer()
 	deploymentTargetRegisterer := NewDeploymentTargetScopedRegisterer()
-	clusterRegisterer := NewClusterScopedRegisterer(namespaceRegisterer, clusterIntegrationRegisterer, stackRegisterer, deploymentTargetRegisterer)
+	clusterRegisterer := NewClusterScopedRegisterer(namespaceRegisterer, clusterIntegrationRegisterer, stackRegisterer, deploymentTargetRegisterer, addonRegisterer)
 	infraRegisterer := NewInfraScopedRegisterer()
 	gitInstallationRegisterer := NewGitInstallationScopedRegisterer()
 	registryRegisterer := NewRegistryScopedRegisterer()
@@ -43,6 +46,7 @@ func NewAPIRouter(config *config.Config) *chi.Mux {
 	projectOAuthRegisterer := NewProjectOAuthScopedRegisterer()
 	slackIntegrationRegisterer := NewSlackIntegrationScopedRegisterer()
 	projRegisterer := NewProjectScopedRegisterer(
+		cloudProviderRegisterer,
 		clusterRegisterer,
 		registryRegisterer,
 		helmRepoRegisterer,

+ 4 - 0
api/types/request.go

@@ -53,6 +53,10 @@ const (
 	URLParamPorterAppName         URLParam = "porter_app_name"
 	URLParamPorterAppEventID      URLParam = "porter_app_event_id"
 	URLParamAppRevisionID         URLParam = "app_revision_id"
+	URLParamDatastoreType         URLParam = "datastore_type"
+	URLParamDatastoreName         URLParam = "datastore_name"
+	URLParamCloudProviderType     URLParam = "cloud_provider_type"
+	URLParamCloudProviderID       URLParam = "cloud_provider_id"
 	URLParamDeploymentTargetID    URLParam = "deployment_target_id"
 	URLParamWebhookID             URLParam = "webhook_id"
 )

+ 201 - 190
dashboard/src/components/AzureProvisionerSettings.tsx

@@ -1,15 +1,4 @@
-import React, {useContext, useEffect, useState} from "react";
-import styled from "styled-components";
-import {type RouteComponentProps, withRouter} from "react-router";
-
-import {OFState} from "main/home/onboarding/state";
-import api from "shared/api";
-import {Context} from "shared/Context";
-import {pushFiltered} from "shared/routing";
-
-import SelectRow from "components/form-components/SelectRow";
-import Heading from "components/form-components/Heading";
-import InputRow from "./form-components/InputRow";
+import React, { useContext, useEffect, useState } from "react";
 import {
   AKS,
   AKSNodePool,
@@ -18,53 +7,46 @@ import {
   Contract,
   EnumCloudProvider,
   EnumKubernetesKind,
-  NodePoolType
+  NodePoolType,
 } from "@porter-dev/api-contracts";
-import {type ClusterType} from "shared/types";
+import { Label } from "@tanstack/react-query-devtools/build/lib/Explorer";
+import { withRouter, type RouteComponentProps } from "react-router";
+import styled from "styled-components";
+
+import Heading from "components/form-components/Heading";
+import SelectRow from "components/form-components/SelectRow";
+import { OFState } from "main/home/onboarding/state";
+import { useIntercom } from "lib/hooks/useIntercom";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { pushFiltered } from "shared/routing";
+import { type ClusterType } from "shared/types";
+import dotVertical from "assets/dot-vertical.svg";
+
+import {
+  AzureLocationOptions,
+  azureSupportedMachineTypes,
+  type MachineTypeOption,
+} from "./azureUtils";
+import InputRow from "./form-components/InputRow";
 import Button from "./porter/Button";
 import Error from "./porter/Error";
+import Icon from "./porter/Icon";
+import Link from "./porter/Link";
 import Spacer from "./porter/Spacer";
 import Step from "./porter/Step";
-import Link from "./porter/Link";
 import Text from "./porter/Text";
-import {useIntercom} from "lib/hooks/useIntercom";
-import Icon from "./porter/Icon";
-import dotVertical from "assets/dot-vertical.svg";
-import {Label} from "@tanstack/react-query-devtools/build/lib/Explorer";
-
-const locationOptions = [
-  { value: "eastus", label: "East US" },
-  { value: "eastus2", label: "East US 2" },
-    { value: "westus2", label: "West US 2" },
-    { value: "westus3", label: "West US 3" },
-    { value: "centralus", label: "Central US" },
-    { value: "southcentralus", label: "South Central US" },
-    { value: "australiaeast", label: "Australia East" },
-    { value: "brazilsouth", label: "Brazil South" },
-    { value: "centralindia", label: "Central India" },
-    { value: "southcentralus", label: "South Central US" },
-    { value: "eastasia", label: "East Asia" },
-    { value: "francecentral", label: "France Central" },
-    { value: "northeurope", label: "North Europe" },
-    { value: "norwayeast", label: "Norway East" },
-    { value: "swedencentral", label: "Sweden Central" },
-    { value: "switzerlandnorth", label: "Switzerland North" },
-    { value: "uksouth", label: "UK South" },
-    { value: "westeurope", label: "West Europe" },
-];
-
-const machineTypeOptions = [
-  { value: "Standard_B2als_v2", label: "Standard_B2als_v2"},
-  { value: "Standard_A2_v2", label: "Standard_A2_v2" },
-  { value: "Standard_A4_v2", label: "Standard_A4_v2" },
-];
 
 const skuTierOptions = [
   { value: AksSkuTier.FREE, label: "Free" },
-  { value: AksSkuTier.STANDARD, label: "Standard (for production workloads, +$73/month)" },
+  {
+    value: AksSkuTier.STANDARD,
+    label: "Standard (for production workloads, +$73/month)",
+  },
 ];
 
-const clusterVersionOptions = [{ value: "v1.27.3", label: "v1.27" }, { value: "v1.24.9", label: "v1.24" }];
+const clusterVersionOptions = [{ value: "v1.27.3", label: "v1.27" }];
 
 type Props = RouteComponentProps & {
   selectedClusterVersion?: Contract;
@@ -73,7 +55,8 @@ type Props = RouteComponentProps & {
   clusterId?: number;
 };
 
-const VALID_CIDR_RANGE_PATTERN = /^(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.0\.0\/16$/;
+const VALID_CIDR_RANGE_PATTERN =
+  /^(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.0\.0\/16$/;
 
 const AzureProvisionerSettings: React.FC<Props> = (props) => {
   const {
@@ -94,18 +77,35 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
   const [cidrRange, setCidrRange] = useState("10.78.0.0/16");
   const [clusterVersion, setClusterVersion] = useState("v1.27.3");
   const [isReadOnly, setIsReadOnly] = useState(false);
-  const [skuTier, setSkuTier] = useState(AksSkuTier.FREE)
+  const [skuTier, setSkuTier] = useState(AksSkuTier.FREE);
   const [errorMessage, setErrorMessage] = useState<string>("");
   const [errorDetails, setErrorDetails] = useState<string>("");
   const [isClicked, setIsClicked] = useState(false);
+  const [
+    regionFilteredMachineTypeOptions,
+    setRegionFilteredMachineTypeOptions,
+  ] = useState<MachineTypeOption[]>(azureSupportedMachineTypes(azureLocation));
 
   const { showIntercomWithMessage } = useIntercom();
 
-  const markStepStarted = async (step: string, {region, error_message}: {region?: string; error_message?: string}) => {
+  useEffect(() => {
+    setRegionFilteredMachineTypeOptions(
+      azureSupportedMachineTypes(azureLocation)
+    );
+  }, [azureLocation]);
+
+  const markStepStarted = async (
+    step: string,
+    { region, error_message }: { region?: string; error_message?: string }
+  ) => {
     try {
-      await api.updateOnboardingStep("<token>", { step, region, error_message, provider: "azure" }, {
-        project_id: currentProject.id,
-      });
+      await api.updateOnboardingStep(
+        "<token>",
+        { step, region, error_message, provider: "azure" },
+        {
+          project_id: currentProject.id,
+        }
+      );
     } catch (err) {
       console.log(err);
     }
@@ -117,7 +117,11 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
     } else if (errorMessage !== "") {
       return (
         <Error
-          message={errorDetails !== "" ? errorMessage + " (" + errorDetails + ")" : errorMessage}
+          message={
+            errorDetails !== ""
+              ? errorMessage + " (" + errorDetails + ")"
+              : errorMessage
+          }
           ctaText={
             errorMessage !== DEFAULT_ERROR_MESSAGE
               ? "Troubleshooting steps"
@@ -132,15 +136,14 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
 
   const isDisabled = () => {
     return (
-      (!clusterName && true)
-      || (isReadOnly && props.provisionerError === "")
-      || currentCluster?.status === "UPDATING"
-      || isClicked
-      || (!currentProject?.enable_reprovision && props.clusterId)
-    )
+      (!clusterName && true) ||
+      (isReadOnly && props.provisionerError === "") ||
+      currentCluster?.status === "UPDATING" ||
+      isClicked ||
+      (!currentProject?.enable_reprovision && props.clusterId)
+    );
   };
 
-
   const validateInputs = (): string => {
     if (!clusterName) {
       return "Cluster name is required";
@@ -162,29 +165,29 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
     }
 
     return "";
-  }
+  };
   const createCluster = async () => {
     const err = validateInputs();
     if (err !== "") {
-      setErrorMessage(err)
-      setErrorDetails("")
+      setErrorMessage(err);
+      setErrorDetails("");
       return;
     }
 
     setIsClicked(true);
-    
+
     try {
       window.dataLayer?.push({
-        event: 'provision-attempt',
+        event: "provision-attempt",
         data: {
-          cloud: 'azure',
-          email: user?.email
-        }
+          cloud: "azure",
+          email: user?.email,
+        },
       });
     } catch (err) {
       console.log(err);
     }
-    
+
     const data = new Contract({
       cluster: new Cluster({
         projectId: currentProject.id,
@@ -234,7 +237,7 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
     try {
       setIsReadOnly(true);
       setErrorMessage("");
-      setErrorDetails("")
+      setErrorDetails("");
 
       if (!props.clusterId) {
         markStepStarted("provisioning-started", { region: azureLocation });
@@ -266,11 +269,14 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
         });
       // }
       setErrorMessage("");
-      setErrorDetails("")
+      setErrorDetails("");
     } catch (err) {
-      showIntercomWithMessage({ message: "I am running into an issue provisioning a cluster." });
+      showIntercomWithMessage({
+        message: "I am running into an issue provisioning a cluster.",
+      });
       let errorMessage = DEFAULT_ERROR_MESSAGE;
-      const errorDetails = err.response?.data?.error?.replace("unknown: ", "") ?? "";
+      const errorDetails =
+        err.response?.data?.error?.replace("unknown: ", "") ?? "";
       // hacky, need to standardize error contract with backend
       setIsClicked(false);
       if (errorDetails.includes("resource provider")) {
@@ -283,7 +289,9 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
         setErrorDetails("");
       }
       setErrorMessage(errorMessage);
-      markStepStarted("provisioning-failed", { error_message: `Error message: ${errorMessage}; Error details: ${errorDetails}` });
+      markStepStarted("provisioning-failed", {
+        error_message: `Error message: ${errorMessage}; Error details: ${errorDetails}`,
+      });
     } finally {
       setIsReadOnly(false);
       setIsClicked(false);
@@ -294,25 +302,29 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
     if (!currentProject) return;
     setIsReadOnly(
       !!props.clusterId &&
-      (currentCluster?.status === "UPDATING" ||
-        currentCluster?.status === "UPDATING_UNAVAILABLE")
+        (currentCluster?.status === "UPDATING" ||
+          currentCluster?.status === "UPDATING_UNAVAILABLE")
     );
     setClusterName(
-        `${currentProject?.name.substring(0,16)}-cluster-${Math.random()
+      `${currentProject?.name.substring(0, 16)}-cluster-${Math.random()
         .toString(36)
         .substring(2, 8)}`
     );
   }, []);
 
   useEffect(() => {
-
     if (!props.selectedClusterVersion) return;
 
     // TODO: pass in contract as the already parsed object, rather than JSON (requires changes to AWS/GCP provisioning)
-    const contract = Contract.fromJsonString(JSON.stringify(props.selectedClusterVersion))
+    const contract = Contract.fromJsonString(
+      JSON.stringify(props.selectedClusterVersion)
+    );
 
-    if (contract?.cluster?.kindValues && contract.cluster.kindValues.case === "aksKind") {
-      const aksValues = contract.cluster.kindValues.value
+    if (
+      contract?.cluster?.kindValues &&
+      contract.cluster.kindValues.case === "aksKind"
+    ) {
+      const aksValues = contract.cluster.kindValues.value;
       aksValues.nodePools.map((nodePool: AKSNodePool) => {
         if (nodePool.nodePoolType === NodePoolType.APPLICATION) {
           setMachineType(nodePool.instanceType);
@@ -326,112 +338,77 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
       setClusterVersion(aksValues.clusterVersion);
       setCidrRange(aksValues.cidrRange);
       if (aksValues.skuTier !== AksSkuTier.UNSPECIFIED) {
-        setSkuTier(aksValues.skuTier)
+        setSkuTier(aksValues.skuTier);
       }
     }
   }, [props.selectedClusterVersion]);
 
-  const renderForm = () => {
-    // Render simplified form if initial create
-    if (!props.clusterId) {
-      return (
-        <>
-          <Text size={16}>Select an Azure location and tier</Text>
-          <Spacer y={1} />
-          <Text color="helper">
-            Porter will automatically provision your infrastructure with the
-            specified configuration.
-          </Text>
-          <Spacer height="10px" />
-          <SelectRow
-            options={locationOptions}
-            width="350px"
-            disabled={isReadOnly}
-            value={azureLocation}
-            scrollBuffer={true}
-            dropdownMaxHeight="240px"
-            setActiveValue={setAzureLocation}
-            label="📍 Azure location"
-          />
-          <Spacer y={.75} />
-          <div style={{display: "flex", alignItems: "center"}}>
-            <Spacer inline x={.05}/>
-            <Icon src={dotVertical} height={"15px"}/>
-            <Spacer inline x={.1}/>
-            <Label>Azure Tier</Label>
-          </div>
-          <SelectRow
-              options={skuTierOptions}
-              width="350px"
-              disabled={isReadOnly}
-              value={skuTier}
-              scrollBuffer={true}
-              dropdownMaxHeight="240px"
-              setActiveValue={setSkuTier}
-          />
-        </>
-      );
-    }
-
-    // If settings, update full form
+  const renderSimpleSettings = (): JSX.Element => {
     return (
       <>
-        <Heading isAtTop>AKS configuration</Heading>
-        <Spacer y={0.75} />
         <SelectRow
-          options={locationOptions}
+          options={AzureLocationOptions}
           width="350px"
-          disabled={isReadOnly || true}
+          disabled={props.clusterId ? props.clusterId !== 0 : false}
           value={azureLocation}
           scrollBuffer={true}
           dropdownMaxHeight="240px"
           setActiveValue={setAzureLocation}
           label="📍 Azure location"
         />
-        <Spacer y={.75} />
-        <div style={{display: "flex", alignItems: "center"}}>
-          <Spacer inline x={.05}/>
-          <Icon src={dotVertical} height={"15px"}/>
-          <Spacer inline x={.1}/>
+        <Spacer y={0.75} />
+        <div style={{ display: "flex", alignItems: "center" }}>
+          <Spacer inline x={0.05} />
+          <Icon src={dotVertical} height={"15px"} />
+          <Spacer inline x={0.2} />
           <Label>Azure Tier</Label>
         </div>
         <SelectRow
-            options={skuTierOptions}
-            width="350px"
-            disabled={isReadOnly}
-            value={skuTier}
-            scrollBuffer={true}
-            dropdownMaxHeight="240px"
-            setActiveValue={setSkuTier}
+          options={skuTierOptions}
+          width="350px"
+          disabled={isReadOnly}
+          value={skuTier}
+          scrollBuffer={true}
+          dropdownMaxHeight="240px"
+          setActiveValue={setSkuTier}
         />
-        {user?.isPorterUser && (
-          <Heading>
-            <ExpandHeader
-              onClick={() => { setIsExpanded(!isExpanded); }}
-              isExpanded={isExpanded}
-            >
-              <i className="material-icons">arrow_drop_down</i>
-              Advanced settings
-            </ExpandHeader>
-          </Heading>
-        )}
+      </>
+    );
+  };
+
+  const renderAdvancedSettings = (): JSX.Element => {
+    return (
+      <>
+        <Heading>
+          <ExpandHeader
+            onClick={() => {
+              setIsExpanded(!isExpanded);
+            }}
+            isExpanded={isExpanded}
+          >
+            <i className="material-icons">arrow_drop_down</i>
+            Advanced settings
+          </ExpandHeader>
+        </Heading>
+        <Spacer y={0.5} />
+
         {isExpanded && (
           <>
             <SelectRow
               options={clusterVersionOptions}
               width="350px"
-              disabled={isReadOnly}
+              disabled={true}
               value={clusterVersion}
               scrollBuffer={true}
               dropdownMaxHeight="240px"
               setActiveValue={setClusterVersion}
               label="Cluster version"
             />
-            <Spacer y={.75} />
+            <Spacer y={0.75} />
             <SelectRow
-              options={machineTypeOptions}
+              options={regionFilteredMachineTypeOptions}
               width="350px"
-              disabled={isReadOnly}
+              disabled={true}
               value={machineType}
               scrollBuffer={true}
               dropdownMaxHeight="240px"
@@ -443,16 +420,20 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
               type="number"
               disabled={isReadOnly}
               value={maxInstances}
-              setValue={(x: number) => { setMaxInstances(x); }}
+              setValue={(x: number) => {
+                setMaxInstances(x);
+              }}
               label="Maximum number of application nodes"
               placeholder="ex: 1"
             />
             <InputRow
               width="350px"
               type="string"
-              disabled={isReadOnly}
+              disabled={true}
               value={cidrRange}
-              setValue={(x: string) => { setCidrRange(x); }}
+              setValue={(x: string) => {
+                setCidrRange(x);
+              }}
               label="VPC CIDR range"
               placeholder="ex: 10.78.0.0/16"
             />
@@ -462,6 +443,34 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
     );
   };
 
+  const renderForm = () => {
+    // Render simplified form if initial create
+    if (!props.clusterId) {
+      return (
+        <>
+          <Text size={16}>Select an Azure location and tier</Text>
+          <Spacer y={1} />
+          <Text color="helper">
+            Porter will automatically provision your infrastructure with the
+            specified configuration.
+          </Text>
+          <Spacer height="10px" />
+          {renderSimpleSettings()}
+        </>
+      );
+    }
+
+    // If settings, update full form
+    return (
+      <>
+        <Heading isAtTop>AKS configuration</Heading>
+        <Spacer y={0.75} />
+        {renderSimpleSettings()}
+        {renderAdvancedSettings()}
+      </>
+    );
+  };
+
   return (
     <>
       <StyledForm>{renderForm()}</StyledForm>
@@ -472,27 +481,25 @@ const AzureProvisionerSettings: React.FC<Props> = (props) => {
       >
         Provision
       </Button>
-      {
-        (!currentProject?.enable_reprovision && currentCluster) &&
+      {!currentProject?.enable_reprovision && currentCluster && (
         <>
           <Spacer y={1} />
-          <Text>Updates to the cluster are disabled on this project. Enable re-provisioning by contacting <a href="mailto:support@porter.run">Porter Support</a>.</Text>
+          <Text>
+            Updates to the cluster are disabled on this project. Enable
+            re-provisioning by contacting{" "}
+            <a href="mailto:support@porter.run">Porter Support</a>.
+          </Text>
         </>
-      }
-      {user.isPorterUser &&
+      )}
+      {user.isPorterUser && (
         <>
-
           <Spacer y={1} />
           <Text color="yellow">Visible to Admin Only</Text>
-          <Button
-            color="red"
-            onClick={createCluster}
-            status={getStatus()}
-          >
+          <Button color="red" onClick={createCluster} status={getStatus()}>
             Override Provision
           </Button>
         </>
-      }
+      )}
     </>
   );
 };
@@ -507,7 +514,7 @@ const ExpandHeader = styled.div<{ isExpanded: boolean }>`
     margin-right: 7px;
     margin-left: -7px;
     transform: ${(props) =>
-    props.isExpanded ? "rotate(0deg)" : "rotate(-90deg)"};
+      props.isExpanded ? "rotate(0deg)" : "rotate(-90deg)"};
     transition: transform 0.1s ease;
   }
 `;
@@ -545,10 +552,7 @@ const errorMessageToModal = (errorMessage: string) => {
           <Step number={1}>
             Log into
             <Spacer inline width="5px" />
-            <Link
-              to="https://login.microsoftonline.com/"
-              target="_blank"
-            >
+            <Link to="https://login.microsoftonline.com/" target="_blank">
               your Azure account
             </Link>
             .
@@ -572,11 +576,14 @@ const errorMessageToModal = (errorMessage: string) => {
           </Step>
           <Spacer y={1} />
           <Step number={4}>
-            Select "Compute" and search for the quotas that have reached usage limits in your region. Request an increase by clicking the pencil icon on the far right.
+            Select "Compute" and search for the quotas that have reached usage
+            limits in your region. Request an increase by clicking the pencil
+            icon on the far right.
           </Step>
           <Spacer y={1} />
           <Text color="helper">
-            We recommend an initial quota of 20 vCPUs for both Total Regional Cores and Standard Basv2 Family.
+            We recommend an initial quota of 20 vCPUs for both Total Regional
+            Cores and Standard Basv2 Family.
           </Text>
           <Spacer y={1} />
           <Step number={5}>
@@ -585,7 +592,9 @@ const errorMessageToModal = (errorMessage: string) => {
           </Step>
           <Spacer y={1} />
           <Text color="helper">
-            Quota increases can take several minutes to process. If Azure is unable to automatically increase the quota, create a support request as prompted by Azure. Requests are usually fulfilled in a few hours.
+            Quota increases can take several minutes to process. If Azure is
+            unable to automatically increase the quota, create a support request
+            as prompted by Azure. Requests are usually fulfilled in a few hours.
           </Text>
         </>
       );
@@ -597,16 +606,17 @@ const errorMessageToModal = (errorMessage: string) => {
           </Text>
           <Spacer y={1} />
           <Text color="helper">
-            You will need to register all of the following resource providers to your Azure subscription before provisioning: Capacity, Compute, ContainerRegistry, ContainerService, ManagedIdentity, Network, OperationalInsights, OperationsManagement, ResourceGraph, Resources, Storage
+            You will need to register all of the following resource providers to
+            your Azure subscription before provisioning: Capacity, Compute,
+            ContainerRegistry, ContainerService, ManagedIdentity, Network,
+            OperationalInsights, OperationsManagement, ResourceGraph, Resources,
+            Storage
           </Text>
           <Spacer y={1} />
           <Step number={1}>
             Log into
             <Spacer inline width="5px" />
-            <Link
-              to="https://login.microsoftonline.com/"
-              target="_blank"
-            >
+            <Link to="https://login.microsoftonline.com/" target="_blank">
               your Azure account
             </Link>
             .
@@ -630,16 +640,17 @@ const errorMessageToModal = (errorMessage: string) => {
           </Step>
           <Spacer y={1} />
           <Step number={4}>
-            Search for each required resource provider and select "Register" from the top menu bar if it is not already registered.
+            Search for each required resource provider and select "Register"
+            from the top menu bar if it is not already registered.
           </Step>
           <Spacer y={1} />
           <Step number={5}>
-            After confirming that all providers are registered, return to Porter and retry the
-            provision.
+            After confirming that all providers are registered, return to Porter
+            and retry the provision.
           </Step>
         </>
       );
     default:
       return null;
   }
-};
+};

+ 246 - 0
dashboard/src/components/azureUtils.ts

@@ -0,0 +1,246 @@
+// These locations are the regions where the default system and monitoring sku types are available (last checked 12/19/2023)
+export const AzureLocationOptions = [
+  { value: "australiaeast", label: "Australia East" },
+  { value: "brazilsouth", label: "Brazil South" },
+  { value: "canadacentral", label: "Canada Central" },
+  { value: "centralindia", label: "Central India" },
+  { value: "centralus", label: "Central US" },
+  { value: "eastasia", label: "East Asia" },
+  { value: "eastus", label: "East US" },
+  { value: "eastus2", label: "East US 2" },
+  { value: "francecentral", label: "France Central" },
+  { value: "northeurope", label: "North Europe" },
+  { value: "norwayeast", label: "Norway East" },
+  { value: "southafricanorth", label: "South Africa North" },
+  { value: "southcentralus", label: "South Central US" },
+  { value: "swedencentral", label: "Sweden Central" },
+  { value: "switzerlandnorth", label: "Switzerland North" },
+  { value: "uaenorth", label: "UAE North" },
+  { value: "uksouth", label: "UK South" },
+  { value: "westeurope", label: "West Europe" },
+  { value: "westus2", label: "West US 2" },
+  { value: "westus3", label: "West US 3" },
+];
+
+export type MachineTypeOption = {
+  value: string;
+  label: string;
+  supportedRegions: Set<string>;
+};
+
+export const azureSupportedMachineTypes = (
+  region: string
+): MachineTypeOption[] => {
+  return AzureMachineTypeOptions.filter((option) =>
+    option.supportedRegions.has(region)
+  );
+};
+
+// Retrieve updated list of supported regions by running the following command: az vm list-skus --all --output table | grep <INSTANCE_TYPE> | grep 1,2,3 | grep None | awk '{print "\047" tolower($2) "\047"}' | paste -s -d, -
+// last updated 12/19/2020
+const AzureMachineTypeOptions: MachineTypeOption[] = [
+  {
+    value: "Standard_B2als_v2",
+    label: "Standard_B2als_v2",
+    supportedRegions: new Set<string>([
+      "australiaeast",
+      "brazilsouth",
+      "canadacentral",
+      "centralindia",
+      "centralus",
+      "eastasia",
+      "eastus",
+      "eastus2",
+      "francecentral",
+      "northeurope",
+      "norwayeast",
+      "southafricanorth",
+      "southcentralus",
+      "southeastasia",
+      "swedencentral",
+      "switzerlandnorth",
+      "uaenorth",
+      "uksouth",
+      "westeurope",
+      "westus2",
+      "westus3",
+    ]),
+  },
+  {
+    value: "Standard_B2as_v2",
+    label: "Standard_B2as_v2",
+    supportedRegions: new Set<string>([
+      "australiaeast",
+      "brazilsouth",
+      "canadacentral",
+      "centralindia",
+      "centralus",
+      "eastasia",
+      "eastus",
+      "eastus2",
+      "francecentral",
+      "northeurope",
+      "norwayeast",
+      "southafricanorth",
+      "southcentralus",
+      "southeastasia",
+      "swedencentral",
+      "switzerlandnorth",
+      "uaenorth",
+      "uksouth",
+      "westeurope",
+      "westus2",
+      "westus3",
+    ]),
+  },
+  {
+    value: "Standard_A2_v2",
+    label: "Standard_A2_v2",
+    supportedRegions: new Set<string>([
+      "australiaeast",
+      "canadacentral",
+      "centralindia",
+      "eastasia",
+      "eastus",
+      "eastus2",
+      "francecentral",
+      "germanywestcentral",
+      "israelcentral",
+      "italynorth",
+      "northeurope",
+      "norwayeast",
+      "polandcentral",
+      "southafricanorth",
+      "swedencentral",
+      "switzerlandnorth",
+      "uaenorth",
+      "uksouth",
+    ]),
+  },
+  {
+    value: "Standard_A4_v2",
+    label: "Standard_A4_v2",
+    supportedRegions: new Set<string>([
+      "australiaeast",
+      "canadacentral",
+      "centralindia",
+      "eastasia",
+      "eastus",
+      "eastus2",
+      "francecentral",
+      "germanywestcentral",
+      "israelcentral",
+      "italynorth",
+      "northeurope",
+      "norwayeast",
+      "polandcentral",
+      "southafricanorth",
+      "swedencentral",
+      "switzerlandnorth",
+      "uaenorth",
+      "uksouth",
+    ]),
+  },
+  {
+    value: "Standard_DS1_v2",
+    label: "Standard_DS1_v2",
+    supportedRegions: new Set<string>([
+      "australiaeast",
+      "canadacentral",
+      "centralindia",
+      "eastasia",
+      "eastus",
+      "eastus2",
+      "francecentral",
+      "germanywestcentral",
+      "israelcentral",
+      "italynorth",
+      "northeurope",
+      "norwayeast",
+      "polandcentral",
+      "southafricanorth",
+      "swedencentral",
+      "switzerlandnorth",
+      "uaenorth",
+      "uksouth",
+    ]),
+  },
+  {
+    value: "Standard_DS2_v2",
+    label: "Standard_DS2_v2",
+    supportedRegions: new Set<string>([
+      "australiaeast",
+      "canadacentral",
+      "centralindia",
+      "eastasia",
+      "eastus",
+      "eastus2",
+      "francecentral",
+      "germanywestcentral",
+      "israelcentral",
+      "italynorth",
+      "northeurope",
+      "norwayeast",
+      "polandcentral",
+      "southafricanorth",
+      "swedencentral",
+      "switzerlandnorth",
+      "uaenorth",
+      "uksouth",
+      "eastus2euap",
+      "israelcentral",
+      "italynorth",
+      "polandcentral",
+      "qatarcentral",
+      "swedencentral",
+      "switzerlandnorth",
+      "westus3",
+    ]),
+  },
+  {
+    value: "Standard_D2ads_v5",
+    label: "Standard_D2ads_v5",
+    supportedRegions: new Set<string>([
+      "australiaeast",
+      "canadacentral",
+      "centralindia",
+      "eastasia",
+      "eastus",
+      "koreacentral",
+      "northeurope",
+      "norwayeast",
+      "southafricanorth",
+      "swedencentral",
+      "uaenorth",
+      "uksouth",
+      "westus3",
+    ]),
+  },
+  {
+    value: "Standard_B4als_v2",
+    label: "Standard_B4als_v2",
+    supportedRegions: new Set<string>([
+      "australiaeast",
+      "brazilsouth",
+      "canadacentral",
+      "centralindia",
+      "centralus",
+      "eastasia",
+      "eastus",
+      "eastus2",
+      "francecentral",
+      "northeurope",
+      "norwayeast",
+      "southafricanorth",
+      "southcentralus",
+      "southeastasia",
+      "swedencentral",
+      "switzerlandnorth",
+      "uaenorth",
+      "uksouth",
+      "westeurope",
+      "westus2",
+      "westus3",
+    ]),
+  },
+];

+ 7 - 7
dashboard/src/lib/addons/index.ts

@@ -64,15 +64,15 @@ export function clientAddonToProto(addon: ClientAddon): Addon {
   return proto;
 }
 
-export function clientAddonFromProto(args: {
+export function clientAddonFromProto({
+  addon,
+  variables = {},
+  secrets = {},
+}: {
   addon: Addon;
-  variables: Record<string, string>;
-  secrets: Record<string, string>;
+  variables?: Record<string, string>;
+  secrets?: Record<string, string>;
 }): ClientAddon {
-  const addon = args.addon;
-  const variables = args.variables;
-  const secrets = args.secrets;
-
   if (!addon.config.case) {
     throw new Error("Addon type is unspecified");
   }

+ 22 - 2
dashboard/src/lib/hooks/useClusterResourceLimits.ts

@@ -1,7 +1,6 @@
 import { useEffect, useState } from "react";
 import {
   Contract,
-  GKENodePoolType,
   LoadBalancerType,
   NodeGroupType,
   NodePoolType,
@@ -11,7 +10,10 @@ import convert from "convert";
 import { match } from "ts-pattern";
 import { z } from "zod";
 
-import { AWS_INSTANCE_LIMITS } from "main/home/app-dashboard/validate-apply/services-settings/tabs/utils";
+import {
+  AWS_INSTANCE_LIMITS,
+  AZURE_INSTANCE_LIMITS,
+} from "main/home/app-dashboard/validate-apply/services-settings/tabs/utils";
 
 import api from "shared/api";
 
@@ -109,6 +111,24 @@ const clusterNodesValidator = z
     }
     const instanceType = data.labels["beta.kubernetes.io/instance-type"];
 
+    if (!instanceType) {
+      return defaultResources;
+    }
+
+    // Azure instance types are all prefixed with "Standard_"
+    if (instanceType.startsWith("Standard_")) {
+      if (AZURE_INSTANCE_LIMITS[instanceType]) {
+        const { vCPU, RAM } = AZURE_INSTANCE_LIMITS[instanceType];
+        return {
+          maxCPU: vCPU,
+          maxRAM: RAM,
+          azureType: instanceType,
+        };
+      } else {
+        return defaultResources;
+      }
+    }
+
     let parsedType;
     if (instanceType && instanceType.includes(".")) {
       parsedType = z

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

@@ -22,6 +22,7 @@ export const appRevisionValidator = z.object({
     "DEPLOYMENT_PROGRESSING",
     "DEPLOYMENT_SUCCESSFUL",
     "DEPLOYMENT_FAILED",
+    "DEPLOYMENT_SUPERSEDED",
     "ROLLBACK_SUCCESSFUL",
     "ROLLBACK_FAILED",
     "ROLLBACK_SKIPPED",

+ 118 - 0
dashboard/src/main/home/app-dashboard/apps/Addon.tsx

@@ -0,0 +1,118 @@
+import React, { useMemo } from "react";
+import styled from "styled-components";
+import { match } from "ts-pattern";
+
+import CopyToClipboard from "components/CopyToClipboard";
+import Container from "components/porter/Container";
+import Icon from "components/porter/Icon";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { type ClientAddon } from "lib/addons";
+
+import { useDeploymentTarget } from "shared/DeploymentTargetContext";
+import copy from "assets/copy-left.svg";
+import postgresql from "assets/postgresql.svg";
+
+import { Block, Row } from "./AppGrid";
+
+type AddonProps = {
+  addon: ClientAddon;
+  view: "grid" | "list";
+};
+
+export const Addon: React.FC<AddonProps> = ({ addon, view }) => {
+  const { currentDeploymentTarget } = useDeploymentTarget();
+
+  const endpoint = useMemo(() => {
+    if (!currentDeploymentTarget) return "";
+    if (!addon.name.value) return "";
+
+    return `${addon.name.value}-postgres.${currentDeploymentTarget.namespace}.svc.cluster.local:5432`;
+  }, [currentDeploymentTarget, addon.name.value]);
+
+  return match(view)
+    .with("grid", () => (
+      <Block locked>
+        <Container row>
+          <Spacer inline width="1px" />
+          <Icon height="16px" src={postgresql} />
+          <Spacer inline width="12px" />
+          <Text size={14}>{addon.name.value}</Text>
+          <Spacer inline x={2} />
+        </Container>
+        <div>
+          <Text color="helper">Endpoint</Text>
+          <Spacer y={0.1} />
+          <IdContainer>
+            <Text size={10} truncate>
+              <Code>{endpoint}</Code>
+            </Text>
+            <CopyContainer>
+              <CopyToClipboard text={endpoint}>
+                <CopyIcon src={copy} alt="copy" />
+              </CopyToClipboard>
+            </CopyContainer>
+          </IdContainer>
+        </div>
+      </Block>
+    ))
+    .with("list", () => (
+      <Row locked>
+        <Container row>
+          <Spacer inline width="1px" />
+          <Icon height="16px" src={postgresql} />
+          <Spacer inline width="12px" />
+          <Text size={14}>{addon.name.value}</Text>
+          <Spacer inline x={1} />
+        </Container>
+        <Spacer height="15px" />
+        <Text color="helper">Endpoint</Text>
+        <Spacer y={0.1} />
+        <IdContainer>
+          <Text size={10} truncate>
+            <Code>{endpoint}</Code>
+          </Text>
+          <CopyContainer>
+            <CopyToClipboard text={endpoint}>
+              <CopyIcon src={copy} alt="copy" />
+            </CopyToClipboard>
+          </CopyContainer>
+        </IdContainer>
+      </Row>
+    ))
+    .exhaustive();
+};
+
+const Code = styled.span`
+  font-family: monospace;
+`;
+
+const IdContainer = styled.div`
+  background: #26292e;
+  border-radius: 5px;
+  padding: 10px;
+  display: flex;
+  width: 100%px;
+  border-radius: 5px;
+  border: 1px solid ${({ theme }) => theme.border};
+  align-items: center;
+  user-select: text;
+  text-overflow: ellipsis;
+`;
+
+const CopyContainer = styled.div`
+  display: flex;
+  align-items: center;
+  margin-left: auto;
+`;
+
+const CopyIcon = styled.img`
+  cursor: pointer;
+  margin-left: 5px;
+  margin-right: 5px;
+  width: 15px;
+  height: 15px;
+  :hover {
+    opacity: 0.8;
+  }
+`;

+ 26 - 8
dashboard/src/main/home/app-dashboard/apps/AppGrid.tsx

@@ -9,6 +9,7 @@ import Container from "components/porter/Container";
 import Fieldset from "components/porter/Fieldset";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
+import { type ClientAddon } from "lib/addons";
 
 import { useDeploymentTarget } from "shared/DeploymentTargetContext";
 import { search } from "shared/search";
@@ -18,17 +19,25 @@ import target from "assets/target.svg";
 import time from "assets/time.png";
 
 import { Context } from "../../../../shared/Context";
+import { Addon } from "./Addon";
 import { AppIcon, AppSource } from "./AppMeta";
 import { type AppRevisionWithSource } from "./types";
 
 type AppGridProps = {
   apps: AppRevisionWithSource[];
+  addons: ClientAddon[];
   searchValue: string;
   view: "grid" | "list";
   sort: "letter" | "calendar";
 };
 
-const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
+const AppGrid: React.FC<AppGridProps> = ({
+  apps,
+  addons,
+  searchValue,
+  view,
+  sort,
+}) => {
   const { currentDeploymentTarget } = useDeploymentTarget();
   const { currentProject } = useContext(Context);
 
@@ -142,6 +151,9 @@ const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
             );
           }
         )}
+        {addons.map((a) => {
+          return <Addon addon={a} view={view} key={a.name.value} />;
+        })}
       </GridList>
     ))
     .with("list", () => (
@@ -184,6 +196,9 @@ const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
             );
           }
         )}
+        {addons.map((a) => {
+          return <Addon addon={a} view={view} key={a.name.value} />;
+        })}
       </List>
     ))
     .exhaustive();
@@ -204,20 +219,22 @@ const GridList = styled.div`
   grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
 `;
 
-const Block = styled.div`
+export const Block = styled.div<{ locked?: boolean }>`
   height: 150px;
   flex-direction: column;
   display: flex;
   justify-content: space-between;
-  cursor: pointer;
+  cursor: ${(props) => (props.locked ? "default" : "pointer")};
   padding: 20px;
   color: ${(props) => props.theme.text.primary};
   position: relative;
   border-radius: 5px;
-  background: ${(props) => props.theme.clickable.bg};
+  background: ${(props) =>
+    props.locked ? props.theme.fg : props.theme.clickable.bg};
   border: 1px solid #494b4f;
+
   :hover {
-    border: 1px solid #7a7b80;
+    border: ${(props) => (props.locked ? "" : `1px solid #7a7b80`)};
   }
   animation: fadeIn 0.3s 0s;
   @keyframes fadeIn {
@@ -234,12 +251,13 @@ const List = styled.div`
   overflow: hidden;
 `;
 
-const Row = styled.div<{ isAtBottom?: boolean }>`
-  cursor: pointer;
+export const Row = styled.div<{ isAtBottom?: boolean; locked?: boolean }>`
+  cursor: ${(props) => (props.locked ? "default" : "pointer")};
   padding: 15px;
   border-bottom: ${(props) =>
     props.isAtBottom ? "none" : "1px solid #494b4f"};
-  background: ${(props) => props.theme.clickable.bg};
+  background: ${(props) =>
+    props.locked ? props.theme.fg : props.theme.clickable.bg};
   position: relative;
   border: 1px solid #494b4f;
   border-radius: 5px;

+ 110 - 43
dashboard/src/main/home/app-dashboard/apps/Apps.tsx

@@ -1,5 +1,6 @@
-import React, { useCallback, useContext, useState } from "react";
-import { useQuery } from "@tanstack/react-query";
+import React, { useCallback, useContext, useMemo, useState } from "react";
+import { Addon } from "@porter-dev/api-contracts/src/porter/v1/addons_pb";
+import { useQueries } from "@tanstack/react-query";
 import { useHistory } from "react-router";
 import styled from "styled-components";
 import { z } from "zod";
@@ -16,6 +17,7 @@ import Text from "components/porter/Text";
 import Toggle from "components/porter/Toggle";
 import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
 import DeleteEnvModal from "main/home/cluster-dashboard/preview-environments/v2/DeleteEnvModal";
+import { clientAddonFromProto, type ClientAddon } from "lib/addons";
 import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
 
 import api from "shared/api";
@@ -31,6 +33,12 @@ import web from "assets/web.png";
 import AppGrid from "./AppGrid";
 import { appRevisionWithSourceValidator } from "./types";
 
+export type ClientAddonWithEnv = {
+  addon: ClientAddon;
+  variables: Record<string, string>;
+  secrets: Record<string, string>;
+};
+
 const Apps: React.FC = () => {
   const { currentProject, currentCluster } = useContext(Context);
   const { updateAppStep } = useAppAnalytics();
@@ -43,53 +51,111 @@ const Apps: React.FC = () => {
   const [showDeleteEnvModal, setShowDeleteEnvModal] = useState(false);
   const [envDeleting, setEnvDeleting] = useState(false);
 
-  const { data: apps = [], status } = useQuery(
-    [
-      "getLatestAppRevisions",
+  const [{ data: apps = [], status }, { data: addons = [] }] = useQueries({
+    queries: [
       {
-        cluster_id: currentCluster?.id,
-        project_id: currentProject?.id,
-        deployment_target_id: currentDeploymentTarget?.id,
+        queryKey: [
+          "getLatestAppRevisions",
+          {
+            cluster_id: currentCluster?.id,
+            project_id: currentProject?.id,
+            deployment_target_id: currentDeploymentTarget?.id,
+          },
+        ],
+        queryFn: async () => {
+          if (
+            !currentCluster ||
+            !currentProject ||
+            currentCluster.id === -1 ||
+            currentProject.id === -1 ||
+            !currentDeploymentTarget
+          ) {
+            return;
+          }
+
+          const res = await api.getLatestAppRevisions(
+            "<token>",
+            {
+              deployment_target_id:
+                currentProject.managed_deployment_targets_enabled &&
+                !currentDeploymentTarget.is_preview
+                  ? undefined
+                  : currentDeploymentTarget.id,
+              ignore_preview_apps: !currentDeploymentTarget.is_preview,
+            },
+            { cluster_id: currentCluster.id, project_id: currentProject.id }
+          );
+
+          const apps = await z
+            .object({
+              app_revisions: z.array(appRevisionWithSourceValidator),
+            })
+            .parseAsync(res.data);
+
+          return apps.app_revisions;
+        },
+        enabled:
+          !!currentCluster && !!currentProject && !!currentDeploymentTarget,
+        refetchInterval: 5000,
+        refetchOnWindowFocus: false,
       },
-    ],
-    async () => {
-      if (
-        !currentCluster ||
-        !currentProject ||
-        currentCluster.id === -1 ||
-        currentProject.id === -1 ||
-        !currentDeploymentTarget
-      ) {
-        return;
-      }
+      {
+        queryKey: [
+          "listLatestAddons",
+          {
+            cluster_id: currentCluster?.id,
+            project_id: currentProject?.id,
+            deployment_target_id: currentDeploymentTarget?.id,
+          },
+        ],
+        queryFn: async () => {
+          if (
+            !currentCluster ||
+            !currentProject ||
+            currentCluster.id === -1 ||
+            currentProject.id === -1 ||
+            !currentDeploymentTarget
+          ) {
+            return;
+          }
 
-      const res = await api.getLatestAppRevisions(
-        "<token>",
-        {
-          deployment_target_id:
-            currentProject.managed_deployment_targets_enabled &&
-            !currentDeploymentTarget.is_preview
-              ? undefined
-              : currentDeploymentTarget.id,
-          ignore_preview_apps: !currentDeploymentTarget.is_preview,
+          const res = await api.listLatestAddons(
+            "<token>",
+            {
+              deployment_target_id: currentDeploymentTarget.id,
+            },
+            { clusterId: currentCluster.id, projectId: currentProject.id }
+          );
+
+          const parsed = await z
+            .object({
+              base64_addons: z.array(z.string()),
+            })
+            .parseAsync(res.data);
+
+          return parsed.base64_addons;
         },
-        { cluster_id: currentCluster.id, project_id: currentProject.id }
-      );
+        enabled:
+          !!currentCluster &&
+          !!currentProject &&
+          !!currentDeploymentTarget &&
+          currentDeploymentTarget.is_preview,
+        refetchOnWindowFocus: false,
+      },
+    ],
+  });
 
-      const apps = await z
-        .object({
-          app_revisions: z.array(appRevisionWithSourceValidator),
-        })
-        .parseAsync(res.data);
+  const clientAddons: ClientAddon[] = useMemo(() => {
+    return addons.map((a) => {
+      const proto = Addon.fromJsonString(atob(a), {
+        ignoreUnknownFields: true,
+      });
 
-      return apps.app_revisions;
-    },
-    {
-      refetchOnWindowFocus: false,
-      enabled:
-        !!currentCluster && !!currentProject && !!currentDeploymentTarget,
-    }
-  );
+      return clientAddonFromProto({
+        addon: proto,
+      });
+    });
+  }, [addons]);
 
   const deletePreviewEnv = useCallback(async () => {
     try {
@@ -256,6 +322,7 @@ const Apps: React.FC = () => {
         <Spacer y={1} />
         <AppGrid
           apps={apps}
+          addons={clientAddons}
           sort={sort}
           view={view}
           searchValue={searchValue}

+ 24 - 49
dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvGroups.tsx

@@ -1,20 +1,20 @@
 import React, { useMemo, useState } from "react";
-import styled from "styled-components";
 import { useFieldArray, useFormContext } from "react-hook-form";
+import styled from "styled-components";
+import { type IterableElement } from "type-fest";
 
-import sliders from "assets/sliders.svg";
-import doppler from "assets/doppler.png";
-
+import Icon from "components/porter/Icon";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import { type PorterAppFormData } from "lib/porter-apps";
-import ExpandableEnvGroup from "./ExpandableEnvGroup";
-import { type PopulatedEnvGroup } from "./types";
 
 import { valueExists } from "shared/util";
+import doppler from "assets/doppler.png";
+import sliders from "assets/sliders.svg";
+
 import EnvGroupModal from "./EnvGroupModal";
-import { type IterableElement } from "type-fest";
-import Icon from "components/porter/Icon";
+import ExpandableEnvGroup from "./ExpandableEnvGroup";
+import { type PopulatedEnvGroup } from "./types";
 
 type Props = {
   baseEnvGroups?: PopulatedEnvGroup[];
@@ -26,10 +26,13 @@ const EnvGroups: React.FC<Props> = ({
   attachedEnvGroups = [],
 }) => {
   const [showEnvModal, setShowEnvModal] = useState(false);
-  const [hovered, setHovered] = useState(false);
 
   const { control } = useFormContext<PorterAppFormData>();
-  const { append, remove, fields: envGroups } = useFieldArray({
+  const {
+    append,
+    remove,
+    fields: envGroups,
+  } = useFieldArray({
     control,
     name: "app.envGroups",
   });
@@ -42,8 +45,6 @@ const EnvGroups: React.FC<Props> = ({
     name: "deletions.envGroupNames",
   });
 
-  const maxEnvGroupsReached = envGroups.length >= 4;
-
   const populatedEnvWithFallback = useMemo(() => {
     return envGroups
       .map((envGroup, index) => {
@@ -102,20 +103,14 @@ const EnvGroups: React.FC<Props> = ({
 
   return (
     <div>
-      <TooltipWrapper
-        onMouseOver={() => { setHovered(true); }}
-        onMouseOut={() => { setHovered(false); }}
+      <LoadButton
+        disabled={false}
+        onClick={() => {
+          setShowEnvModal(true);
+        }}
       >
-        <LoadButton
-          disabled={maxEnvGroupsReached}
-          onClick={() => { !maxEnvGroupsReached && setShowEnvModal(true); }}
-        >
-          <img src={sliders} /> Load from Env Group
-        </LoadButton>
-        <TooltipText visible={maxEnvGroupsReached && hovered}>
-          Max 3 Env Groups allowed
-        </TooltipText>
-      </TooltipWrapper>
+        <img src={sliders} /> Load from Env Group
+      </LoadButton>
       {populatedEnvWithFallback.length > 0 && (
         <>
           <Spacer y={0.5} />
@@ -127,7 +122,9 @@ const EnvGroups: React.FC<Props> = ({
                 index={index}
                 envGroup={envGroup}
                 remove={onRemove}
-                icon={<Icon src={envGroup.type === "doppler" ? doppler : sliders} />}
+                icon={
+                  <Icon src={envGroup.type === "doppler" ? doppler : sliders} />
+                }
               />
             );
           })}
@@ -171,7 +168,7 @@ const AddRowButton = styled.div`
   }
 `;
 
-const LoadButton = styled(AddRowButton) <{ disabled?: boolean }>`
+const LoadButton = styled(AddRowButton)<{ disabled?: boolean }>`
   background: ${(props) => (props.disabled ? "#aaaaaa55" : "none")};
   border: 1px solid ${(props) => (props.disabled ? "#aaaaaa55" : "#ffffff55")};
   cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
@@ -192,25 +189,3 @@ const LoadButton = styled(AddRowButton) <{ disabled?: boolean }>`
     opacity: ${(props) => (props.disabled ? "0.5" : "1")};
   }
 `;
-
-const TooltipWrapper = styled.div`
-  position: relative;
-  display: inline-block;
-`;
-
-const TooltipText = styled.span<{ visible: boolean }>`
-  visibility: ${(props) => (props.visible ? "visible" : "hidden")};
-  width: 240px;
-  color: #fff;
-  text-align: center;
-  padding: 5px 0;
-  border-radius: 6px;
-  position: absolute;
-  z-index: 1;
-  bottom: 100%;
-  left: 50%;
-  margin-left: -120px;
-  opacity: ${(props) => (props.visible ? "1" : "0")};
-  transition: opacity 0.3s;
-  font-size: 12px;
-`;

+ 13 - 0
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/utils.ts

@@ -6,6 +6,7 @@ type InstanceDetails = {
 };
 
 type InstanceTypes = Record<string, Record<string, InstanceDetails>>;
+type AzureInstanceTypes = Record<string, InstanceDetails>;
 
 // use values from AWS as base constant, convert to MB
 export const AWS_INSTANCE_LIMITS: InstanceTypes = Object.freeze({
@@ -120,3 +121,15 @@ export const AWS_INSTANCE_LIMITS: InstanceTypes = Object.freeze({
     "standard-44": { vCPU: 44, RAM: 176 },
   },
 });
+
+// use values from Azure as base constant, convert to MB
+export const AZURE_INSTANCE_LIMITS: AzureInstanceTypes = Object.freeze({
+  Standard_B2als_v2: { vCPU: 2, RAM: 4 },
+  Standard_B2as_v2: { vCPU: 2, RAM: 8 },
+  Standard_B4als_v2: { vCPU: 4, RAM: 8 },
+  Standard_A2_v2: { vCPU: 2, RAM: 4 },
+  Standard_A4_v2: { vCPU: 4, RAM: 8 },
+  Standard_DS1_v2: { vCPU: 1, RAM: 3.5 },
+  Standard_DS2_v2: { vCPU: 2, RAM: 7 },
+  Standard_D2ads_v5: { vCPU: 2, RAM: 8 },
+});

+ 1 - 1
dashboard/src/main/home/sidebar/ProjectButton.tsx

@@ -62,7 +62,7 @@ const ProjectButton: React.FC<PropsType> = (props) => {
 
         {(user.isPorterUser && currentProject.simplified_view_enabled) ?
           <Tooltip
-            content={`Porter Apps ${currentProject.validate_apply_v2 ? "V2" : "V1"}`}
+            content={`Porter Apps ${currentProject.validate_apply_v2 ? currentProject.beta_features_enabled ? "V2 (Update)" : "V2 (Apply)" : "V1"}`}
             position="right"
           >
             <MainSelector

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

@@ -1245,6 +1245,17 @@ const getAppTemplate = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/templates`;
 });
 
+const listLatestAddons = baseApi<
+{
+  deployment_target_id?: string;
+},
+{
+  projectId: number;
+  clusterId: number;
+}>("GET", ({ projectId, clusterId }) => {
+  return `/api/projects/${projectId}/clusters/${clusterId}/addons/latest`;
+})
+
 const getGitlabProcfileContents = baseApi<
   {
     path: string;
@@ -3447,6 +3458,7 @@ export default {
   createDeploymentTarget,
   getDeploymentTarget,
   getAppTemplate,
+  listLatestAddons,
   getGitlabProcfileContents,
   getProjectClusters,
   getProjectRegistries,

+ 1 - 1
dashboard/tsconfig.json

@@ -11,7 +11,7 @@
     "allowJs": true,
     "allowSyntheticDefaultImports": true,
     "removeComments": true,
-    "moduleResolution": "node",
+    "moduleResolution": "Bundler",
     "strict": true,
     "skipLibCheck": true
   },

+ 1 - 1
dashboard/webpack.config.js

@@ -88,7 +88,7 @@ module.exports = () => {
           ],
         },
         {
-          test: /\.mjs/,
+          test: /\.(mjs|js)$/,
           include: /node_modules/,
           type: "javascript/auto",
         },

+ 1 - 1
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.76
+	github.com/porter-dev/api-contracts v0.2.80
 	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

+ 2 - 2
go.sum

@@ -1520,8 +1520,8 @@ 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.76 h1:hQrtYKG7Z0JQlCXpmmXH+pNI2CcyXfdDpiH4VKSChw0=
-github.com/porter-dev/api-contracts v0.2.76/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.80 h1:Ufa0d64kO+XhrZcANje2vltCbgn7WzsIafo3p3cu+jE=
+github.com/porter-dev/api-contracts v0.2.80/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=

+ 2 - 0
go.work.sum

@@ -845,6 +845,8 @@ github.com/porter-dev/api-contracts v0.2.68 h1:OeU3RQAI6IpGC99UdDalrlRnNn7nevoxj
 github.com/porter-dev/api-contracts v0.2.68/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/api-contracts v0.2.73 h1:hsFcJSf0HLxS7VgV36qn5X3tYPzWG48mCvHwuOlU2eE=
 github.com/porter-dev/api-contracts v0.2.73/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.78 h1:Iyp1DL33mPxJZQSjH8W/ylv5Ch8i30eJJx9mvhZmhTU=
+github.com/porter-dev/api-contracts v0.2.78/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.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo=

+ 2 - 0
internal/models/app_revision.go

@@ -45,6 +45,8 @@ const (
 	AppRevisionStatus_DeploymentSuccessful AppRevisionStatus = "DEPLOYMENT_SUCCESSFUL"
 	// AppRevisionStatus_DeploymentFailed is the status for a revision that failed to deploy
 	AppRevisionStatus_DeploymentFailed AppRevisionStatus = "DEPLOYMENT_FAILED"
+	// AppRevisionStatus_DeploymentSuperseded is the status for a revision that was superseded by a newer revision
+	AppRevisionStatus_DeploymentSuperseded AppRevisionStatus = "DEPLOYMENT_SUPERSEDED"
 	// AppRevisionStatus_RollbackSuccessful is the status for a revision that successfully rolled back
 	AppRevisionStatus_RollbackSuccessful AppRevisionStatus = "ROLLBACK_SUCCESSFUL"
 	// AppRevisionStatus_RollbackFailed is the status for a revision that failed to rollback

+ 2 - 0
internal/porter_app/revisions.go

@@ -273,6 +273,8 @@ func appRevisionStatusFromProto(status string) (models.AppRevisionStatus, error)
 		appRevisionStatus = models.AppRevisionStatus_DeploymentSuccessful
 	case string(models.AppRevisionStatus_DeploymentFailed):
 		appRevisionStatus = models.AppRevisionStatus_DeploymentFailed
+	case string(models.AppRevisionStatus_DeploymentSuperseded):
+		appRevisionStatus = models.AppRevisionStatus_DeploymentSuperseded
 	case string(models.AppRevisionStatus_RollbackSuccessful):
 		appRevisionStatus = models.AppRevisionStatus_RollbackSuccessful
 	case string(models.AppRevisionStatus_RollbackFailed):

+ 17 - 0
internal/porter_app/v2/addons.go

@@ -29,6 +29,13 @@ func ProtoFromAddon(ctx context.Context, addon Addon) (*porterv1.Addon, error) {
 		addonProto.Config = &porterv1.Addon_Postgres{
 			Postgres: postgres,
 		}
+	case porterv1.AddonType_ADDON_TYPE_REDIS:
+		addonProto.Type = addonType
+		redis := redisConfigProtoFromAddon(addon)
+
+		addonProto.Config = &porterv1.Addon_Redis{
+			Redis: redis,
+		}
 	default:
 		return addonProto, telemetry.Error(ctx, span, nil, "specified addon type not supported")
 	}
@@ -56,6 +63,8 @@ func addonEnumProtoFromType(ctx context.Context, addonType string) (porterv1.Add
 	switch addonType {
 	case "postgres":
 		addonTypeEnum = porterv1.AddonType_ADDON_TYPE_POSTGRES
+	case "redis":
+		addonTypeEnum = porterv1.AddonType_ADDON_TYPE_REDIS
 	default:
 		return addonTypeEnum, telemetry.Error(ctx, span, nil, "invalid addon type")
 	}
@@ -70,3 +79,11 @@ func postgresConfigProtoFromAddon(addon Addon) *porterv1.Postgres {
 		StorageGigabytes: int32(addon.StorageGigabytes),
 	}
 }
+
+func redisConfigProtoFromAddon(addon Addon) *porterv1.Redis {
+	return &porterv1.Redis{
+		RamMegabytes:     int32(addon.RamMegabytes),
+		CpuCores:         addon.CpuCores,
+		StorageGigabytes: int32(addon.StorageGigabytes),
+	}
+}