useCluster.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. import { useContext, useState } from "react";
  2. import { Contract, PreflightCheckRequest } from "@porter-dev/api-contracts";
  3. import { useQuery } from "@tanstack/react-query";
  4. import axios from "axios";
  5. import { match } from "ts-pattern";
  6. import { z } from "zod";
  7. import {
  8. clientClusterContractFromProto,
  9. updateExistingClusterContract,
  10. } from "lib/clusters";
  11. import {
  12. CloudProviderAWS, CloudProviderAzure,
  13. CloudProviderGCP,
  14. SUPPORTED_CLOUD_PROVIDERS,
  15. } from "lib/clusters/constants";
  16. import {
  17. clusterStateValidator,
  18. clusterValidator,
  19. contractValidator,
  20. createContractResponseValidator,
  21. nodeValidator,
  22. preflightCheckValidator,
  23. type APIContract,
  24. type ClientCluster,
  25. type ClientClusterContract,
  26. type ClientNode,
  27. type ClientPreflightCheck,
  28. type ClusterState,
  29. type ContractCondition,
  30. type UpdateClusterResponse,
  31. } from "lib/clusters/types";
  32. import api from "shared/api";
  33. import { Context } from "shared/Context";
  34. import { valueExists } from "shared/util";
  35. type TUseClusterList = {
  36. clusters: ClientCluster[];
  37. isLoading: boolean;
  38. };
  39. export const useClusterList = (): TUseClusterList => {
  40. const { currentProject } = useContext(Context);
  41. const clusterReq = useQuery(
  42. ["getClusters", currentProject?.id],
  43. async () => {
  44. if (!currentProject?.id || currentProject.id === -1) {
  45. return;
  46. }
  47. const res = await api.getClusters(
  48. "<token>",
  49. {},
  50. { id: currentProject.id }
  51. );
  52. const parsed = await z.array(clusterValidator).parseAsync(res.data);
  53. const filtered = parsed
  54. .map((c) => {
  55. const cloudProviderMatch = SUPPORTED_CLOUD_PROVIDERS.find(
  56. (s) => s.name === c.cloud_provider
  57. );
  58. return cloudProviderMatch
  59. ? { ...c, cloud_provider: cloudProviderMatch }
  60. : null;
  61. })
  62. .filter(valueExists);
  63. const latestContractsRes = await api.getContracts(
  64. "<token>",
  65. { latest: true },
  66. { project_id: currentProject.id }
  67. );
  68. const latestContracts = await z
  69. .array(contractValidator)
  70. .parseAsync(latestContractsRes.data);
  71. return filtered
  72. .map((c) => {
  73. const latestContract = latestContracts.find(
  74. (contract) => contract.cluster_id === c.id
  75. );
  76. // if this cluster has no latest contract, don't include it
  77. if (!latestContract) {
  78. return undefined;
  79. }
  80. const latestClientContract = clientClusterContractFromProto(
  81. Contract.fromJsonString(atob(latestContract.base64_contract), {
  82. ignoreUnknownFields: true,
  83. })
  84. );
  85. // if we can't parse the latest contract, don't include it
  86. if (!latestClientContract) {
  87. return undefined;
  88. }
  89. return {
  90. ...c,
  91. contract: {
  92. ...latestContract,
  93. config: latestClientContract,
  94. },
  95. };
  96. })
  97. .filter(valueExists);
  98. },
  99. {
  100. enabled: !!currentProject && currentProject.id !== -1,
  101. }
  102. );
  103. return {
  104. clusters: clusterReq.data ?? [],
  105. isLoading: clusterReq.isLoading,
  106. };
  107. };
  108. type TUseCluster = {
  109. cluster: ClientCluster | undefined;
  110. isLoading: boolean;
  111. isError: boolean;
  112. };
  113. export const useCluster = ({
  114. clusterId,
  115. refetchInterval,
  116. }: {
  117. clusterId: number | undefined;
  118. refetchInterval?: number;
  119. }): TUseCluster => {
  120. const { currentProject } = useContext(Context);
  121. const clusterReq = useQuery(
  122. ["getCluster", currentProject?.id, clusterId],
  123. async () => {
  124. if (
  125. !currentProject?.id ||
  126. currentProject.id === -1 ||
  127. !clusterId ||
  128. clusterId === -1
  129. ) {
  130. return;
  131. }
  132. // get the cluster + match with what we know
  133. const res = await api.getCluster(
  134. "<token>",
  135. {},
  136. { project_id: currentProject.id, cluster_id: clusterId }
  137. );
  138. const parsed = await clusterValidator.parseAsync(res.data);
  139. const cloudProviderMatch = SUPPORTED_CLOUD_PROVIDERS.find(
  140. (s) => s.name === parsed.cloud_provider
  141. );
  142. if (!cloudProviderMatch) {
  143. return;
  144. }
  145. // get the latest contract
  146. const latestContractsRes = await api.getContracts(
  147. "<token>",
  148. { latest: true, cluster_id: clusterId },
  149. { project_id: currentProject.id }
  150. );
  151. const latestContracts = await z
  152. .array(contractValidator)
  153. .parseAsync(latestContractsRes.data);
  154. if (latestContracts.length !== 1) {
  155. return;
  156. }
  157. const latestClientContract = clientClusterContractFromProto(
  158. Contract.fromJsonString(atob(latestContracts[0].base64_contract), {
  159. ignoreUnknownFields: true,
  160. })
  161. );
  162. if (!latestClientContract) {
  163. return;
  164. }
  165. // get the latest state
  166. const stateRes = await api.getClusterState(
  167. "<token>",
  168. {},
  169. { project_id: currentProject.id, cluster_id: clusterId }
  170. );
  171. const state = await clusterStateValidator.parseAsync(stateRes.data);
  172. return {
  173. ...parsed,
  174. cloud_provider: cloudProviderMatch,
  175. state,
  176. contract: {
  177. ...latestContracts[0],
  178. config: latestClientContract,
  179. },
  180. };
  181. },
  182. {
  183. enabled:
  184. !!currentProject &&
  185. currentProject.id !== -1 &&
  186. !!clusterId &&
  187. clusterId !== -1,
  188. refetchInterval,
  189. }
  190. );
  191. return {
  192. cluster: clusterReq.data,
  193. isLoading: clusterReq.isLoading,
  194. isError: clusterReq.isError,
  195. };
  196. };
  197. export const useLatestClusterContract = ({
  198. clusterId,
  199. }: {
  200. clusterId: number | undefined;
  201. }): {
  202. contractDB: APIContract | undefined;
  203. contractProto: Contract | undefined;
  204. clientContract: ClientClusterContract | undefined;
  205. clusterCondition: ContractCondition | undefined;
  206. isLoading: boolean;
  207. isError: boolean;
  208. } => {
  209. const { currentProject } = useContext(Context);
  210. const latestClusterContractReq = useQuery(
  211. ["getLatestClusterContract", currentProject?.id, clusterId],
  212. async () => {
  213. if (
  214. !currentProject?.id ||
  215. currentProject.id === -1 ||
  216. !clusterId ||
  217. clusterId === -1
  218. ) {
  219. return;
  220. }
  221. const res = await api.getContracts(
  222. "<token>",
  223. { cluster_id: clusterId, latest: true },
  224. { project_id: currentProject.id }
  225. );
  226. const data = await z.array(contractValidator).parseAsync(res.data);
  227. if (data.length !== 1) {
  228. return;
  229. }
  230. const contractDB = data[0];
  231. const contractProto = Contract.fromJsonString(
  232. atob(contractDB.base64_contract),
  233. {
  234. ignoreUnknownFields: true,
  235. }
  236. );
  237. const clientContract = clientClusterContractFromProto(contractProto);
  238. return {
  239. contractDB,
  240. contractProto,
  241. clientContract,
  242. clusterCondition: contractDB.condition,
  243. };
  244. },
  245. {
  246. refetchInterval: 3000,
  247. enabled:
  248. !!currentProject &&
  249. currentProject.id !== -1 &&
  250. !!clusterId &&
  251. clusterId !== -1,
  252. }
  253. );
  254. return {
  255. contractDB: latestClusterContractReq.data?.contractDB,
  256. contractProto: latestClusterContractReq.data?.contractProto,
  257. clientContract: latestClusterContractReq.data?.clientContract,
  258. clusterCondition: latestClusterContractReq.data?.clusterCondition,
  259. isLoading: latestClusterContractReq.isLoading,
  260. isError: latestClusterContractReq.isError,
  261. };
  262. };
  263. type TUseClusterState = {
  264. state: ClusterState | undefined;
  265. isLoading: boolean;
  266. isError: boolean;
  267. };
  268. export const useClusterState = ({
  269. clusterId,
  270. }: {
  271. clusterId: number | undefined;
  272. }): TUseClusterState => {
  273. const { currentProject } = useContext(Context);
  274. const clusterStateReq = useQuery(
  275. ["getClusterState", currentProject?.id, clusterId],
  276. async () => {
  277. if (
  278. !currentProject?.id ||
  279. currentProject.id === -1 ||
  280. !clusterId ||
  281. clusterId === -1
  282. ) {
  283. return;
  284. }
  285. const res = await api.getClusterState(
  286. "<token>",
  287. {},
  288. { project_id: currentProject.id, cluster_id: clusterId }
  289. );
  290. const parsed = await clusterStateValidator.parseAsync(res.data);
  291. return parsed;
  292. },
  293. {
  294. enabled:
  295. !!currentProject &&
  296. currentProject.id !== -1 &&
  297. !!clusterId &&
  298. clusterId !== -1,
  299. refetchInterval: 5000,
  300. }
  301. );
  302. return {
  303. state: clusterStateReq.data,
  304. isLoading: clusterStateReq.isLoading,
  305. isError: clusterStateReq.isError,
  306. };
  307. };
  308. type TUseUpdateCluster = {
  309. updateCluster: (
  310. clientContract: ClientClusterContract,
  311. baseContract: Contract
  312. ) => Promise<UpdateClusterResponse>;
  313. isHandlingPreflightChecks: boolean;
  314. isCreatingContract: boolean;
  315. };
  316. export const useUpdateCluster = ({
  317. projectId,
  318. }: {
  319. projectId: number | undefined;
  320. }): TUseUpdateCluster => {
  321. const [isHandlingPreflightChecks, setIsHandlingPreflightChecks] =
  322. useState<boolean>(false);
  323. const [isCreatingContract, setIsCreatingContract] = useState<boolean>(false);
  324. const updateCluster = async (
  325. clientContract: ClientClusterContract,
  326. baseContract: Contract
  327. ): Promise<UpdateClusterResponse> => {
  328. if (!projectId) {
  329. throw new Error("Project ID is missing");
  330. }
  331. if (!baseContract.cluster) {
  332. throw new Error("Cluster is missing");
  333. }
  334. const newContract = new Contract({
  335. ...baseContract,
  336. cluster: updateExistingClusterContract(
  337. clientContract,
  338. baseContract.cluster
  339. ),
  340. });
  341. setIsHandlingPreflightChecks(true);
  342. try {
  343. const preflightCheckResp = await api.preflightCheck(
  344. "<token>",
  345. new PreflightCheckRequest({
  346. contract: newContract,
  347. }),
  348. {
  349. id: projectId,
  350. }
  351. );
  352. const parsed = await preflightCheckValidator.parseAsync(
  353. preflightCheckResp.data
  354. );
  355. if (parsed.errors.length > 0) {
  356. const cloudProviderSpecificChecks = match(
  357. clientContract.cluster.cloudProvider
  358. )
  359. .with("AWS", () => CloudProviderAWS.preflightChecks)
  360. .with("GCP", () => CloudProviderGCP.preflightChecks)
  361. .with("Azure", () => CloudProviderAzure.preflightChecks)
  362. .otherwise(() => []);
  363. const clientPreflightChecks: ClientPreflightCheck[] = parsed.errors
  364. .map((e) => {
  365. const preflightCheckMatch = cloudProviderSpecificChecks.find(
  366. (cloudProviderCheck) => e.name === cloudProviderCheck.name
  367. );
  368. if (!preflightCheckMatch) {
  369. return undefined;
  370. }
  371. return {
  372. title: preflightCheckMatch.displayName,
  373. status: "failure" as const,
  374. error: {
  375. detail: e.error.message,
  376. metadata: e.error.metadata,
  377. resolution: preflightCheckMatch.resolution,
  378. },
  379. };
  380. })
  381. .filter(valueExists);
  382. return {
  383. preflightChecks: clientPreflightChecks,
  384. };
  385. }
  386. // otherwise, continue to create the contract
  387. } catch (err) {
  388. throw new Error(
  389. getErrorMessageFromNetworkCall(err, "Cluster preflight checks")
  390. );
  391. } finally {
  392. setIsHandlingPreflightChecks(false);
  393. }
  394. setIsCreatingContract(true);
  395. try {
  396. const createContractResp = await api.createContract(
  397. "<token>",
  398. newContract,
  399. {
  400. project_id: projectId,
  401. }
  402. );
  403. const parsed = await createContractResponseValidator.parseAsync(
  404. createContractResp.data
  405. );
  406. return {
  407. createContractResponse: parsed,
  408. };
  409. } catch (err) {
  410. throw new Error(getErrorMessageFromNetworkCall(err, "Cluster creation"));
  411. } finally {
  412. setIsCreatingContract(false);
  413. }
  414. };
  415. return {
  416. updateCluster,
  417. isHandlingPreflightChecks,
  418. isCreatingContract,
  419. };
  420. };
  421. type TUseClusterNodeList = {
  422. nodes: ClientNode[];
  423. isLoading: boolean;
  424. };
  425. export const useClusterNodeList = ({
  426. clusterId,
  427. refetchInterval = 3000,
  428. }: {
  429. clusterId: number | undefined;
  430. refetchInterval?: number;
  431. }): TUseClusterNodeList => {
  432. const { currentProject } = useContext(Context);
  433. const clusterNodesReq = useQuery(
  434. ["getClusterNodes", currentProject?.id, clusterId],
  435. async () => {
  436. if (
  437. !currentProject?.id ||
  438. currentProject.id === -1 ||
  439. !clusterId ||
  440. clusterId === -1
  441. ) {
  442. return;
  443. }
  444. const res = await api.getClusterNodes(
  445. "<token>",
  446. {},
  447. { project_id: currentProject.id, cluster_id: clusterId }
  448. );
  449. const parsed = await z.array(nodeValidator).parseAsync(res.data);
  450. return parsed
  451. .map((n) => {
  452. const nodeGroupType = match(n.labels["porter.run/workload-kind"])
  453. .with("application", () => "APPLICATION" as const)
  454. .with("system", () => "SYSTEM" as const)
  455. .with("monitoring", () => "MONITORING" as const)
  456. .with("custom", () => "CUSTOM" as const)
  457. .otherwise(() => "UNKNOWN" as const);
  458. if (nodeGroupType === "UNKNOWN") {
  459. return undefined;
  460. }
  461. const instanceType = n.labels["node.kubernetes.io/instance-type"];
  462. if (!instanceType) {
  463. return undefined;
  464. }
  465. return {
  466. nodeGroupType,
  467. instanceType,
  468. };
  469. })
  470. .filter(valueExists);
  471. },
  472. {
  473. refetchInterval,
  474. enabled:
  475. !!currentProject &&
  476. currentProject.id !== -1 &&
  477. !!clusterId &&
  478. clusterId !== -1,
  479. }
  480. );
  481. return {
  482. nodes: clusterNodesReq.data ?? [],
  483. isLoading: clusterNodesReq.isLoading,
  484. };
  485. };
  486. const getErrorMessageFromNetworkCall = (
  487. err: unknown,
  488. networkCallDescription: string
  489. ): string => {
  490. if (axios.isAxiosError(err)) {
  491. const parsed = z
  492. .object({ error: z.string() })
  493. .safeParse(err.response?.data);
  494. if (parsed.success) {
  495. return `${networkCallDescription} failed: ${parsed.data.error}`;
  496. }
  497. }
  498. return `${networkCallDescription} failed: please try again or contact support@porter.run if the error persists.`;
  499. };