浏览代码

merge confl

jusrhee 2 年之前
父节点
当前提交
2fc9436a6b

+ 138 - 0
api/server/handlers/cloud_provider/machines.go

@@ -0,0 +1,138 @@
+package cloud_provider
+
+import (
+	"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"
+)
+
+// CloudProviderMachineTypesHandler checks for available machine types for a given cloud provider, account and region
+type CloudProviderMachineTypesHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewCloudProviderMachineTypesHandler constructs a CloudProviderMachineTypesHandler
+func NewCloudProviderMachineTypesHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CloudProviderMachineTypesHandler {
+	return &CloudProviderMachineTypesHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// CloudProviderMachineTypesRequest is the request object for the CloudProviderMachineTypesHandler
+type CloudProviderMachineTypesRequest struct {
+	CloudProvider                     string `schema:"cloud_provider"`
+	CloudProviderCredentialIdentifier string `schema:"cloud_provider_credential_identifier"`
+	Region                            string `schema:"region"`
+}
+
+// CloudProviderMachineTypesResponse is the response object for the CloudProviderMachineTypesHandler
+type CloudProviderMachineTypesResponse struct {
+	MachineTypes            []MachineType `json:"machine_types"`
+	UnsupportedMachineTypes []MachineType `json:"unsupported_machine_types"`
+}
+
+// MachineType represents a machine type
+type MachineType struct {
+	Name string `json:"name"`
+}
+
+// ServeHTTP handles the cloud provider machine types request
+func (c *CloudProviderMachineTypesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-cloud-provider-machines")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	request := &CloudProviderMachineTypesRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "unable to decode and validate request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	var resp CloudProviderMachineTypesResponse
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "cloud-provider", Value: request.CloudProvider},
+		telemetry.AttributeKV{Key: "region", Value: request.Region},
+		telemetry.AttributeKV{Key: "cloud-provider-credential-identifier", Value: request.CloudProviderCredentialIdentifier},
+	)
+
+	if request.CloudProvider == "" {
+		err := telemetry.Error(ctx, span, nil, "cloud provider is required")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	if request.Region == "" {
+		err := telemetry.Error(ctx, span, nil, "region is required")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	if request.CloudProviderCredentialIdentifier == "" {
+		err := telemetry.Error(ctx, span, nil, "cloud provider credentials id is required")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	req := porterv1.MachineTypesRequest{
+		ProjectId:                  int64(project.ID),
+		CloudProvider:              translateCloudProvider(request.CloudProvider),
+		CloudProviderCredentialsId: request.CloudProviderCredentialIdentifier,
+		Region:                     request.Region,
+	}
+
+	machineTypesResp, err := c.Config().ClusterControlPlaneClient.MachineTypes(ctx, connect.NewRequest(&req))
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error getting machine types")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if machineTypesResp.Msg == nil {
+		err = telemetry.Error(ctx, span, nil, "no message received from machine types")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	for _, machineType := range machineTypesResp.Msg.MachineTypes {
+		resp.MachineTypes = append(resp.MachineTypes, MachineType{
+			Name: machineType.Name,
+		})
+	}
+	for _, machineType := range machineTypesResp.Msg.UnsupportedMachineTypes {
+		resp.UnsupportedMachineTypes = append(resp.UnsupportedMachineTypes, MachineType{
+			Name: machineType.Name,
+		})
+	}
+
+	c.WriteResult(w, r, resp)
+}
+
+var cloudProviderTranslator = map[string]porterv1.EnumCloudProvider{
+	"aws":   porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_AWS,
+	"azure": porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_AZURE,
+	"gcp":   porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_GCP,
+}
+
+func translateCloudProvider(cloudProvider string) porterv1.EnumCloudProvider {
+	if val, ok := cloudProviderTranslator[strings.ToLower(cloudProvider)]; ok {
+		return val
+	}
+
+	return porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_UNSPECIFIED
+}

+ 37 - 372
api/server/handlers/porter_app/create_and_update_events.go

@@ -2,13 +2,12 @@ package porter_app
 
 import (
 	"context"
-	"encoding/json"
 	"fmt"
 	"net/http"
 	"strings"
-	"time"
 
 	"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"
@@ -74,6 +73,42 @@ func (p *CreateUpdatePorterAppEventHandler) ServeHTTP(w http.ResponseWriter, r *
 		reportBuildStatus(ctx, request, p.Config(), user, project, appName, validateApplyV2)
 	}
 
+	// if sandbox, reroute the event to the hosted project and cluster ids
+	if p.Config().ServerConf.EnableSandbox {
+		deploymentTarget, err := p.Repo().DeploymentTarget().DeploymentTargetById(request.DeploymentTargetID)
+		if err != nil {
+			e := telemetry.Error(ctx, span, err, "error getting deployment target by id")
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusInternalServerError))
+			return
+		}
+
+		telemetry.WithAttributes(span,
+			telemetry.AttributeKV{Key: "deployment-target-id", Value: deploymentTarget.ID},
+			telemetry.AttributeKV{Key: "hosted-project-id", Value: deploymentTarget.ProjectID},
+			telemetry.AttributeKV{Key: "hosted-cluster-id", Value: deploymentTarget.ClusterID},
+		)
+
+		project, err = p.Repo().Project().ReadProject(uint(deploymentTarget.ProjectID))
+		if err != nil {
+			e := telemetry.Error(ctx, span, err, "error reading project")
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusInternalServerError))
+			return
+		}
+
+		if !project.EnableSandbox {
+			e := telemetry.Error(ctx, span, nil, "project does not have sandbox enabled")
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+			return
+		}
+
+		cluster, err = p.Repo().Cluster().ReadCluster(project.ID, uint(deploymentTarget.ClusterID))
+		if err != nil {
+			e := telemetry.Error(ctx, span, err, "error reading cluster")
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusInternalServerError))
+			return
+		}
+	}
+
 	var event types.PorterAppEvent
 	var err error
 	if request.ID == "" { // no event id provided, so create a new event/notification
@@ -85,13 +120,6 @@ func (p *CreateUpdatePorterAppEventHandler) ServeHTTP(w http.ResponseWriter, r *
 				p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusInternalServerError))
 				return
 			}
-		} else {
-			event, err = p.createNewAppEvent(ctx, *project, *cluster, appName, request.DeploymentTargetID, request.Status, string(request.Type), request.TypeExternalSource, request.Metadata)
-			if err != nil {
-				e := telemetry.Error(ctx, span, err, "error creating new app event")
-				p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
-				return
-			}
 		}
 	} else { // event id provided, so update an existing event matching that event
 		event, err = p.updateExistingAppEvent(ctx, *cluster, appName, *request)
@@ -137,128 +165,6 @@ func reportBuildStatus(ctx context.Context, request *types.CreateOrUpdatePorterA
 	_ = TrackStackBuildStatus(ctx, config, user, project, stackName, errStr, request.Status, validateApplyV2, buildLogs)
 }
 
-// createNewAppEvent will create a new app event for the given porter app name. If the app event is an agent event, then it will be created only if there is no existing event which has the agent ID. In the case that an existing event is found, that will be returned instead
-func (p *CreateUpdatePorterAppEventHandler) createNewAppEvent(ctx context.Context, project models.Project, cluster models.Cluster, porterAppName string, deploymentTargetID string, status types.PorterAppEventStatus, eventType string, externalSource string, requestMetadata map[string]any) (types.PorterAppEvent, error) {
-	ctx, span := telemetry.NewSpan(ctx, "create-porter-app-event")
-	defer span.End()
-
-	app, err := p.Repo().PorterApp().ReadPorterAppByName(cluster.ID, porterAppName)
-	if err != nil {
-		return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error retrieving porter app by name for cluster")
-	}
-	if app == nil || app.ID == 0 {
-		return types.PorterAppEvent{}, telemetry.Error(ctx, span, nil, "porter app not found")
-	}
-	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "porter-app-id", Value: app.ID},
-		telemetry.AttributeKV{Key: "porter-app-name", Value: porterAppName},
-		telemetry.AttributeKV{Key: "cluster-id", Value: int(cluster.ID)},
-		telemetry.AttributeKV{Key: "project-id", Value: int(cluster.ProjectID)},
-	)
-
-	// this branch can be safely removed once v1 is deprecated
-	if eventType == string(types.PorterAppEventType_AppEvent) {
-		// Agent has no way to know what the porter app event id is, so if we must dedup here
-		if agentEventID, ok := requestMetadata["agent_event_id"]; ok {
-			var existingEvents []*models.PorterAppEvent
-			existingEvents, _, err = p.Repo().PorterAppEvent().ListEventsByPorterAppID(ctx, app.ID)
-			if err != nil {
-				return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error listing porter app events for event type")
-			}
-
-			for _, existingEvent := range existingEvents {
-				if existingEvent != nil && existingEvent.Type == eventType {
-					existingAgentEventID, ok := existingEvent.Metadata["agent_event_id"]
-					if !ok {
-						continue
-					}
-					if existingAgentEventID == 0 {
-						continue
-					}
-					if existingAgentEventID == agentEventID {
-						return existingEvent.ToPorterAppEvent(), nil
-					}
-				}
-			}
-		}
-	}
-
-	if eventType == string(types.PorterAppEventType_Deploy) {
-		// Agent has no way to know what the porter app event id is, so update the deploy event if it exists
-		if _, ok := requestMetadata["deploy_status"]; ok {
-			if deploymentTargetID == "" {
-				event, err := p.updateDeployEventV1(ctx, porterAppName, app.ID, requestMetadata)
-				if err != nil {
-					return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error updating v1 deploy event")
-				}
-				return event, nil
-			} else {
-				// v2 handles its own deploy events
-				return types.PorterAppEvent{}, nil
-			}
-		}
-	}
-
-	event := models.PorterAppEvent{
-		ID:                 uuid.New(),
-		Status:             string(status),
-		Type:               eventType,
-		TypeExternalSource: externalSource,
-		PorterAppID:        app.ID,
-		Metadata:           make(map[string]any),
-	}
-
-	if deploymentTargetID != "" {
-		deploymentTargetUUID, err := uuid.Parse(deploymentTargetID)
-		if err != nil {
-			return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error parsing deployment target id")
-		}
-		if deploymentTargetUUID == uuid.Nil {
-			return types.PorterAppEvent{}, telemetry.Error(ctx, span, nil, "deployment target id cannot be nil")
-		}
-
-		// hacky way to get the app instance id into the build event
-		revision, err := p.Config().ClusterControlPlaneClient.CurrentAppRevision(ctx, connect.NewRequest(&porterv1.CurrentAppRevisionRequest{
-			ProjectId:          int64(cluster.ProjectID),
-			AppId:              int64(app.ID),
-			DeploymentTargetId: deploymentTargetID,
-			AppName:            porterAppName,
-		}))
-		if err != nil {
-			return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error getting current app revision from cluster control plane client")
-		}
-		if revision.Msg.AppRevision == nil {
-			return types.PorterAppEvent{}, telemetry.Error(ctx, span, nil, "app revision is nil")
-		}
-
-		appInstanceUUID, err := uuid.Parse(revision.Msg.AppRevision.AppInstanceId)
-		if err != nil {
-			return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error parsing app instance id")
-		}
-		if appInstanceUUID == uuid.Nil {
-			return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "app instance id cannot be nil")
-		}
-
-		event.DeploymentTargetID = deploymentTargetUUID
-		event.AppInstanceID = appInstanceUUID
-	}
-
-	for k, v := range requestMetadata {
-		event.Metadata[k] = v
-	}
-
-	err = p.Repo().PorterAppEvent().CreateEvent(ctx, &event)
-	if err != nil {
-		return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error creating porter app event")
-	}
-
-	if event.ID == uuid.Nil {
-		return types.PorterAppEvent{}, telemetry.Error(ctx, span, nil, "porter app event not found")
-	}
-
-	return event.ToPorterAppEvent(), nil
-}
-
 func (p *CreateUpdatePorterAppEventHandler) updateExistingAppEvent(ctx context.Context, cluster models.Cluster, porterAppName string, submittedEvent types.CreateOrUpdatePorterAppEventRequest) (types.PorterAppEvent, error) {
 	ctx, span := telemetry.NewSpan(ctx, "update-porter-app-event")
 	defer span.End()
@@ -300,247 +206,6 @@ func (p *CreateUpdatePorterAppEventHandler) updateExistingAppEvent(ctx context.C
 	return existingAppEvent.ToPorterAppEvent(), nil
 }
 
-// updateDeployEventV1 attempts to update the deploy event with the deploy status of each service given in updatedStatusMetadata
-// an update is only made in the following cases:
-// 1. the deploy event is found
-// 2. the deploy event is in the PROGRESSING state
-// 3. the deploy event service deployment metadata is formatted correctly
-// 4. the services specified in the updatedStatusMetadata match the services in the deploy event metadata
-// 5. some of the above services are still in the PROGRESSING state
-// if one of these conditions is not met, then an empty event is returned and no update is made; otherwise, the matched event is returned
-func (p *CreateUpdatePorterAppEventHandler) updateDeployEventV1(ctx context.Context, appName string, appID uint, updatedStatusMetadata map[string]any) (types.PorterAppEvent, error) {
-	ctx, span := telemetry.NewSpan(ctx, "update-deploy-event")
-	defer span.End()
-
-	revision, ok := updatedStatusMetadata["revision"]
-	if !ok {
-		return types.PorterAppEvent{}, telemetry.Error(ctx, span, nil, "revision not found in request metadata")
-	}
-	revisionFloat64, ok := revision.(float64)
-	if !ok {
-		return types.PorterAppEvent{}, telemetry.Error(ctx, span, nil, "revision not a float64")
-	}
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "revision", Value: revisionFloat64})
-
-	podName, ok := updatedStatusMetadata["pod_name"]
-	if !ok {
-		return types.PorterAppEvent{}, telemetry.Error(ctx, span, nil, "pod name not found in request metadata")
-	}
-	podNameStr, ok := podName.(string)
-	if !ok {
-		return types.PorterAppEvent{}, telemetry.Error(ctx, span, nil, "pod name not a string")
-	}
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "pod-name", Value: podNameStr})
-
-	serviceName := getServiceNameFromPodName(podNameStr, appName)
-	if serviceName == "" {
-		return types.PorterAppEvent{}, telemetry.Error(ctx, span, nil, "service name not found in pod name")
-	}
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "service-name", Value: serviceName})
-
-	var err error
-	matchEvent, err := p.Repo().PorterAppEvent().ReadDeployEventByRevision(ctx, appID, revisionFloat64)
-	if err != nil {
-		return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "error finding matching deploy event")
-	}
-
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "updating-deployment-event", Value: false})
-
-	newStatus, ok := updatedStatusMetadata["deploy_status"]
-	if !ok {
-		return types.PorterAppEvent{}, telemetry.Error(ctx, span, nil, "deploy status not found in request metadata")
-	}
-	newStatusStr, ok := newStatus.(string)
-	if !ok {
-		return types.PorterAppEvent{}, telemetry.Error(ctx, span, nil, "deploy status not a string")
-	}
-	var porterAppEventStatus types.PorterAppEventStatus
-	switch newStatusStr {
-	case string(types.PorterAppEventStatus_Success):
-		porterAppEventStatus = types.PorterAppEventStatus_Success
-	case string(types.PorterAppEventStatus_Failed):
-		porterAppEventStatus = types.PorterAppEventStatus_Failed
-	case string(types.PorterAppEventStatus_Progressing):
-		porterAppEventStatus = types.PorterAppEventStatus_Progressing
-	default:
-		return types.PorterAppEvent{}, telemetry.Error(ctx, span, nil, "deploy status not valid")
-	}
-
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "new-status", Value: string(porterAppEventStatus)})
-
-	// first check to see if the event is empty, meaning there was no match found, or not progressing, meaning it has already been updated
-	if matchEvent.ID == uuid.Nil || matchEvent.Status != string(types.PorterAppEventStatus_Progressing) {
-		return types.PorterAppEvent{}, nil
-	}
-
-	serviceStatus, ok := matchEvent.Metadata["service_deployment_metadata"]
-	if !ok {
-		return types.PorterAppEvent{}, telemetry.Error(ctx, span, nil, "service deployment metadata not found in deploy event metadata")
-	}
-	serviceDeploymentGenericMap, ok := serviceStatus.(map[string]interface{})
-	if !ok {
-		return types.PorterAppEvent{}, telemetry.Error(ctx, span, nil, "service deployment metadata is not map[string]interface{}")
-	}
-	serviceDeploymentMap := make(map[string]types.ServiceDeploymentMetadata)
-	for k, v := range serviceDeploymentGenericMap {
-		by, err := json.Marshal(v)
-		if err != nil {
-			return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "unable to marshal")
-		}
-
-		var serviceDeploymentMetadata types.ServiceDeploymentMetadata
-		err = json.Unmarshal(by, &serviceDeploymentMetadata)
-		if err != nil {
-			return types.PorterAppEvent{}, telemetry.Error(ctx, span, err, "unable to unmarshal")
-		}
-		serviceDeploymentMap[k] = serviceDeploymentMetadata
-	}
-	serviceDeploymentMetadata, ok := serviceDeploymentMap[serviceName]
-	if !ok {
-		return types.PorterAppEvent{}, telemetry.Error(ctx, span, nil, "deployment metadata not found for service")
-	}
-
-	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "existing-status", Value: serviceDeploymentMetadata.Status})
-
-	// only update service status if it has not been updated yet
-	if serviceDeploymentMetadata.Status == types.PorterAppEventStatus_Progressing {
-		// update the map with the new status
-		serviceDeploymentMetadata.Status = porterAppEventStatus
-		serviceDeploymentMap[serviceName] = serviceDeploymentMetadata
-
-		// update the deploy event with new map and status if all services are done
-		// note: this assumes that all services are reported 'done' sequentially
-		// if two service statuses are updated at the same time, we might miss updating the parent deploy event
-		matchEvent.Metadata["service_deployment_metadata"] = serviceDeploymentMap
-		allServicesDone := true
-		anyServicesFailed := false
-		for _, deploymentMetadata := range serviceDeploymentMap {
-			if deploymentMetadata.Status == types.PorterAppEventStatus_Progressing {
-				allServicesDone = false
-				break
-			}
-			if deploymentMetadata.Status == types.PorterAppEventStatus_Failed {
-				anyServicesFailed = true
-			}
-		}
-		if allServicesDone {
-			matchEvent.Metadata["end_time"] = time.Now().UTC()
-			if anyServicesFailed {
-				matchEvent.Status = string(types.PorterAppEventStatus_Failed)
-			} else {
-				matchEvent.Status = string(types.PorterAppEventStatus_Success)
-			}
-		}
-
-		err := p.Repo().PorterAppEvent().UpdateEvent(ctx, &matchEvent)
-		if err != nil {
-			_ = telemetry.Error(ctx, span, err, "error updating deploy event")
-			return matchEvent.ToPorterAppEvent(), nil
-		}
-		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "updating-deployment-event", Value: true})
-		return matchEvent.ToPorterAppEvent(), nil
-	}
-
-	return types.PorterAppEvent{}, nil
-}
-
-type updateDeployEventV2Input struct {
-	projectID             uint
-	appName               string
-	appID                 uint
-	deploymentTargetID    string
-	updatedStatusMetadata map[string]any
-}
-
-func (p *CreateUpdatePorterAppEventHandler) updateDeployEventV2(
-	ctx context.Context,
-	inp updateDeployEventV2Input,
-) error {
-	ctx, span := telemetry.NewSpan(ctx, "update-deploy-event-v2")
-	defer span.End()
-
-	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "app-name", Value: inp.appName},
-		telemetry.AttributeKV{Key: "app-id", Value: inp.appID},
-		telemetry.AttributeKV{Key: "deployment-target-id", Value: inp.deploymentTargetID},
-		telemetry.AttributeKV{Key: "project-id", Value: int(inp.projectID)},
-	)
-
-	agentEventMetadata, err := notifications.ParseAgentEventMetadata(inp.updatedStatusMetadata)
-	if err != nil {
-		return telemetry.Error(ctx, span, err, "failed to unmarshal agent event metadata")
-	}
-	if agentEventMetadata == nil {
-		return telemetry.Error(ctx, span, nil, "agent event metadata is nil")
-	}
-
-	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "app-revision-id", Value: agentEventMetadata.AppRevisionID},
-		telemetry.AttributeKV{Key: "service-name", Value: agentEventMetadata.ServiceName},
-		telemetry.AttributeKV{Key: "deployment-status", Value: agentEventMetadata.DeployStatus},
-	)
-	var deploymentStatus porterv1.EnumServiceDeploymentStatus
-	switch agentEventMetadata.DeployStatus {
-	case types.PorterAppEventStatus_Success:
-		deploymentStatus = porterv1.EnumServiceDeploymentStatus_ENUM_SERVICE_DEPLOYMENT_STATUS_SUCCESS
-	case types.PorterAppEventStatus_Failed:
-		deploymentStatus = porterv1.EnumServiceDeploymentStatus_ENUM_SERVICE_DEPLOYMENT_STATUS_FAILED
-	case types.PorterAppEventStatus_Progressing:
-		deploymentStatus = porterv1.EnumServiceDeploymentStatus_ENUM_SERVICE_DEPLOYMENT_STATUS_PROGRESSING
-	default:
-		return telemetry.Error(ctx, span, nil, "deployment status not valid")
-	}
-
-	updateRequest := connect.NewRequest(&porterv1.UpdateServiceDeploymentStatusRequest{
-		ProjectId: int64(inp.projectID),
-		DeploymentTargetIdentifier: &porterv1.DeploymentTargetIdentifier{
-			Id: inp.deploymentTargetID,
-		},
-		AppName:       inp.appName,
-		AppRevisionId: agentEventMetadata.AppRevisionID,
-		ServiceName:   agentEventMetadata.ServiceName,
-		Status:        deploymentStatus,
-	})
-
-	_, err = p.Config().ClusterControlPlaneClient.UpdateServiceDeploymentStatus(ctx, updateRequest)
-	if err != nil {
-		return telemetry.Error(ctx, span, err, "error updating service deployment status")
-	}
-
-	return nil
-}
-
-func getServiceNameFromPodName(podName, porterAppName string) string {
-	prefix := porterAppName + "-"
-	if !strings.HasPrefix(podName, prefix) {
-		return ""
-	}
-
-	podName = strings.TrimPrefix(podName, prefix)
-	suffixes := []string{"-web", "-wkr", "-job"}
-	index := -1
-
-	for _, suffix := range suffixes {
-		newIndex := strings.LastIndex(podName, suffix)
-		if newIndex > index {
-			index = newIndex
-		}
-	}
-
-	if index != -1 {
-		return podName[:index]
-	}
-
-	// if the suffix wasn't found, it's possible that the service name was too long to keep the entire suffix. example: postgres-snowflake-connector-postgres-snowflake-service-wk8gnst
-	// if this is the case, find the service name by removing everything after the last dash
-	index = strings.LastIndex(podName, "-")
-	if index != -1 {
-		return podName[:index]
-	}
-
-	return ""
-}
-
 // handleNotification handles all logic for notifications in app v2
 func (p *CreateUpdatePorterAppEventHandler) handleNotification(ctx context.Context,
 	request *types.CreateOrUpdatePorterAppEventRequest,

+ 1 - 1
api/server/handlers/project/invite_admin.go

@@ -56,7 +56,7 @@ func (p *ProjectInviteAdminHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 			Role: types.Role{
 				UserID:    adminUserId,
 				ProjectID: proj.ID,
-				Kind:      types.RoleAdmin,
+				Kind:      types.RoleViewer,
 			},
 		})
 	}

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

@@ -3,6 +3,8 @@ package router
 import (
 	"fmt"
 
+	"github.com/porter-dev/porter/api/server/handlers/cloud_provider"
+
 	"github.com/porter-dev/porter/api/server/handlers/deployment_target"
 
 	"github.com/go-chi/chi/v5"
@@ -1825,6 +1827,34 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/cloud/machines -> apiContract.NewCloudProviderMachineTypesHandler
+	machineTypeEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/cloud/machines", relPath),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	machineTypeHandler := cloud_provider.NewCloudProviderMachineTypesHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: machineTypeEndpoint,
+		Handler:  machineTypeHandler,
+		Router:   r,
+	})
+
 	// POST /api/projects/{project_id}/rename -> cluster.newRenamProject
 	renameProjectEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 0 - 2
cli/cmd/commands/all.go

@@ -28,7 +28,6 @@ func RegisterCommands() (*cobra.Command, error) {
 	rootCmd.AddCommand(registerCommand_Auth(cliConf))
 	rootCmd.AddCommand(registerCommand_Cluster(cliConf))
 	rootCmd.AddCommand(registerCommand_Config(cliConf))
-	rootCmd.AddCommand(registerCommand_Connect(cliConf))
 	rootCmd.AddCommand(registerCommand_Create(cliConf))
 	rootCmd.AddCommand(registerCommand_Delete(cliConf))
 	rootCmd.AddCommand(registerCommand_Deploy(cliConf))
@@ -40,7 +39,6 @@ func RegisterCommands() (*cobra.Command, error) {
 	rootCmd.AddCommand(registerCommand_List(cliConf))
 	rootCmd.AddCommand(registerCommand_Logs(cliConf))
 	rootCmd.AddCommand(registerCommand_Open(cliConf))
-	rootCmd.AddCommand(registerCommand_PortForward(cliConf))
 	rootCmd.AddCommand(registerCommand_Project(cliConf))
 	rootCmd.AddCommand(registerCommand_Registry(cliConf))
 	rootCmd.AddCommand(registerCommand_Run(cliConf))

+ 15 - 3
cli/cmd/commands/helm.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"os"
 	"os/exec"
+	"strings"
 
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/api/types"
@@ -13,9 +14,15 @@ import (
 )
 
 func registerCommand_Helm(cliConf config.CLIConfig) *cobra.Command {
+	depMsg := `This command is no longer available. Please consult documentation of the respective cloud provider to get access to the kubeconfig of the cluster. 
+	Note that any change made directly on the kubernetes cluster under the hood can degrade the performance and reliability of the cluster, and Porter will 
+	automatically reconcile any changes that pose a threat to the uptime of the cluster to its original state. Porter is not responsible for the issues that 
+	arise due to the change implemented directly on the Kubernetes cluster via kubectl.`
+
 	helmCmd := &cobra.Command{
-		Use:   "helm",
-		Short: "Use helm to interact with a Porter cluster",
+		Use:        "helm",
+		Short:      "Use helm to interact with a Porter cluster",
+		Deprecated: depMsg,
 		Run: func(cmd *cobra.Command, args []string) {
 			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runHelm)
 			if err != nil {
@@ -28,6 +35,12 @@ func registerCommand_Helm(cliConf config.CLIConfig) *cobra.Command {
 }
 
 func runHelm(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, cmd *cobra.Command, args []string) error {
+	// this will never error because it just ran
+	user, _ := client.AuthCheck(ctx)
+	if !strings.HasSuffix(user.Email, "@porter.run") {
+		return fmt.Errorf("Forbidden")
+	}
+
 	_, err := exec.LookPath("helm")
 	if err != nil {
 		return fmt.Errorf("error finding helm: %w", err)
@@ -51,7 +64,6 @@ func runHelm(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client
 	execCommand.Stderr = os.Stderr
 
 	err = execCommand.Run()
-
 	if err != nil {
 		return fmt.Errorf("error running helm: %w", err)
 	}

+ 15 - 3
cli/cmd/commands/kubectl.go

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"os"
 	"os/exec"
+	"strings"
 
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/api/types"
@@ -13,9 +14,15 @@ import (
 )
 
 func registerCommand_Kubectl(cliConf config.CLIConfig) *cobra.Command {
+	depMsg := `This command is no longer available. Please consult documentation of the respective cloud provider to get access to the kubeconfig of the cluster. 
+	Note that any change made directly on the kubernetes cluster under the hood can degrade the performance and reliability of the cluster, and Porter will 
+	automatically reconcile any changes that pose a threat to the uptime of the cluster to its original state. Porter is not responsible for the issues that 
+	arise due to the change implemented directly on the Kubernetes cluster via kubectl.`
+
 	kubectlCmd := &cobra.Command{
-		Use:   "kubectl",
-		Short: "Use kubectl to interact with a Porter cluster",
+		Use:        "kubectl",
+		Short:      "Use kubectl to interact with a Porter cluster",
+		Deprecated: depMsg,
 		Run: func(cmd *cobra.Command, args []string) {
 			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runKubectl)
 			if err != nil {
@@ -29,6 +36,12 @@ func registerCommand_Kubectl(cliConf config.CLIConfig) *cobra.Command {
 }
 
 func runKubectl(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client api.Client, cliConf config.CLIConfig, featureFlags config.FeatureFlags, cmd *cobra.Command, args []string) error {
+	// this will never error because it just ran
+	user, _ := client.AuthCheck(ctx)
+	if !strings.HasSuffix(user.Email, "@porter.run") {
+		return fmt.Errorf("Forbidden")
+	}
+
 	_, err := exec.LookPath("kubectl")
 	if err != nil {
 		return fmt.Errorf("error finding kubectl: %w", err)
@@ -90,7 +103,6 @@ func downloadTempKubeconfig(ctx context.Context, client api.Client, cliConf conf
 	}
 
 	_, err = tmpFile.Write(resp.Kubeconfig)
-
 	if err != nil {
 		return "", fmt.Errorf("error writing kubeconfig to temp file: %w", err)
 	}

+ 0 - 21
cli/cmd/commands/portforward.go

@@ -1,21 +0,0 @@
-package commands
-
-import (
-	"fmt"
-
-	"github.com/fatih/color"
-	"github.com/porter-dev/porter/cli/cmd/config"
-	"github.com/spf13/cobra"
-)
-
-func registerCommand_PortForward(_ config.CLIConfig) *cobra.Command {
-	portForwardCmd := &cobra.Command{
-		Use: "port-forward [release] [LOCAL_PORT:]REMOTE_PORT [...[LOCAL_PORT_N:]REMOTE_PORT_N]",
-		Deprecated: fmt.Sprintf("please use the %s command instead.",
-			color.New(color.FgYellow, color.Bold).Sprintf("porter kubectl -- port-forward"),
-		),
-		DisableFlagParsing: true,
-	}
-
-	return portForwardCmd
-}

+ 21 - 12
cli/cmd/v2/app_logs.go

@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"os"
 	"os/signal"
+	"strings"
 	"syscall"
 	"time"
 
@@ -28,6 +29,11 @@ type AppLogsInput struct {
 	ServiceName string
 }
 
+// LogLine represents a single line of log output
+type LogLine struct {
+	Line string `json:"line"`
+}
+
 // ServiceName_AllServices is a special value for ServiceName that indicates all services should be included
 const ServiceName_AllServices = "all"
 
@@ -71,27 +77,30 @@ func AppLogs(ctx context.Context, inp AppLogsInput) error {
 		case <-ctx.Done():
 			return ctx.Err()
 		default:
-			_, message, _ := conn.ReadMessage()
+			_, message, err := conn.ReadMessage()
 			if err != nil {
-				return err
+				return fmt.Errorf("error reading message from app logs stream: %w", err)
 			}
 			if len(message) == 0 {
 				return nil
 			}
 
-			var line struct {
-				Line string `json:"line"`
-			}
+			lines := strings.Split(string(message), "\n")
+			for _, l := range lines {
+				var line LogLine
 
-			err = json.Unmarshal(message, &line)
-			if err != nil {
-				return err
-			}
+				err = json.Unmarshal([]byte(l), &line)
+				if err != nil {
+					// silently fail in case output is not properly formatted
+					continue
+				}
 
-			message = append([]byte(line.Line), '\n')
-			if _, err = os.Stdout.Write(message); err != nil {
-				return nil
+				message = append([]byte(line.Line), '\n')
+				if _, err = os.Stdout.Write(message); err != nil {
+					return nil
+				}
 			}
+
 		}
 	}
 }

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

@@ -413,7 +413,7 @@ const nodeGroupTypeValidator = z.enum([
   "APPLICATION",
   "CUSTOM",
 ]);
-type NodeGroupType = z.infer<typeof nodeGroupTypeValidator>;
+export type NodeGroupType = z.infer<typeof nodeGroupTypeValidator>;
 const eksNodeGroupValidator = z.object({
   instanceType: z.string(),
   minInstances: z.number(),
@@ -611,3 +611,8 @@ export type UpdateClusterResponse =
       preflightChecks?: ClientPreflightCheck[];
       createContractResponse: CreateContractResponse;
     };
+
+export const machineTypeValidator = z.object({
+  name: z.string(),
+});
+export type MachineType = z.infer<typeof machineTypeValidator>;

+ 68 - 0
dashboard/src/lib/hooks/useNodeGroups.ts

@@ -0,0 +1,68 @@
+import { useQuery } from "@tanstack/react-query";
+
+import { useClusterFormContext } from "../../main/home/infrastructure-dashboard/ClusterFormContextProvider";
+import { CloudProviderAzure } from "../clusters/constants";
+import type {
+  AWSRegion,
+  AzureRegion,
+  ClientMachineType,
+  GCPRegion,
+} from "../clusters/types";
+
+type TUseMachineTypeList = {
+  machineTypes: ClientMachineType[];
+  isLoading: boolean;
+};
+export const useMachineTypeList = ({
+  cloudProvider,
+  cloudProviderCredentialIdentifier,
+  region,
+}: {
+  cloudProvider?: string;
+  cloudProviderCredentialIdentifier?: string;
+  region?: AWSRegion | GCPRegion | AzureRegion;
+}): TUseMachineTypeList => {
+  const { availableMachineTypes } = useClusterFormContext();
+
+  const { data: machineTypes = [], isLoading } = useQuery(
+    [
+      "availableMachineTypes",
+      region,
+      cloudProvider,
+      cloudProviderCredentialIdentifier,
+    ],
+    async () => {
+      if (!cloudProvider || !cloudProviderCredentialIdentifier || !region) {
+        return [];
+      }
+      try {
+        const machineTypes = await availableMachineTypes(
+          cloudProvider,
+          cloudProviderCredentialIdentifier,
+          region
+        );
+        const machineTypesNames = machineTypes.map(
+          (machineType) => machineType.name
+        );
+
+        return CloudProviderAzure.machineTypes.filter((mt) =>
+          machineTypesNames.includes(mt.name)
+        );
+      } catch (err) {
+        // fallback to default machine types if api call fails
+        return CloudProviderAzure.machineTypes.filter((mt) =>
+          mt.supportedRegions.includes(region)
+        );
+      }
+    },
+    {
+      enabled:
+        !!cloudProvider && !!cloudProviderCredentialIdentifier && !!region,
+    }
+  );
+
+  return {
+    machineTypes,
+    isLoading,
+  };
+};

+ 10 - 0
dashboard/src/lib/hooks/useStripe.tsx

@@ -313,10 +313,20 @@ export const useCustomerPlan = (): TGetPlan => {
   };
 };
 
+<<<<<<< HEAD
 export const useCustomerUsage = (windowSize: string, currentPeriod: boolean): TGetUsage => {
   const { currentProject } = useContext(Context);
 
   // Fetch current plan
+=======
+export const useCustomerUsage = (
+  windowSize: string,
+  currentPeriod: boolean
+): TGetUsage => {
+  const { currentProject } = useContext(Context);
+
+  // Fetch customer usage
+>>>>>>> 26d90f2aa267905d083cc670797d5d99d7f39d88
   const usageReq = useQuery(
     ["listCustomerUsage", currentProject?.id],
     async () => {

+ 34 - 0
dashboard/src/main/home/infrastructure-dashboard/ClusterFormContextProvider.tsx

@@ -6,12 +6,15 @@ import { FormProvider, useForm } from "react-hook-form";
 import { useHistory } from "react-router";
 import styled from "styled-components";
 import { match } from "ts-pattern";
+import { z } from "zod";
 
 import { Error as ErrorComponent } from "components/porter/Error";
 import {
   clusterContractValidator,
+  machineTypeValidator,
   type ClientClusterContract,
   type ClientPreflightCheck,
+  type MachineType,
   type UpdateClusterResponse,
 } from "lib/clusters/types";
 import {
@@ -42,6 +45,11 @@ type ClusterFormContextType = {
   submitAndPatchCheckSuggestions: (args: {
     preflightChecks: ClientPreflightCheck[];
   }) => Promise<void>;
+  availableMachineTypes: (
+    cloud_provider: string,
+    cloud_provider_credential_identifier: string,
+    region: string
+  ) => Promise<MachineType[]>;
 };
 
 const ClusterFormContext = createContext<ClusterFormContextType | null>(null);
@@ -217,6 +225,31 @@ const ClusterFormContextProvider: React.FC<ClusterFormContextProviderProps> = ({
     await handleClusterUpdate(data);
   });
 
+  const availableMachineTypes = async (
+    cloudProvider: string,
+    cloudProviderCredentialIdentifier: string,
+    region: string
+  ): Promise<MachineType[]> => {
+    const response = await api.cloudProviderMachineTypes(
+      "<token>",
+      {
+        cloud_provider: cloudProvider,
+        cloud_provider_credential_identifier: cloudProviderCredentialIdentifier,
+        region,
+      },
+      {
+        project_id: projectId,
+      }
+    );
+    const parsed = await z
+      .object({
+        machine_types: z.array(machineTypeValidator),
+      })
+      .parseAsync(response.data);
+
+    return parsed.machine_types;
+  };
+
   const submitSkippingPreflightChecks = async (): Promise<void> => {
     if (clusterForm.formState.isSubmitting) {
       return;
@@ -271,6 +304,7 @@ const ClusterFormContextProvider: React.FC<ClusterFormContextProviderProps> = ({
         isMultiClusterEnabled,
         submitSkippingPreflightChecks,
         submitAndPatchCheckSuggestions,
+        availableMachineTypes,
       }}
     >
       <Wrapper ref={scrollToTopRef}>

+ 5 - 1
dashboard/src/main/home/infrastructure-dashboard/ClusterSaveButton.tsx

@@ -5,12 +5,14 @@ import Button from "components/porter/Button";
 import { useClusterFormContext } from "./ClusterFormContextProvider";
 
 type Props = {
+  forceDisable?: boolean;
   height?: string;
   disabledTooltipPosition?: "top" | "bottom" | "left" | "right";
   isClusterUpdating?: boolean;
   children: React.ReactNode;
 };
 const ClusterSaveButton: React.FC<Props> = ({
+  forceDisable,
   height,
   disabledTooltipPosition,
   isClusterUpdating,
@@ -23,7 +25,9 @@ const ClusterSaveButton: React.FC<Props> = ({
       type="submit"
       status={updateClusterButtonProps.status}
       loadingText={updateClusterButtonProps.loadingText}
-      disabled={updateClusterButtonProps.isDisabled || isClusterUpdating}
+      disabled={
+        updateClusterButtonProps.isDisabled || isClusterUpdating || forceDisable
+      }
       disabledTooltipMessage={
         "Please wait for the current update to complete before updating again."
       }

+ 226 - 59
dashboard/src/main/home/infrastructure-dashboard/forms/azure/ConfigureAKSCluster.tsx

@@ -1,15 +1,24 @@
-import React, { useState } from "react";
+import React, { useContext, useEffect, useState } from "react";
 import { Controller, useFormContext } from "react-hook-form";
 
+import Loading from "components/Loading";
 import Container from "components/porter/Container";
 import { ControlledInput } from "components/porter/ControlledInput";
+import Error from "components/porter/Error";
 import Select from "components/porter/Select";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import VerticalSteps from "components/porter/VerticalSteps";
 import { CloudProviderAzure } from "lib/clusters/constants";
-import { type ClientClusterContract } from "lib/clusters/types";
+import type {
+  ClientClusterContract,
+  ClientMachineType,
+  NodeGroupType,
+} from "lib/clusters/types";
+import { useIntercom } from "lib/hooks/useIntercom";
+import { useMachineTypeList } from "lib/hooks/useNodeGroups";
 
+import { Context } from "shared/Context";
 import { valueExists } from "shared/util";
 
 import { useClusterFormContext } from "../../ClusterFormContextProvider";
@@ -23,10 +32,23 @@ type Props = {
 
 const ConfigureAKSCluster: React.FC<Props> = ({ goBack }) => {
   const [currentStep, _setCurrentStep] = useState<number>(100); // hack to show all steps
+  const [customSetupRequired, setCustomSetupRequired] =
+    useState<boolean>(false);
+  const { showIntercomWithMessage } = useIntercom();
+  const { user } = useContext(Context);
+
+  useEffect(() => {
+    if (customSetupRequired) {
+      showIntercomWithMessage({
+        message: "I need help configuring instance types for my Azure cluster.",
+      });
+    }
+  }, [customSetupRequired]);
 
   const {
     control,
     register,
+    setValue,
     formState: { errors },
     watch,
   } = useFormContext<ClientClusterContract>();
@@ -34,6 +56,123 @@ const ConfigureAKSCluster: React.FC<Props> = ({ goBack }) => {
   const { isMultiClusterEnabled } = useClusterFormContext();
 
   const region = watch("cluster.config.region");
+  const clusterId = watch("cluster.clusterId");
+  const nodeGroups = watch("cluster.config.nodeGroups");
+  const cloudProviderCredentialIdentifier = watch(
+    "cluster.cloudProviderCredentialsId"
+  );
+
+  const { machineTypes, isLoading: areMachineTypesLoading } =
+    useMachineTypeList({
+      cloudProvider: "azure",
+      cloudProviderCredentialIdentifier,
+      region,
+    });
+
+  const defaultNodeGroupType = (
+    nodeGroupType: NodeGroupType,
+    availableMachineTypes: ClientMachineType[]
+  ): { defaultType: string; notAvailable: boolean } => {
+    const availableNonGPUMachineTypes = availableMachineTypes
+      .filter((mt) => !mt.isGPU)
+      .map((mt) => mt.name.toString());
+    const availableGPUMachineTypes = availableMachineTypes
+      .filter((mt) => mt.isGPU)
+      .map((mt) => mt.name.toString());
+
+    const defaultMachineTypes: Record<
+      NodeGroupType,
+      {
+        defaultTypes: string[];
+        fallback: boolean; // if true, will fallback to first available machine type if no default machine types are available; if false, will require custom setup
+      }
+    > = {
+      APPLICATION: {
+        defaultTypes: ["Standard_B2als_v2", "Standard_A2_v2"],
+        fallback: true,
+      },
+      SYSTEM: {
+        defaultTypes: ["Standard_B2als_v2", "Standard_A2_v2"],
+        fallback: false,
+      },
+      MONITORING: {
+        defaultTypes: ["Standard_B2as_v2", "Standard_A4_v2"],
+        fallback: false,
+      },
+      CUSTOM: {
+        defaultTypes: ["Standard_NC4as_T4_v3"],
+        fallback: true,
+      },
+      UNKNOWN: {
+        defaultTypes: [],
+        fallback: false,
+      },
+    };
+
+    const availableMachines =
+      nodeGroupType === "CUSTOM"
+        ? availableGPUMachineTypes
+        : availableNonGPUMachineTypes;
+
+    for (const machineType of defaultMachineTypes[nodeGroupType].defaultTypes) {
+      if (availableMachines.includes(machineType)) {
+        return {
+          defaultType: machineType,
+          notAvailable: false,
+        };
+      }
+    }
+
+    return {
+      defaultType: availableMachines[0],
+      notAvailable: !defaultMachineTypes[nodeGroupType].fallback,
+    };
+  };
+
+  const regionValid =
+    !areMachineTypesLoading &&
+    machineTypes &&
+    (!customSetupRequired || user?.isPorterUser);
+
+  useEffect(() => {
+    if (
+      clusterId || // if cluster has already been provisioned, don't change instance types that have been set
+      areMachineTypesLoading ||
+      !machineTypes || // if machine types are still loading, don't change instance types
+      !nodeGroups ||
+      nodeGroups.length === 0 // wait until node groups are loaded
+    ) {
+      return;
+    }
+
+    let instanceTypeReplaced = false;
+    let anyCustomSetupRequired = false;
+    const substituteBadInstanceTypes = nodeGroups.map((nodeGroup) => {
+      const { defaultType, notAvailable } = defaultNodeGroupType(
+        nodeGroup.nodeGroupType,
+        machineTypes
+      );
+
+      if (notAvailable) {
+        anyCustomSetupRequired = true;
+      }
+
+      if (nodeGroup.instanceType !== defaultType) {
+        instanceTypeReplaced = true;
+        return {
+          ...nodeGroup,
+          instanceType: defaultType,
+        };
+      }
+
+      return nodeGroup;
+    });
+
+    setCustomSetupRequired(anyCustomSetupRequired);
+
+    instanceTypeReplaced &&
+      setValue(`cluster.config.nodeGroups`, substituteBadInstanceTypes);
+  }, [machineTypes, areMachineTypesLoading, region]);
 
   return (
     <div>
@@ -47,7 +186,7 @@ const ConfigureAKSCluster: React.FC<Props> = ({ goBack }) => {
         <Text size={16}>Configure AKS Cluster</Text>
       </Container>
       <Spacer y={1} />
-      <Text>Specify settings for your AKS infratructure.</Text>
+      <Text>Specify settings for your AKS infrastructure.</Text>
       <Spacer y={1} />
       <VerticalSteps
         currentStep={currentStep}
@@ -93,82 +232,110 @@ const ConfigureAKSCluster: React.FC<Props> = ({ goBack }) => {
                 </Container>
               )}
             />
+            {areMachineTypesLoading ? (
+              <Container style={{ width: "300px" }}>
+                <Spacer y={1} />
+                <Loading />
+              </Container>
+            ) : (
+              customSetupRequired && (
+                <Container style={{ width: "500px" }}>
+                  <Spacer y={1} />
+                  <Error
+                    message={
+                      "Azure has limited instance types for your subscription in this region. Please select a different region, or contact Porter support for assistance."
+                    }
+                  />
+                </Container>
+              )
+            )}
           </>,
           <>
             <Container style={{ width: "300px" }}>
               <Text size={16}>Azure tier</Text>
-              <Spacer y={0.5} />
-              <Text color="helper">
-                Select Azure cluster management tier.{" "}
-                <a
-                  href="https://learn.microsoft.com/en-us/azure/aks/free-standard-pricing-tiers"
-                  target="_blank"
-                  rel="noreferrer"
-                >
-                  &nbsp;(?)
-                </a>
-              </Text>
-              <Spacer y={0.7} />
-              <Controller
-                name={`cluster.config.skuTier`}
-                control={control}
-                render={({ field: { value, onChange } }) => (
-                  <Select
-                    options={CloudProviderAzure.config.skuTiers.map((tier) => ({
-                      value: tier.name,
-                      label: tier.displayName,
-                    }))}
-                    value={value}
-                    setValue={(newSkuTier: string) => {
-                      onChange(newSkuTier);
-                    }}
+              {!customSetupRequired && (
+                <>
+                  <Spacer y={0.5} />
+                  <Text color="helper">
+                    Select Azure cluster management tier.{" "}
+                    <a
+                      href="https://learn.microsoft.com/en-us/azure/aks/free-standard-pricing-tiers"
+                      target="_blank"
+                      rel="noreferrer"
+                    >
+                      &nbsp;(?)
+                    </a>
+                  </Text>
+                  <Spacer y={0.7} />
+                  <Controller
+                    name={`cluster.config.skuTier`}
+                    control={control}
+                    render={({ field: { value, onChange } }) => (
+                      <Select
+                        options={CloudProviderAzure.config.skuTiers.map(
+                          (tier) => ({
+                            value: tier.name,
+                            label: tier.displayName,
+                          })
+                        )}
+                        value={value}
+                        setValue={(newSkuTier: string) => {
+                          onChange(newSkuTier);
+                        }}
+                      />
+                    )}
                   />
-                )}
-              />
+                </>
+              )}
             </Container>
           </>,
           isMultiClusterEnabled ? (
             <>
               <Text size={16}>CIDR range</Text>
               <Spacer y={0.5} />
-              <Text color="helper">
-                Specify the CIDR range for your cluster.
-              </Text>
-              <Spacer y={0.7} />
-              <ControlledInput
-                placeholder="ex: 10.78.0.0/16"
-                type="text"
-                width="300px"
-                error={errors.cluster?.config?.cidrRange?.message}
-                {...register("cluster.config.cidrRange")}
-              />
+              {regionValid && (
+                <>
+                  <Text color="helper">
+                    Specify the CIDR range for your cluster.
+                  </Text>
+                  <Spacer y={0.7} />
+                  <ControlledInput
+                    placeholder="ex: 10.78.0.0/16"
+                    type="text"
+                    width="300px"
+                    error={errors.cluster?.config?.cidrRange?.message}
+                    {...register("cluster.config.cidrRange")}
+                  />
+                </>
+              )}
             </>
           ) : null,
           <>
             <Text size={16}>Application node group </Text>
             <Spacer y={0.5} />
-            <Text color="helper">
-              Configure your application infrastructure.{" "}
-              <a
-                href="https://docs.porter.run/other/kubernetes-101"
-                target="_blank"
-                rel="noreferrer"
-              >
-                &nbsp;(?)
-              </a>
-            </Text>
-            <Spacer y={1} />
-            <NodeGroups
-              availableMachineTypes={CloudProviderAzure.machineTypes.filter(
-                (mt) => mt.supportedRegions.includes(region)
-              )}
-              isCreating
-            />
+            {regionValid && (
+              <>
+                <Text color="helper">
+                  Configure your application infrastructure.{" "}
+                  <a
+                    href="https://docs.porter.run/other/kubernetes-101"
+                    target="_blank"
+                    rel="noreferrer"
+                  >
+                    &nbsp;(?)
+                  </a>
+                </Text>
+                <Spacer y={1} />
+                <NodeGroups availableMachineTypes={machineTypes} isCreating />
+              </>
+            )}
           </>,
           <>
             <Text size={16}>Provision cluster</Text>
             <Spacer y={0.5} />
-            <ClusterSaveButton>Submit</ClusterSaveButton>
+            <ClusterSaveButton forceDisable={customSetupRequired}>
+              Submit
+            </ClusterSaveButton>
           </>,
         ].filter(valueExists)}
       />

+ 21 - 6
dashboard/src/main/home/infrastructure-dashboard/tabs/overview/AKSClusterOverview.tsx

@@ -1,21 +1,32 @@
 import React from "react";
 import { Controller, useFormContext } from "react-hook-form";
 
+import Loading from "components/Loading";
 import Container from "components/porter/Container";
 import Select from "components/porter/Select";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import { CloudProviderAzure } from "lib/clusters/constants";
 import { type ClientClusterContract } from "lib/clusters/types";
+import { useMachineTypeList } from "lib/hooks/useNodeGroups";
 
 import NodeGroups from "../../shared/NodeGroups";
 
 const AKSClusterOverview: React.FC = () => {
   const { control, watch } = useFormContext<ClientClusterContract>();
 
+  const cloudProviderCredentialIdentifier = watch(
+    "cluster.cloudProviderCredentialsId"
+  );
   const region = watch("cluster.config.region");
   const cidrRange = watch("cluster.config.cidrRange");
 
+  const { machineTypes, isLoading } = useMachineTypeList({
+    cloudProvider: "azure",
+    cloudProviderCredentialIdentifier,
+    region,
+  });
+
   return (
     <>
       <Container style={{ width: "300px" }}>
@@ -73,12 +84,16 @@ const AKSClusterOverview: React.FC = () => {
         </a>
       </Text>
       <Spacer y={1} />
-      <NodeGroups
-        availableMachineTypes={CloudProviderAzure.machineTypes.filter((mt) =>
-          mt.supportedRegions.includes(region)
-        )}
-        isDefaultExpanded={false}
-      />
+      {isLoading || !machineTypes ? (
+        <Container style={{ width: "300px" }}>
+          <Loading />
+        </Container>
+      ) : (
+        <NodeGroups
+          availableMachineTypes={machineTypes}
+          isDefaultExpanded={false}
+        />
+      )}
     </>
   );
 };

+ 1 - 5
dashboard/src/main/home/project-settings/Bars.tsx

@@ -21,11 +21,7 @@ type Props = {
   title?: string;
 };
 
-const CustomTooltip = ({
-  active,
-  payload,
-  label,
-}: TooltipProps<string, string>) => {
+const CustomTooltip = ({ active, payload }: TooltipProps<string, string>) => {
   if (active && payload?.length) {
     return (
       <div

+ 1 - 4
dashboard/src/main/home/project-settings/BillingPage.tsx

@@ -44,9 +44,6 @@ function BillingPage(): JSX.Element {
 
   const { refetchPaymentEnabled } = checkIfProjectHasPayment();
 
-  // const { url: usageDashboard } = useCustomeUsageDashboard("usage");
-
-  // This will return the aggregated daily usage, only for this billing period
   const { usage } = useCustomerUsage("day", true);
 
   const processedData = useMemo(() => {
@@ -54,7 +51,7 @@ function BillingPage(): JSX.Element {
     const resultMap = new Map();
 
     before?.forEach((metric) => {
-      const metricName = metric.metric_name.toLowerCase().replace(" ", "_");
+      const metricName = metric.metric_name.toLowerCase().replacqme(" ", "_");
       metric.usage_metrics.forEach(({ starting_on, value }) => {
         if (resultMap.has(starting_on)) {
           resultMap.get(starting_on)[metricName] = value;

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

@@ -1596,6 +1596,17 @@ const cloudContractPreflightCheck = baseApi<Contract, { project_id: number }>(
   }
 );
 
+const cloudProviderMachineTypes = baseApi<
+  {
+    cloud_provider: string;
+    cloud_provider_credential_identifier: string;
+    region: string;
+  },
+  { project_id: number }
+>("GET", ({ project_id }) => {
+  return `/api/projects/${project_id}/cloud/machines`;
+});
+
 const getContracts = baseApi<
   { cluster_id?: number; latest?: boolean },
   { project_id: number }
@@ -3468,8 +3479,8 @@ const getCustomerUsage = baseApi<
 const getUsageDashboard = baseApi<
   {
     dashboard: string;
-    dashboard_options?: { key: string; value: string }[];
-    color_overrides?: { name: string; value: string }[];
+    dashboard_options?: Array<{ key: string; value: string }>;
+    color_overrides?: Array<{ name: string; value: string }>;
   },
   {
     project_id?: number;
@@ -3599,7 +3610,7 @@ const createCloudSqlSecret = baseApi<
 const appEventWebhooks = baseApi<
   {},
   {
-    projectId: number; deploymentTargetId: string; appName: string 
+    projectId: number; deploymentTargetId: string; appName: string
   }
 >("GET", (pathParams) => {
   return `/api/projects/${pathParams.projectId}/targets/${pathParams.deploymentTargetId}/apps/${pathParams.appName}/app-event-webhooks`;
@@ -3610,7 +3621,7 @@ const updateAppEventWebhooks = baseApi<
     app_event_webhooks: AppEventWebhook[];
   },
   {
-    projectId: number; deploymentTargetId: string; appName: string 
+    projectId: number; deploymentTargetId: string; appName: string
   }
 >("POST", (pathParams) => {
   return `/api/projects/${pathParams.projectId}/targets/${pathParams.deploymentTargetId}/apps/${pathParams.appName}/update-app-event-webhooks`;
@@ -3924,6 +3935,8 @@ export default {
   getCloudSqlSecret,
   createCloudSqlSecret,
 
+  cloudProviderMachineTypes,
+
   // Webhooks
   appEventWebhooks,
   updateAppEventWebhooks

+ 1 - 1
go.mod

@@ -85,7 +85,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.149
+	github.com/porter-dev/api-contracts v0.2.150
 	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

@@ -1552,8 +1552,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.149 h1:pD2AjBypva1BVYnt7DMU78Ds0BmCubFrlgh/dPI8LEk=
-github.com/porter-dev/api-contracts v0.2.149/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU=
+github.com/porter-dev/api-contracts v0.2.150 h1:4BMuDuRboUg5aeuQOTy+/MWK+zFmKQ6Vdgek3/1nKOk=
+github.com/porter-dev/api-contracts v0.2.150/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU=
 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

@@ -380,6 +380,8 @@ github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tN
 github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
 github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398 h1:WDC6ySpJzbxGWFh4aMxFFC28wwGp5pEuoTtvA4q/qQ4=
 github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
+github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
+github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
 github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
 github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
 github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=

+ 3 - 0
internal/models/app_event_webhook.go

@@ -18,6 +18,9 @@ type AppEventWebhooks struct {
 	UpdatedAt sql.NullTime `db:"updated_at"`
 	DeletedAt sql.NullTime `db:"deleted_at"`
 
+	// ProjectID uniquely identifies the project this app is in
+	ProjectID uint `db:"project_id"`
+
 	// AppInstanceID uniquely identifies the application this webhook URL is configured for
 	AppInstanceID uuid.UUID `db:"app_instance_id"`
 

+ 3 - 0
internal/repository/deployment_target.go

@@ -16,4 +16,7 @@ type DeploymentTargetRepository interface {
 	CreateDeploymentTarget(deploymentTarget *models.DeploymentTarget) (*models.DeploymentTarget, error)
 	// DeploymentTarget retrieves a deployment target by its id if a uuid is provided or by name
 	DeploymentTarget(projectID uint, deploymentTargetIdentifier string) (*models.DeploymentTarget, error)
+	// DeploymentTargetById retrieves a deployment target by its uuid
+	// This bypasses the projectID check, and should only be used to retrieve a deployment target for a cloud project.
+	DeploymentTargetById(deploymentTargetIdentifier string) (*models.DeploymentTarget, error)
 }

+ 14 - 0
internal/repository/gorm/deployment_target.go

@@ -73,6 +73,20 @@ func (repo *DeploymentTargetRepository) DeploymentTarget(projectID uint, deploym
 	return deploymentTarget, nil
 }
 
+// DeploymentTargetById finds a deployment target by its uuid
+func (repo *DeploymentTargetRepository) DeploymentTargetById(id string) (*models.DeploymentTarget, error) {
+	if id == "" {
+		return nil, errors.New("deployment target id is empty")
+	}
+
+	deploymentTarget := &models.DeploymentTarget{}
+	if err := repo.db.Where("id = ?", id).Find(deploymentTarget).Error; err != nil {
+		return nil, err
+	}
+
+	return deploymentTarget, nil
+}
+
 // CreateDeploymentTarget creates a new deployment target
 func (repo *DeploymentTargetRepository) CreateDeploymentTarget(deploymentTarget *models.DeploymentTarget) (*models.DeploymentTarget, error) {
 	if deploymentTarget == nil {

+ 5 - 0
internal/repository/test/deployment_target.go

@@ -41,3 +41,8 @@ func (repo *DeploymentTargetRepository) CreateDeploymentTarget(deploymentTarget
 func (repo *DeploymentTargetRepository) DeploymentTarget(projectID uint, deploymentTargetIdentifier string) (*models.DeploymentTarget, error) {
 	return nil, errors.New("cannot read database")
 }
+
+// DeploymentTargetById finds a deployment target by its uuid
+func (repo *DeploymentTargetRepository) DeploymentTargetById(id string) (*models.DeploymentTarget, error) {
+	return nil, errors.New("cannot read database")
+}