Explorar el Código

check available instance types for azure before provisioning (#4540)

d-g-town hace 2 años
padre
commit
26d1b296c7

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

+ 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"
@@ -1797,6 +1799,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{

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

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

+ 245 - 60
dashboard/src/main/home/infrastructure-dashboard/forms/azure/ConfigureAKSCluster.tsx

@@ -1,14 +1,23 @@
-import React, { useState } from "react";
+import React, { useEffect, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
 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,
+  MachineType,
+  NodeGroupType,
+} from "lib/clusters/types";
+import { useIntercom } from "lib/hooks/useIntercom";
 
 import { valueExists } from "shared/util";
 
@@ -19,14 +28,30 @@ import { BackButton, Img } from "../CreateClusterForm";
 
 type Props = {
   goBack: () => void;
+  availableMachineTypes: (region: string) => Promise<MachineType[]>;
 };
 
-const ConfigureAKSCluster: React.FC<Props> = ({ goBack }) => {
+const ConfigureAKSCluster: React.FC<Props> = ({
+  goBack,
+  availableMachineTypes,
+}) => {
   const [currentStep, _setCurrentStep] = useState<number>(100); // hack to show all steps
+  const [customSetupRequired, setCustomSetupRequired] =
+    useState<boolean>(false);
+  const { showIntercomWithMessage } = useIntercom();
+
+  useEffect(() => {
+    if (customSetupRequired) {
+      showIntercomWithMessage({
+        message: "I need custom configuration for creating  an Azure cluster.",
+      });
+    }
+  }, [customSetupRequired]);
 
   const {
     control,
     register,
+    setValue,
     formState: { errors },
     watch,
   } = useFormContext<ClientClusterContract>();
@@ -34,6 +59,138 @@ 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 defaultNodeGroupType = (
+    nodeGroupType: NodeGroupType,
+    availableMachineTypes: ClientMachineType[]
+  ): string => {
+    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
+      }
+    > = {
+      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 machineType;
+      }
+    }
+
+    if (defaultMachineTypes[nodeGroupType].fallback) {
+      return availableMachines[0];
+    }
+
+    return "";
+  };
+
+  const { data: machineTypes, status: machineTypesStatus } = useQuery(
+    ["availableMachineTypes", region],
+    async () => {
+      try {
+        const machineTypes = await availableMachineTypes(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)
+        );
+      }
+    }
+  );
+
+  const regionValid =
+    !customSetupRequired && machineTypesStatus !== "loading" && machineTypes;
+
+  useEffect(() => {
+    if (
+      clusterId || // if cluster has already been provisioned, don't change instance types that have been set
+      machineTypesStatus === "loading" ||
+      !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;
+    const substituteBadInstanceTypes = nodeGroups.map((nodeGroup) => {
+      const defaultMachineType = defaultNodeGroupType(
+        nodeGroup.nodeGroupType,
+        machineTypes
+      );
+
+      if (nodeGroup.instanceType !== defaultMachineType) {
+        instanceTypeReplaced = true;
+        return {
+          ...nodeGroup,
+          instanceType: defaultMachineType,
+        };
+      }
+
+      return nodeGroup;
+    });
+
+    // if we cannot find a valid machine type for any node group, set custom setup required and exit
+    for (const nodeGroup of substituteBadInstanceTypes) {
+      if (nodeGroup.instanceType === "") {
+        setCustomSetupRequired(true);
+        return;
+      }
+    }
+
+    // if we reach here, custom setup is not required
+    if (customSetupRequired) {
+      setCustomSetupRequired(false);
+    }
+
+    // if any instance types were replaced, update the form
+    if (instanceTypeReplaced) {
+      setValue(`cluster.config.nodeGroups`, substituteBadInstanceTypes);
+    }
+  }, [machineTypes, machineTypesStatus, region]);
 
   return (
     <div>
@@ -47,7 +204,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 +250,110 @@ const ConfigureAKSCluster: React.FC<Props> = ({ goBack }) => {
                 </Container>
               )}
             />
+            {machineTypesStatus === "loading" ? (
+              <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)}
       />

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

@@ -3,7 +3,7 @@ import { useFormContext } from "react-hook-form";
 import { match } from "ts-pattern";
 
 import { CloudProviderAzure } from "lib/clusters/constants";
-import { type ClientClusterContract } from "lib/clusters/types";
+import { MachineType, type ClientClusterContract } from "lib/clusters/types";
 import { useClusterAnalytics } from "lib/hooks/useClusterAnalytics";
 
 import { useClusterFormContext } from "../../ClusterFormContextProvider";
@@ -20,12 +20,20 @@ const CreateAKSClusterForm: React.FC<Props> = ({
   projectId,
   projectName,
 }) => {
+  const { availableMachineTypes } = useClusterFormContext();
+
   const [step, setStep] = useState<"permissions" | "cluster">("permissions");
 
   const { setValue, reset } = useFormContext<ClientClusterContract>();
   const { setCurrentContract } = useClusterFormContext();
   const { reportToAnalytics } = useClusterAnalytics();
 
+  const { watch } = useFormContext<ClientClusterContract>();
+
+  const cloudProviderCredentialIdentifier = watch(
+    "cluster.cloudProviderCredentialsId"
+  );
+
   useEffect(() => {
     const truncatedProjectName = projectName
       .substring(0, 24)
@@ -102,6 +110,13 @@ const CreateAKSClusterForm: React.FC<Props> = ({
           setStep("permissions");
           setValue("cluster.cloudProviderCredentialsId", "");
         }}
+        availableMachineTypes={async (region: string) => {
+          return await availableMachineTypes(
+            "azure",
+            cloudProviderCredentialIdentifier,
+            region
+          );
+        }}
       />
     ))
     .exhaustive();

+ 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 }
@@ -3456,8 +3467,8 @@ const getPublishableKey = 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;
@@ -3587,7 +3598,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`;
@@ -3598,7 +3609,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`;
@@ -3911,6 +3922,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=