jusrhee vor 2 Jahren
Ursprung
Commit
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 (
 import (
 	"context"
 	"context"
-	"encoding/json"
 	"fmt"
 	"fmt"
 	"net/http"
 	"net/http"
 	"strings"
 	"strings"
-	"time"
 
 
 	"connectrpc.com/connect"
 	"connectrpc.com/connect"
+
 	"github.com/google/uuid"
 	"github.com/google/uuid"
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 	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/authz"
@@ -74,6 +73,42 @@ func (p *CreateUpdatePorterAppEventHandler) ServeHTTP(w http.ResponseWriter, r *
 		reportBuildStatus(ctx, request, p.Config(), user, project, appName, validateApplyV2)
 		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 event types.PorterAppEvent
 	var err error
 	var err error
 	if request.ID == "" { // no event id provided, so create a new event/notification
 	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))
 				p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusInternalServerError))
 				return
 				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
 	} else { // event id provided, so update an existing event matching that event
 		event, err = p.updateExistingAppEvent(ctx, *cluster, appName, *request)
 		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)
 	_ = 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) {
 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")
 	ctx, span := telemetry.NewSpan(ctx, "update-porter-app-event")
 	defer span.End()
 	defer span.End()
@@ -300,247 +206,6 @@ func (p *CreateUpdatePorterAppEventHandler) updateExistingAppEvent(ctx context.C
 	return existingAppEvent.ToPorterAppEvent(), nil
 	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
 // handleNotification handles all logic for notifications in app v2
 func (p *CreateUpdatePorterAppEventHandler) handleNotification(ctx context.Context,
 func (p *CreateUpdatePorterAppEventHandler) handleNotification(ctx context.Context,
 	request *types.CreateOrUpdatePorterAppEventRequest,
 	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{
 			Role: types.Role{
 				UserID:    adminUserId,
 				UserID:    adminUserId,
 				ProjectID: proj.ID,
 				ProjectID: proj.ID,
-				Kind:      types.RoleAdmin,
+				Kind:      types.RoleViewer,
 			},
 			},
 		})
 		})
 	}
 	}

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

@@ -3,6 +3,8 @@ package router
 import (
 import (
 	"fmt"
 	"fmt"
 
 
+	"github.com/porter-dev/porter/api/server/handlers/cloud_provider"
+
 	"github.com/porter-dev/porter/api/server/handlers/deployment_target"
 	"github.com/porter-dev/porter/api/server/handlers/deployment_target"
 
 
 	"github.com/go-chi/chi/v5"
 	"github.com/go-chi/chi/v5"
@@ -1825,6 +1827,34 @@ func getProjectRoutes(
 		Router:   r,
 		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
 	// POST /api/projects/{project_id}/rename -> cluster.newRenamProject
 	renameProjectEndpoint := factory.NewAPIEndpoint(
 	renameProjectEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 		&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_Auth(cliConf))
 	rootCmd.AddCommand(registerCommand_Cluster(cliConf))
 	rootCmd.AddCommand(registerCommand_Cluster(cliConf))
 	rootCmd.AddCommand(registerCommand_Config(cliConf))
 	rootCmd.AddCommand(registerCommand_Config(cliConf))
-	rootCmd.AddCommand(registerCommand_Connect(cliConf))
 	rootCmd.AddCommand(registerCommand_Create(cliConf))
 	rootCmd.AddCommand(registerCommand_Create(cliConf))
 	rootCmd.AddCommand(registerCommand_Delete(cliConf))
 	rootCmd.AddCommand(registerCommand_Delete(cliConf))
 	rootCmd.AddCommand(registerCommand_Deploy(cliConf))
 	rootCmd.AddCommand(registerCommand_Deploy(cliConf))
@@ -40,7 +39,6 @@ func RegisterCommands() (*cobra.Command, error) {
 	rootCmd.AddCommand(registerCommand_List(cliConf))
 	rootCmd.AddCommand(registerCommand_List(cliConf))
 	rootCmd.AddCommand(registerCommand_Logs(cliConf))
 	rootCmd.AddCommand(registerCommand_Logs(cliConf))
 	rootCmd.AddCommand(registerCommand_Open(cliConf))
 	rootCmd.AddCommand(registerCommand_Open(cliConf))
-	rootCmd.AddCommand(registerCommand_PortForward(cliConf))
 	rootCmd.AddCommand(registerCommand_Project(cliConf))
 	rootCmd.AddCommand(registerCommand_Project(cliConf))
 	rootCmd.AddCommand(registerCommand_Registry(cliConf))
 	rootCmd.AddCommand(registerCommand_Registry(cliConf))
 	rootCmd.AddCommand(registerCommand_Run(cliConf))
 	rootCmd.AddCommand(registerCommand_Run(cliConf))

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

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"fmt"
 	"os"
 	"os"
 	"os/exec"
 	"os/exec"
+	"strings"
 
 
 	api "github.com/porter-dev/porter/api/client"
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
@@ -13,9 +14,15 @@ import (
 )
 )
 
 
 func registerCommand_Helm(cliConf config.CLIConfig) *cobra.Command {
 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{
 	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) {
 		Run: func(cmd *cobra.Command, args []string) {
 			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runHelm)
 			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runHelm)
 			if err != nil {
 			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 {
 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")
 	_, err := exec.LookPath("helm")
 	if err != nil {
 	if err != nil {
 		return fmt.Errorf("error finding helm: %w", err)
 		return fmt.Errorf("error finding helm: %w", err)
@@ -51,7 +64,6 @@ func runHelm(ctx context.Context, _ *types.GetAuthenticatedUserResponse, client
 	execCommand.Stderr = os.Stderr
 	execCommand.Stderr = os.Stderr
 
 
 	err = execCommand.Run()
 	err = execCommand.Run()
-
 	if err != nil {
 	if err != nil {
 		return fmt.Errorf("error running helm: %w", err)
 		return fmt.Errorf("error running helm: %w", err)
 	}
 	}

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

@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"fmt"
 	"os"
 	"os"
 	"os/exec"
 	"os/exec"
+	"strings"
 
 
 	api "github.com/porter-dev/porter/api/client"
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/api/types"
@@ -13,9 +14,15 @@ import (
 )
 )
 
 
 func registerCommand_Kubectl(cliConf config.CLIConfig) *cobra.Command {
 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{
 	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) {
 		Run: func(cmd *cobra.Command, args []string) {
 			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runKubectl)
 			err := checkLoginAndRunWithConfig(cmd, cliConf, args, runKubectl)
 			if err != nil {
 			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 {
 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")
 	_, err := exec.LookPath("kubectl")
 	if err != nil {
 	if err != nil {
 		return fmt.Errorf("error finding kubectl: %w", err)
 		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)
 	_, err = tmpFile.Write(resp.Kubeconfig)
-
 	if err != nil {
 	if err != nil {
 		return "", fmt.Errorf("error writing kubeconfig to temp file: %w", err)
 		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"
 	"fmt"
 	"os"
 	"os"
 	"os/signal"
 	"os/signal"
+	"strings"
 	"syscall"
 	"syscall"
 	"time"
 	"time"
 
 
@@ -28,6 +29,11 @@ type AppLogsInput struct {
 	ServiceName string
 	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
 // ServiceName_AllServices is a special value for ServiceName that indicates all services should be included
 const ServiceName_AllServices = "all"
 const ServiceName_AllServices = "all"
 
 
@@ -71,27 +77,30 @@ func AppLogs(ctx context.Context, inp AppLogsInput) error {
 		case <-ctx.Done():
 		case <-ctx.Done():
 			return ctx.Err()
 			return ctx.Err()
 		default:
 		default:
-			_, message, _ := conn.ReadMessage()
+			_, message, err := conn.ReadMessage()
 			if err != nil {
 			if err != nil {
-				return err
+				return fmt.Errorf("error reading message from app logs stream: %w", err)
 			}
 			}
 			if len(message) == 0 {
 			if len(message) == 0 {
 				return nil
 				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",
   "APPLICATION",
   "CUSTOM",
   "CUSTOM",
 ]);
 ]);
-type NodeGroupType = z.infer<typeof nodeGroupTypeValidator>;
+export type NodeGroupType = z.infer<typeof nodeGroupTypeValidator>;
 const eksNodeGroupValidator = z.object({
 const eksNodeGroupValidator = z.object({
   instanceType: z.string(),
   instanceType: z.string(),
   minInstances: z.number(),
   minInstances: z.number(),
@@ -611,3 +611,8 @@ export type UpdateClusterResponse =
       preflightChecks?: ClientPreflightCheck[];
       preflightChecks?: ClientPreflightCheck[];
       createContractResponse: CreateContractResponse;
       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 => {
 export const useCustomerUsage = (windowSize: string, currentPeriod: boolean): TGetUsage => {
   const { currentProject } = useContext(Context);
   const { currentProject } = useContext(Context);
 
 
   // Fetch current plan
   // Fetch current plan
+=======
+export const useCustomerUsage = (
+  windowSize: string,
+  currentPeriod: boolean
+): TGetUsage => {
+  const { currentProject } = useContext(Context);
+
+  // Fetch customer usage
+>>>>>>> 26d90f2aa267905d083cc670797d5d99d7f39d88
   const usageReq = useQuery(
   const usageReq = useQuery(
     ["listCustomerUsage", currentProject?.id],
     ["listCustomerUsage", currentProject?.id],
     async () => {
     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 { useHistory } from "react-router";
 import styled from "styled-components";
 import styled from "styled-components";
 import { match } from "ts-pattern";
 import { match } from "ts-pattern";
+import { z } from "zod";
 
 
 import { Error as ErrorComponent } from "components/porter/Error";
 import { Error as ErrorComponent } from "components/porter/Error";
 import {
 import {
   clusterContractValidator,
   clusterContractValidator,
+  machineTypeValidator,
   type ClientClusterContract,
   type ClientClusterContract,
   type ClientPreflightCheck,
   type ClientPreflightCheck,
+  type MachineType,
   type UpdateClusterResponse,
   type UpdateClusterResponse,
 } from "lib/clusters/types";
 } from "lib/clusters/types";
 import {
 import {
@@ -42,6 +45,11 @@ type ClusterFormContextType = {
   submitAndPatchCheckSuggestions: (args: {
   submitAndPatchCheckSuggestions: (args: {
     preflightChecks: ClientPreflightCheck[];
     preflightChecks: ClientPreflightCheck[];
   }) => Promise<void>;
   }) => Promise<void>;
+  availableMachineTypes: (
+    cloud_provider: string,
+    cloud_provider_credential_identifier: string,
+    region: string
+  ) => Promise<MachineType[]>;
 };
 };
 
 
 const ClusterFormContext = createContext<ClusterFormContextType | null>(null);
 const ClusterFormContext = createContext<ClusterFormContextType | null>(null);
@@ -217,6 +225,31 @@ const ClusterFormContextProvider: React.FC<ClusterFormContextProviderProps> = ({
     await handleClusterUpdate(data);
     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> => {
   const submitSkippingPreflightChecks = async (): Promise<void> => {
     if (clusterForm.formState.isSubmitting) {
     if (clusterForm.formState.isSubmitting) {
       return;
       return;
@@ -271,6 +304,7 @@ const ClusterFormContextProvider: React.FC<ClusterFormContextProviderProps> = ({
         isMultiClusterEnabled,
         isMultiClusterEnabled,
         submitSkippingPreflightChecks,
         submitSkippingPreflightChecks,
         submitAndPatchCheckSuggestions,
         submitAndPatchCheckSuggestions,
+        availableMachineTypes,
       }}
       }}
     >
     >
       <Wrapper ref={scrollToTopRef}>
       <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";
 import { useClusterFormContext } from "./ClusterFormContextProvider";
 
 
 type Props = {
 type Props = {
+  forceDisable?: boolean;
   height?: string;
   height?: string;
   disabledTooltipPosition?: "top" | "bottom" | "left" | "right";
   disabledTooltipPosition?: "top" | "bottom" | "left" | "right";
   isClusterUpdating?: boolean;
   isClusterUpdating?: boolean;
   children: React.ReactNode;
   children: React.ReactNode;
 };
 };
 const ClusterSaveButton: React.FC<Props> = ({
 const ClusterSaveButton: React.FC<Props> = ({
+  forceDisable,
   height,
   height,
   disabledTooltipPosition,
   disabledTooltipPosition,
   isClusterUpdating,
   isClusterUpdating,
@@ -23,7 +25,9 @@ const ClusterSaveButton: React.FC<Props> = ({
       type="submit"
       type="submit"
       status={updateClusterButtonProps.status}
       status={updateClusterButtonProps.status}
       loadingText={updateClusterButtonProps.loadingText}
       loadingText={updateClusterButtonProps.loadingText}
-      disabled={updateClusterButtonProps.isDisabled || isClusterUpdating}
+      disabled={
+        updateClusterButtonProps.isDisabled || isClusterUpdating || forceDisable
+      }
       disabledTooltipMessage={
       disabledTooltipMessage={
         "Please wait for the current update to complete before updating again."
         "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 { Controller, useFormContext } from "react-hook-form";
 
 
+import Loading from "components/Loading";
 import Container from "components/porter/Container";
 import Container from "components/porter/Container";
 import { ControlledInput } from "components/porter/ControlledInput";
 import { ControlledInput } from "components/porter/ControlledInput";
+import Error from "components/porter/Error";
 import Select from "components/porter/Select";
 import Select from "components/porter/Select";
 import Spacer from "components/porter/Spacer";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import Text from "components/porter/Text";
 import VerticalSteps from "components/porter/VerticalSteps";
 import VerticalSteps from "components/porter/VerticalSteps";
 import { CloudProviderAzure } from "lib/clusters/constants";
 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 { valueExists } from "shared/util";
 
 
 import { useClusterFormContext } from "../../ClusterFormContextProvider";
 import { useClusterFormContext } from "../../ClusterFormContextProvider";
@@ -23,10 +32,23 @@ type Props = {
 
 
 const ConfigureAKSCluster: React.FC<Props> = ({ goBack }) => {
 const ConfigureAKSCluster: React.FC<Props> = ({ goBack }) => {
   const [currentStep, _setCurrentStep] = useState<number>(100); // hack to show all steps
   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 {
   const {
     control,
     control,
     register,
     register,
+    setValue,
     formState: { errors },
     formState: { errors },
     watch,
     watch,
   } = useFormContext<ClientClusterContract>();
   } = useFormContext<ClientClusterContract>();
@@ -34,6 +56,123 @@ const ConfigureAKSCluster: React.FC<Props> = ({ goBack }) => {
   const { isMultiClusterEnabled } = useClusterFormContext();
   const { isMultiClusterEnabled } = useClusterFormContext();
 
 
   const region = watch("cluster.config.region");
   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 (
   return (
     <div>
     <div>
@@ -47,7 +186,7 @@ const ConfigureAKSCluster: React.FC<Props> = ({ goBack }) => {
         <Text size={16}>Configure AKS Cluster</Text>
         <Text size={16}>Configure AKS Cluster</Text>
       </Container>
       </Container>
       <Spacer y={1} />
       <Spacer y={1} />
-      <Text>Specify settings for your AKS infratructure.</Text>
+      <Text>Specify settings for your AKS infrastructure.</Text>
       <Spacer y={1} />
       <Spacer y={1} />
       <VerticalSteps
       <VerticalSteps
         currentStep={currentStep}
         currentStep={currentStep}
@@ -93,82 +232,110 @@ const ConfigureAKSCluster: React.FC<Props> = ({ goBack }) => {
                 </Container>
                 </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" }}>
             <Container style={{ width: "300px" }}>
               <Text size={16}>Azure tier</Text>
               <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>
             </Container>
           </>,
           </>,
           isMultiClusterEnabled ? (
           isMultiClusterEnabled ? (
             <>
             <>
               <Text size={16}>CIDR range</Text>
               <Text size={16}>CIDR range</Text>
               <Spacer y={0.5} />
               <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,
           ) : null,
           <>
           <>
             <Text size={16}>Application node group </Text>
             <Text size={16}>Application node group </Text>
             <Spacer y={0.5} />
             <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>
             <Text size={16}>Provision cluster</Text>
             <Spacer y={0.5} />
             <Spacer y={0.5} />
-            <ClusterSaveButton>Submit</ClusterSaveButton>
+            <ClusterSaveButton forceDisable={customSetupRequired}>
+              Submit
+            </ClusterSaveButton>
           </>,
           </>,
         ].filter(valueExists)}
         ].filter(valueExists)}
       />
       />

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

@@ -1,21 +1,32 @@
 import React from "react";
 import React from "react";
 import { Controller, useFormContext } from "react-hook-form";
 import { Controller, useFormContext } from "react-hook-form";
 
 
+import Loading from "components/Loading";
 import Container from "components/porter/Container";
 import Container from "components/porter/Container";
 import Select from "components/porter/Select";
 import Select from "components/porter/Select";
 import Spacer from "components/porter/Spacer";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import Text from "components/porter/Text";
 import { CloudProviderAzure } from "lib/clusters/constants";
 import { CloudProviderAzure } from "lib/clusters/constants";
 import { type ClientClusterContract } from "lib/clusters/types";
 import { type ClientClusterContract } from "lib/clusters/types";
+import { useMachineTypeList } from "lib/hooks/useNodeGroups";
 
 
 import NodeGroups from "../../shared/NodeGroups";
 import NodeGroups from "../../shared/NodeGroups";
 
 
 const AKSClusterOverview: React.FC = () => {
 const AKSClusterOverview: React.FC = () => {
   const { control, watch } = useFormContext<ClientClusterContract>();
   const { control, watch } = useFormContext<ClientClusterContract>();
 
 
+  const cloudProviderCredentialIdentifier = watch(
+    "cluster.cloudProviderCredentialsId"
+  );
   const region = watch("cluster.config.region");
   const region = watch("cluster.config.region");
   const cidrRange = watch("cluster.config.cidrRange");
   const cidrRange = watch("cluster.config.cidrRange");
 
 
+  const { machineTypes, isLoading } = useMachineTypeList({
+    cloudProvider: "azure",
+    cloudProviderCredentialIdentifier,
+    region,
+  });
+
   return (
   return (
     <>
     <>
       <Container style={{ width: "300px" }}>
       <Container style={{ width: "300px" }}>
@@ -73,12 +84,16 @@ const AKSClusterOverview: React.FC = () => {
         </a>
         </a>
       </Text>
       </Text>
       <Spacer y={1} />
       <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;
   title?: string;
 };
 };
 
 
-const CustomTooltip = ({
-  active,
-  payload,
-  label,
-}: TooltipProps<string, string>) => {
+const CustomTooltip = ({ active, payload }: TooltipProps<string, string>) => {
   if (active && payload?.length) {
   if (active && payload?.length) {
     return (
     return (
       <div
       <div

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

@@ -44,9 +44,6 @@ function BillingPage(): JSX.Element {
 
 
   const { refetchPaymentEnabled } = checkIfProjectHasPayment();
   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 { usage } = useCustomerUsage("day", true);
 
 
   const processedData = useMemo(() => {
   const processedData = useMemo(() => {
@@ -54,7 +51,7 @@ function BillingPage(): JSX.Element {
     const resultMap = new Map();
     const resultMap = new Map();
 
 
     before?.forEach((metric) => {
     before?.forEach((metric) => {
-      const metricName = metric.metric_name.toLowerCase().replace(" ", "_");
+      const metricName = metric.metric_name.toLowerCase().replacqme(" ", "_");
       metric.usage_metrics.forEach(({ starting_on, value }) => {
       metric.usage_metrics.forEach(({ starting_on, value }) => {
         if (resultMap.has(starting_on)) {
         if (resultMap.has(starting_on)) {
           resultMap.get(starting_on)[metricName] = value;
           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<
 const getContracts = baseApi<
   { cluster_id?: number; latest?: boolean },
   { cluster_id?: number; latest?: boolean },
   { project_id: number }
   { project_id: number }
@@ -3468,8 +3479,8 @@ const getCustomerUsage = baseApi<
 const getUsageDashboard = baseApi<
 const getUsageDashboard = baseApi<
   {
   {
     dashboard: string;
     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;
     project_id?: number;
@@ -3599,7 +3610,7 @@ const createCloudSqlSecret = baseApi<
 const appEventWebhooks = baseApi<
 const appEventWebhooks = baseApi<
   {},
   {},
   {
   {
-    projectId: number; deploymentTargetId: string; appName: string 
+    projectId: number; deploymentTargetId: string; appName: string
   }
   }
 >("GET", (pathParams) => {
 >("GET", (pathParams) => {
   return `/api/projects/${pathParams.projectId}/targets/${pathParams.deploymentTargetId}/apps/${pathParams.appName}/app-event-webhooks`;
   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[];
     app_event_webhooks: AppEventWebhook[];
   },
   },
   {
   {
-    projectId: number; deploymentTargetId: string; appName: string 
+    projectId: number; deploymentTargetId: string; appName: string
   }
   }
 >("POST", (pathParams) => {
 >("POST", (pathParams) => {
   return `/api/projects/${pathParams.projectId}/targets/${pathParams.deploymentTargetId}/apps/${pathParams.appName}/update-app-event-webhooks`;
   return `/api/projects/${pathParams.projectId}/targets/${pathParams.deploymentTargetId}/apps/${pathParams.appName}/update-app-event-webhooks`;
@@ -3924,6 +3935,8 @@ export default {
   getCloudSqlSecret,
   getCloudSqlSecret,
   createCloudSqlSecret,
   createCloudSqlSecret,
 
 
+  cloudProviderMachineTypes,
+
   // Webhooks
   // Webhooks
   appEventWebhooks,
   appEventWebhooks,
   updateAppEventWebhooks
   updateAppEventWebhooks

+ 1 - 1
go.mod

@@ -85,7 +85,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.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/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d
 	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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 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/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 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
 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/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/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398 h1:WDC6ySpJzbxGWFh4aMxFFC28wwGp5pEuoTtvA4q/qQ4=
 github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
 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 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
 github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
 github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
 github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
 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"`
 	UpdatedAt sql.NullTime `db:"updated_at"`
 	DeletedAt sql.NullTime `db:"deleted_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 uniquely identifies the application this webhook URL is configured for
 	AppInstanceID uuid.UUID `db:"app_instance_id"`
 	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)
 	CreateDeploymentTarget(deploymentTarget *models.DeploymentTarget) (*models.DeploymentTarget, error)
 	// DeploymentTarget retrieves a deployment target by its id if a uuid is provided or by name
 	// DeploymentTarget retrieves a deployment target by its id if a uuid is provided or by name
 	DeploymentTarget(projectID uint, deploymentTargetIdentifier string) (*models.DeploymentTarget, error)
 	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
 	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
 // CreateDeploymentTarget creates a new deployment target
 func (repo *DeploymentTargetRepository) CreateDeploymentTarget(deploymentTarget *models.DeploymentTarget) (*models.DeploymentTarget, error) {
 func (repo *DeploymentTargetRepository) CreateDeploymentTarget(deploymentTarget *models.DeploymentTarget) (*models.DeploymentTarget, error) {
 	if deploymentTarget == nil {
 	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) {
 func (repo *DeploymentTargetRepository) DeploymentTarget(projectID uint, deploymentTargetIdentifier string) (*models.DeploymentTarget, error) {
 	return nil, errors.New("cannot read database")
 	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")
+}