AzureProvisionerSettings.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656
  1. import React, { useContext, useEffect, useState } from "react";
  2. import {
  3. AKS,
  4. AKSNodePool,
  5. AksSkuTier,
  6. Cluster,
  7. Contract,
  8. EnumCloudProvider,
  9. EnumKubernetesKind,
  10. NodePoolType,
  11. } from "@porter-dev/api-contracts";
  12. import { Label } from "@tanstack/react-query-devtools/build/lib/Explorer";
  13. import { withRouter, type RouteComponentProps } from "react-router";
  14. import styled from "styled-components";
  15. import Heading from "components/form-components/Heading";
  16. import SelectRow from "components/form-components/SelectRow";
  17. import { OFState } from "main/home/onboarding/state";
  18. import { useIntercom } from "lib/hooks/useIntercom";
  19. import api from "shared/api";
  20. import { Context } from "shared/Context";
  21. import { pushFiltered } from "shared/routing";
  22. import { type ClusterType } from "shared/types";
  23. import dotVertical from "assets/dot-vertical.svg";
  24. import {
  25. AzureLocationOptions,
  26. azureSupportedMachineTypes,
  27. type MachineTypeOption,
  28. } from "./azureUtils";
  29. import InputRow from "./form-components/InputRow";
  30. import Button from "./porter/Button";
  31. import Error from "./porter/Error";
  32. import Icon from "./porter/Icon";
  33. import Link from "./porter/Link";
  34. import Spacer from "./porter/Spacer";
  35. import Step from "./porter/Step";
  36. import Text from "./porter/Text";
  37. const skuTierOptions = [
  38. { value: AksSkuTier.FREE, label: "Free" },
  39. {
  40. value: AksSkuTier.STANDARD,
  41. label: "Standard (for production workloads, +$73/month)",
  42. },
  43. ];
  44. const clusterVersionOptions = [{ value: "v1.27.3", label: "v1.27" }];
  45. type Props = RouteComponentProps & {
  46. selectedClusterVersion?: Contract;
  47. provisionerError?: string;
  48. credentialId: string;
  49. clusterId?: number;
  50. };
  51. const VALID_CIDR_RANGE_PATTERN =
  52. /^(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.0\.0\/16$/;
  53. const AzureProvisionerSettings: React.FC<Props> = (props) => {
  54. const {
  55. user,
  56. currentProject,
  57. currentCluster,
  58. setCurrentCluster,
  59. setShouldRefreshClusters,
  60. setHasFinishedOnboarding,
  61. } = useContext(Context);
  62. const [createStatus, setCreateStatus] = useState("");
  63. const [clusterName, setClusterName] = useState("");
  64. const [azureLocation, setAzureLocation] = useState("eastus");
  65. const [machineType, setMachineType] = useState("Standard_B2als_v2");
  66. const [isExpanded, setIsExpanded] = useState(false);
  67. const [minInstances, setMinInstances] = useState(1);
  68. const [maxInstances, setMaxInstances] = useState(10);
  69. const [cidrRange, setCidrRange] = useState("10.78.0.0/16");
  70. const [clusterVersion, setClusterVersion] = useState("v1.27.3");
  71. const [isReadOnly, setIsReadOnly] = useState(false);
  72. const [skuTier, setSkuTier] = useState(AksSkuTier.FREE);
  73. const [errorMessage, setErrorMessage] = useState<string>("");
  74. const [errorDetails, setErrorDetails] = useState<string>("");
  75. const [isClicked, setIsClicked] = useState(false);
  76. const [
  77. regionFilteredMachineTypeOptions,
  78. setRegionFilteredMachineTypeOptions,
  79. ] = useState<MachineTypeOption[]>(azureSupportedMachineTypes(azureLocation));
  80. const { showIntercomWithMessage } = useIntercom();
  81. useEffect(() => {
  82. setRegionFilteredMachineTypeOptions(
  83. azureSupportedMachineTypes(azureLocation)
  84. );
  85. }, [azureLocation]);
  86. const markStepStarted = async (
  87. step: string,
  88. { region, error_message }: { region?: string; error_message?: string }
  89. ) => {
  90. try {
  91. await api.updateOnboardingStep(
  92. "<token>",
  93. { step, region, error_message, provider: "azure" },
  94. {
  95. project_id: currentProject.id,
  96. }
  97. );
  98. } catch (err) {
  99. console.log(err);
  100. }
  101. };
  102. const getStatus = () => {
  103. if (isReadOnly && props.provisionerError == "") {
  104. return "Provisioning is still in progress...";
  105. } else if (errorMessage !== "") {
  106. return (
  107. <Error
  108. message={
  109. errorDetails !== ""
  110. ? errorMessage + " (" + errorDetails + ")"
  111. : errorMessage
  112. }
  113. ctaText={
  114. errorMessage !== DEFAULT_ERROR_MESSAGE
  115. ? "Troubleshooting steps"
  116. : null
  117. }
  118. errorModalContents={errorMessageToModal(errorMessage)}
  119. />
  120. );
  121. }
  122. return undefined;
  123. };
  124. const isDisabled = () => {
  125. return (
  126. (!clusterName && true) ||
  127. (isReadOnly && props.provisionerError === "") ||
  128. currentCluster?.status === "UPDATING" ||
  129. isClicked ||
  130. (!currentProject?.enable_reprovision && props.clusterId)
  131. );
  132. };
  133. const validateInputs = (): string => {
  134. if (!clusterName) {
  135. return "Cluster name is required";
  136. }
  137. if (!azureLocation) {
  138. return "Azure location is required";
  139. }
  140. if (!machineType) {
  141. return "Machine type is required";
  142. }
  143. if (!cidrRange) {
  144. return "VPC CIDR range is required";
  145. }
  146. if (!VALID_CIDR_RANGE_PATTERN.test(cidrRange)) {
  147. return "VPC CIDR range must be in the format of [0-255].[0-255].0.0/16";
  148. }
  149. if (clusterVersion == "v1.24.9") {
  150. return "Cluster version v1.24.9 is no longer supported";
  151. }
  152. return "";
  153. };
  154. const createCluster = async () => {
  155. const err = validateInputs();
  156. if (err !== "") {
  157. setErrorMessage(err);
  158. setErrorDetails("");
  159. return;
  160. }
  161. setIsClicked(true);
  162. try {
  163. window.dataLayer?.push({
  164. event: "provision-attempt",
  165. data: {
  166. cloud: "azure",
  167. email: user?.email,
  168. },
  169. });
  170. } catch (err) {
  171. console.log(err);
  172. }
  173. const data = new Contract({
  174. cluster: new Cluster({
  175. projectId: currentProject.id,
  176. kind: EnumKubernetesKind.AKS,
  177. cloudProvider: EnumCloudProvider.AZURE,
  178. cloudProviderCredentialsId: props.credentialId,
  179. kindValues: {
  180. case: "aksKind",
  181. value: new AKS({
  182. clusterName,
  183. clusterVersion: clusterVersion || "v1.27.3",
  184. cidrRange: cidrRange || "10.78.0.0/16",
  185. location: azureLocation,
  186. nodePools: [
  187. new AKSNodePool({
  188. instanceType: "Standard_B2als_v2",
  189. minInstances: 1,
  190. maxInstances: 3,
  191. nodePoolType: NodePoolType.SYSTEM,
  192. mode: "User",
  193. }),
  194. new AKSNodePool({
  195. instanceType: "Standard_B2as_v2",
  196. minInstances: 1,
  197. maxInstances: 3,
  198. nodePoolType: NodePoolType.MONITORING,
  199. mode: "User",
  200. }),
  201. new AKSNodePool({
  202. instanceType: machineType,
  203. minInstances: minInstances || 1,
  204. maxInstances: maxInstances || 10,
  205. nodePoolType: NodePoolType.APPLICATION,
  206. mode: "User",
  207. }),
  208. ],
  209. skuTier,
  210. }),
  211. },
  212. }),
  213. });
  214. if (props.clusterId) {
  215. data.cluster.clusterId = props.clusterId;
  216. }
  217. try {
  218. setIsReadOnly(true);
  219. setErrorMessage("");
  220. setErrorDetails("");
  221. if (!props.clusterId) {
  222. markStepStarted("provisioning-started", { region: azureLocation });
  223. }
  224. const res = await api.createContract("<token>", data, {
  225. project_id: currentProject.id,
  226. });
  227. // Only refresh and set clusters on initial create
  228. // if (!props.clusterId) {
  229. setShouldRefreshClusters(true);
  230. api
  231. .getClusters("<token>", {}, { id: currentProject.id })
  232. .then(({ data }) => {
  233. data.forEach((cluster: ClusterType) => {
  234. if (cluster.id === res.data.contract_revision?.cluster_id) {
  235. // setHasFinishedOnboarding(true);
  236. setCurrentCluster(cluster);
  237. OFState.actions.goTo("clean_up");
  238. pushFiltered(props, "/cluster-dashboard", ["project_id"], {
  239. cluster: cluster.name,
  240. });
  241. }
  242. });
  243. })
  244. .catch((err) => {
  245. console.error(err);
  246. });
  247. // }
  248. setErrorMessage("");
  249. setErrorDetails("");
  250. } catch (err) {
  251. showIntercomWithMessage({
  252. message: "I am running into an issue provisioning a cluster.",
  253. });
  254. let errorMessage = DEFAULT_ERROR_MESSAGE;
  255. const errorDetails =
  256. err.response?.data?.error?.replace("unknown: ", "") ?? "";
  257. // hacky, need to standardize error contract with backend
  258. setIsClicked(false);
  259. if (errorDetails.includes("resource provider")) {
  260. setErrorDetails(errorDetails);
  261. errorMessage = AZURE_MISSING_RESOURCE_PROVIDER_MESSAGE;
  262. } else if (errorDetails.includes("quota")) {
  263. setErrorDetails(errorDetails);
  264. errorMessage = AZURE_CORE_QUOTA_ERROR_MESSAGE;
  265. } else {
  266. setErrorDetails("");
  267. }
  268. setErrorMessage(errorMessage);
  269. markStepStarted("provisioning-failed", {
  270. error_message: `Error message: ${errorMessage}; Error details: ${errorDetails}`,
  271. });
  272. } finally {
  273. setIsReadOnly(false);
  274. setIsClicked(false);
  275. }
  276. };
  277. useEffect(() => {
  278. if (!currentProject) return;
  279. setIsReadOnly(
  280. !!props.clusterId &&
  281. (currentCluster?.status === "UPDATING" ||
  282. currentCluster?.status === "UPDATING_UNAVAILABLE")
  283. );
  284. setClusterName(
  285. `${currentProject?.name.substring(0, 16)}-cluster-${Math.random()
  286. .toString(36)
  287. .substring(2, 8)}`
  288. );
  289. }, []);
  290. useEffect(() => {
  291. if (!props.selectedClusterVersion) return;
  292. // TODO: pass in contract as the already parsed object, rather than JSON (requires changes to AWS/GCP provisioning)
  293. const contract = Contract.fromJsonString(
  294. JSON.stringify(props.selectedClusterVersion)
  295. );
  296. if (
  297. contract?.cluster?.kindValues &&
  298. contract.cluster.kindValues.case === "aksKind"
  299. ) {
  300. const aksValues = contract.cluster.kindValues.value;
  301. aksValues.nodePools.map((nodePool: AKSNodePool) => {
  302. if (nodePool.nodePoolType === NodePoolType.APPLICATION) {
  303. setMachineType(nodePool.instanceType);
  304. setMinInstances(nodePool.minInstances);
  305. setMaxInstances(nodePool.maxInstances);
  306. }
  307. });
  308. setCreateStatus("");
  309. setClusterName(aksValues.clusterName);
  310. setAzureLocation(aksValues.location);
  311. setClusterVersion(aksValues.clusterVersion);
  312. setCidrRange(aksValues.cidrRange);
  313. if (aksValues.skuTier !== AksSkuTier.UNSPECIFIED) {
  314. setSkuTier(aksValues.skuTier);
  315. }
  316. }
  317. }, [props.selectedClusterVersion]);
  318. const renderSimpleSettings = (): JSX.Element => {
  319. return (
  320. <>
  321. <SelectRow
  322. options={AzureLocationOptions}
  323. width="350px"
  324. disabled={props.clusterId ? props.clusterId !== 0 : false}
  325. value={azureLocation}
  326. scrollBuffer={true}
  327. dropdownMaxHeight="240px"
  328. setActiveValue={setAzureLocation}
  329. label="📍 Azure location"
  330. />
  331. <Spacer y={0.75} />
  332. <div style={{ display: "flex", alignItems: "center" }}>
  333. <Spacer inline x={0.05} />
  334. <Icon src={dotVertical} height={"15px"} />
  335. <Spacer inline x={0.2} />
  336. <Label>Azure Tier</Label>
  337. </div>
  338. <SelectRow
  339. options={skuTierOptions}
  340. width="350px"
  341. disabled={isReadOnly}
  342. value={skuTier}
  343. scrollBuffer={true}
  344. dropdownMaxHeight="240px"
  345. setActiveValue={setSkuTier}
  346. />
  347. </>
  348. );
  349. };
  350. const renderAdvancedSettings = (): JSX.Element => {
  351. return (
  352. <>
  353. <Heading>
  354. <ExpandHeader
  355. onClick={() => {
  356. setIsExpanded(!isExpanded);
  357. }}
  358. isExpanded={isExpanded}
  359. >
  360. <i className="material-icons">arrow_drop_down</i>
  361. Advanced settings
  362. </ExpandHeader>
  363. </Heading>
  364. <Spacer y={0.5} />
  365. {isExpanded && (
  366. <>
  367. <SelectRow
  368. options={clusterVersionOptions}
  369. width="350px"
  370. disabled={true}
  371. value={clusterVersion}
  372. scrollBuffer={true}
  373. dropdownMaxHeight="240px"
  374. setActiveValue={setClusterVersion}
  375. label="Cluster version"
  376. />
  377. <Spacer y={0.75} />
  378. <SelectRow
  379. options={regionFilteredMachineTypeOptions}
  380. width="350px"
  381. disabled={true}
  382. value={machineType}
  383. scrollBuffer={true}
  384. dropdownMaxHeight="240px"
  385. setActiveValue={setMachineType}
  386. label="Machine type"
  387. />
  388. <InputRow
  389. width="350px"
  390. type="number"
  391. disabled={isReadOnly}
  392. value={maxInstances}
  393. setValue={(x: number) => {
  394. setMaxInstances(x);
  395. }}
  396. label="Maximum number of application nodes"
  397. placeholder="ex: 1"
  398. />
  399. <InputRow
  400. width="350px"
  401. type="string"
  402. disabled={true}
  403. value={cidrRange}
  404. setValue={(x: string) => {
  405. setCidrRange(x);
  406. }}
  407. label="VPC CIDR range"
  408. placeholder="ex: 10.78.0.0/16"
  409. />
  410. </>
  411. )}
  412. </>
  413. );
  414. };
  415. const renderForm = () => {
  416. // Render simplified form if initial create
  417. if (!props.clusterId) {
  418. return (
  419. <>
  420. <Text size={16}>Select an Azure location and tier</Text>
  421. <Spacer y={1} />
  422. <Text color="helper">
  423. Porter will automatically provision your infrastructure with the
  424. specified configuration.
  425. </Text>
  426. <Spacer height="10px" />
  427. {renderSimpleSettings()}
  428. </>
  429. );
  430. }
  431. // If settings, update full form
  432. return (
  433. <>
  434. <Heading isAtTop>AKS configuration</Heading>
  435. <Spacer y={0.75} />
  436. {renderSimpleSettings()}
  437. {renderAdvancedSettings()}
  438. </>
  439. );
  440. };
  441. return (
  442. <>
  443. <StyledForm>{renderForm()}</StyledForm>
  444. <Button
  445. disabled={isDisabled()}
  446. onClick={createCluster}
  447. status={getStatus()}
  448. >
  449. Provision
  450. </Button>
  451. {!currentProject?.enable_reprovision && currentCluster && (
  452. <>
  453. <Spacer y={1} />
  454. <Text>
  455. Updates to the cluster are disabled on this project. Enable
  456. re-provisioning by contacting{" "}
  457. <a href="mailto:support@porter.run">Porter Support</a>.
  458. </Text>
  459. </>
  460. )}
  461. {user.isPorterUser && (
  462. <>
  463. <Spacer y={1} />
  464. <Text color="yellow">Visible to Admin Only</Text>
  465. <Button color="red" onClick={createCluster} status={getStatus()}>
  466. Override Provision
  467. </Button>
  468. </>
  469. )}
  470. </>
  471. );
  472. };
  473. export default withRouter(AzureProvisionerSettings);
  474. const ExpandHeader = styled.div<{ isExpanded: boolean }>`
  475. display: flex;
  476. align-items: center;
  477. cursor: pointer;
  478. > i {
  479. margin-right: 7px;
  480. margin-left: -7px;
  481. transform: ${(props) =>
  482. props.isExpanded ? "rotate(0deg)" : "rotate(-90deg)"};
  483. transition: transform 0.1s ease;
  484. }
  485. `;
  486. const StyledForm = styled.div`
  487. position: relative;
  488. padding: 30px 30px 25px;
  489. border-radius: 5px;
  490. background: ${({ theme }) => theme.fg};
  491. border: 1px solid #494b4f;
  492. font-size: 13px;
  493. margin-bottom: 30px;
  494. `;
  495. const DEFAULT_ERROR_MESSAGE =
  496. "An error occurred while provisioning your infrastructure. Please confirm you have completed all required setup as described in our docs, and try again. If issues persist, contact support@porter.run.";
  497. const AZURE_CORE_QUOTA_ERROR_MESSAGE =
  498. "Your Azure subscription has reached a vCPU core quota in the location";
  499. const AZURE_MISSING_RESOURCE_PROVIDER_MESSAGE =
  500. "Your Azure subscription is missing required resource providers";
  501. const errorMessageToModal = (errorMessage: string) => {
  502. switch (errorMessage) {
  503. case AZURE_CORE_QUOTA_ERROR_MESSAGE:
  504. return (
  505. <>
  506. <Text size={16} weight={500}>
  507. Requesting more cores
  508. </Text>
  509. <Spacer y={1} />
  510. <Text color="helper">
  511. You will need to request a quota increase for vCPUs in your region.
  512. </Text>
  513. <Spacer y={1} />
  514. <Step number={1}>
  515. Log into
  516. <Spacer inline width="5px" />
  517. <Link to="https://login.microsoftonline.com/" target="_blank">
  518. your Azure account
  519. </Link>
  520. .
  521. </Step>
  522. <Spacer y={1} />
  523. <Step number={2}>
  524. Navigate to
  525. <Spacer inline width="5px" />
  526. <Link
  527. to="https://portal.azure.com/#view/Microsoft_Azure_Billing/SubscriptionsBlade"
  528. target="_blank"
  529. >
  530. the Subscriptions page
  531. </Link>
  532. <Spacer inline width="5px" />
  533. and select the subscription you are using to provision Porter.
  534. </Step>
  535. <Spacer y={1} />
  536. <Step number={3}>
  537. Select "Usage + Quotas" under "Settings" from the left panel.
  538. </Step>
  539. <Spacer y={1} />
  540. <Step number={4}>
  541. Select "Compute" and search for the quotas that have reached usage
  542. limits in your region. Request an increase by clicking the pencil
  543. icon on the far right.
  544. </Step>
  545. <Spacer y={1} />
  546. <Text color="helper">
  547. We recommend an initial quota of 20 vCPUs for both Total Regional
  548. Cores and Standard Basv2 Family.
  549. </Text>
  550. <Spacer y={1} />
  551. <Step number={5}>
  552. Once the request has been approved, return to Porter and retry the
  553. provision.
  554. </Step>
  555. <Spacer y={1} />
  556. <Text color="helper">
  557. Quota increases can take several minutes to process. If Azure is
  558. unable to automatically increase the quota, create a support request
  559. as prompted by Azure. Requests are usually fulfilled in a few hours.
  560. </Text>
  561. </>
  562. );
  563. case AZURE_MISSING_RESOURCE_PROVIDER_MESSAGE:
  564. return (
  565. <>
  566. <Text size={16} weight={500}>
  567. Registering required resource providers
  568. </Text>
  569. <Spacer y={1} />
  570. <Text color="helper">
  571. You will need to register all of the following resource providers to
  572. your Azure subscription before provisioning: Capacity, Compute,
  573. ContainerRegistry, ContainerService, ManagedIdentity, Network,
  574. OperationalInsights, OperationsManagement, ResourceGraph, Resources,
  575. Storage
  576. </Text>
  577. <Spacer y={1} />
  578. <Step number={1}>
  579. Log into
  580. <Spacer inline width="5px" />
  581. <Link to="https://login.microsoftonline.com/" target="_blank">
  582. your Azure account
  583. </Link>
  584. .
  585. </Step>
  586. <Spacer y={1} />
  587. <Step number={2}>
  588. Navigate to
  589. <Spacer inline width="5px" />
  590. <Link
  591. to="https://portal.azure.com/#view/Microsoft_Azure_Billing/SubscriptionsBlade"
  592. target="_blank"
  593. >
  594. the Subscriptions page
  595. </Link>
  596. <Spacer inline width="5px" />
  597. and select the subscription you are using to provision Porter.
  598. </Step>
  599. <Spacer y={1} />
  600. <Step number={3}>
  601. Select "Resource Providers" under "Settings" from the left panel.
  602. </Step>
  603. <Spacer y={1} />
  604. <Step number={4}>
  605. Search for each required resource provider and select "Register"
  606. from the top menu bar if it is not already registered.
  607. </Step>
  608. <Spacer y={1} />
  609. <Step number={5}>
  610. After confirming that all providers are registered, return to Porter
  611. and retry the provision.
  612. </Step>
  613. </>
  614. );
  615. default:
  616. return null;
  617. }
  618. };