services.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717
  1. import {
  2. Service,
  3. ServiceType,
  4. type PorterApp,
  5. } from "@porter-dev/api-contracts";
  6. import _ from "lodash";
  7. import { match } from "ts-pattern";
  8. import { z } from "zod";
  9. import { type BuildOptions } from "./build";
  10. import {
  11. autoscalingValidator,
  12. deserializeAutoscaling,
  13. deserializeHealthCheck,
  14. domainsValidator,
  15. healthcheckValidator,
  16. ingressAnnotationsValidator,
  17. serializeAutoscaling,
  18. serializeHealth,
  19. serviceBooleanValidator,
  20. ServiceField,
  21. serviceNumberValidator,
  22. serviceStringValidator,
  23. type SerializedAutoscaling,
  24. type SerializedHealthcheck,
  25. } from "./values";
  26. const LAUNCHER_PREFIX = "/cnb/lifecycle/launcher ";
  27. export type DetectedServices = {
  28. services: ClientService[];
  29. predeploy?: ClientService;
  30. build?: BuildOptions;
  31. previews?: {
  32. services: ClientService[];
  33. predeploy?: ClientService;
  34. variables?: Record<string, string>;
  35. };
  36. };
  37. type ClientServiceType = "web" | "worker" | "job" | "predeploy";
  38. type ClientWebService = ClientService & { config: ClientWebConfig };
  39. export const isClientWebService = (
  40. service: ClientService
  41. ): service is ClientWebService => {
  42. return service.config.type === "web";
  43. };
  44. type ClientWorkerService = ClientService & { config: ClientWorkerConfig };
  45. export const isClientWorkerService = (
  46. service: ClientService
  47. ): service is ClientWorkerService => {
  48. return service.config.type === "worker";
  49. };
  50. type ClientJobService = ClientService & { config: ClientJobConfig };
  51. export const isClientJobService = (
  52. service: ClientService
  53. ): service is ClientJobService => {
  54. return service.config.type === "job";
  55. };
  56. const webConfigValidator = z.object({
  57. type: z.literal("web"),
  58. autoscaling: autoscalingValidator.optional(),
  59. domains: domainsValidator,
  60. healthCheck: healthcheckValidator.optional(),
  61. private: serviceBooleanValidator.optional(),
  62. ingressAnnotations: ingressAnnotationsValidator.default([]),
  63. disableTls: serviceBooleanValidator.optional(),
  64. });
  65. export type ClientWebConfig = z.infer<typeof webConfigValidator>;
  66. const workerConfigValidator = z.object({
  67. type: z.literal("worker"),
  68. autoscaling: autoscalingValidator.optional(),
  69. healthCheck: healthcheckValidator.optional(),
  70. });
  71. export type ClientWorkerConfig = z.infer<typeof workerConfigValidator>;
  72. const jobConfigValidator = z.object({
  73. type: z.literal("job"),
  74. allowConcurrent: serviceBooleanValidator.optional(),
  75. cron: serviceStringValidator,
  76. suspendCron: serviceBooleanValidator.optional(),
  77. timeoutSeconds: serviceNumberValidator,
  78. });
  79. export type ClientJobConfig = z.infer<typeof jobConfigValidator>;
  80. const predeployConfigValidator = z.object({
  81. type: z.literal("predeploy"),
  82. });
  83. export type ClientPredeployConfig = z.infer<typeof predeployConfigValidator>;
  84. // serviceValidator is the validator for a ClientService
  85. // This is used to validate a service when creating or updating an app
  86. export const serviceValidator = z.object({
  87. expanded: z.boolean().default(false).optional(),
  88. canDelete: z.boolean().default(true).optional(),
  89. name: serviceStringValidator,
  90. run: serviceStringValidator,
  91. instances: serviceNumberValidator,
  92. port: serviceNumberValidator,
  93. cpuCores: serviceNumberValidator,
  94. ramMegabytes: serviceNumberValidator,
  95. gpuCoresNvidia: serviceNumberValidator,
  96. gpu: z.object({
  97. enabled: serviceBooleanValidator,
  98. gpuCoresNvidia: serviceNumberValidator,
  99. }),
  100. smartOptimization: serviceBooleanValidator.optional(),
  101. terminationGracePeriodSeconds: serviceNumberValidator.optional(),
  102. config: z.discriminatedUnion("type", [
  103. webConfigValidator,
  104. workerConfigValidator,
  105. jobConfigValidator,
  106. predeployConfigValidator,
  107. ]),
  108. domainDeletions: z
  109. .object({
  110. name: z.string(),
  111. })
  112. .array()
  113. .default([]),
  114. ingressAnnotationDeletions: z
  115. .object({
  116. key: z.string(),
  117. })
  118. .array()
  119. .default([]),
  120. });
  121. export type ClientService = z.infer<typeof serviceValidator>;
  122. // SerializedService is just the values of a Service without any override information
  123. // This is used as an intermediate step to convert a ClientService to a protobuf Service
  124. export type SerializedService = {
  125. name: string;
  126. run: string;
  127. instances: number;
  128. port: number;
  129. cpuCores: number;
  130. ramMegabytes: number;
  131. smartOptimization?: boolean;
  132. gpuCoresNvidia: number;
  133. gpu?: {
  134. enabled: boolean;
  135. gpuCoresNvidia: number;
  136. };
  137. terminationGracePeriodSeconds?: number;
  138. config:
  139. | {
  140. type: "web";
  141. domains: Array<{
  142. name: string;
  143. }>;
  144. autoscaling?: SerializedAutoscaling;
  145. healthCheck?: SerializedHealthcheck;
  146. private?: boolean;
  147. disableTls?: boolean;
  148. ingressAnnotations: Record<string, string>;
  149. }
  150. | {
  151. type: "worker";
  152. autoscaling?: SerializedAutoscaling;
  153. healthCheck?: SerializedHealthcheck;
  154. }
  155. | {
  156. type: "job";
  157. allowConcurrent?: boolean;
  158. cron: string;
  159. suspendCron?: boolean;
  160. timeoutSeconds: number;
  161. }
  162. | {
  163. type: "predeploy";
  164. };
  165. };
  166. export function isPredeployService(
  167. service: SerializedService | ClientService
  168. ): boolean {
  169. return service.config.type === "predeploy";
  170. }
  171. export function prefixSubdomain(subdomain: string): string {
  172. if (subdomain.startsWith("https://") || subdomain.startsWith("http://")) {
  173. return subdomain;
  174. }
  175. return "https://" + subdomain;
  176. }
  177. export function uniqueServices(app: PorterApp): Service[] {
  178. if (app.serviceList?.length) {
  179. // dedupe services by name, favoring the first instance
  180. return _.uniqBy(app.serviceList, "name");
  181. }
  182. const servicesFromMap = Object.entries(app.services ?? {}).map(
  183. ([name, service]) => {
  184. return new Service({
  185. ...service,
  186. name,
  187. });
  188. }
  189. );
  190. return servicesFromMap;
  191. }
  192. export function defaultSerialized({
  193. name,
  194. type,
  195. defaultCPU = 0.1,
  196. defaultRAM = 256,
  197. }: {
  198. name: string;
  199. type: ClientServiceType;
  200. defaultCPU?: number;
  201. defaultRAM?: number;
  202. }): SerializedService {
  203. const baseService = {
  204. name,
  205. run: "",
  206. instances: 1,
  207. port: 3000,
  208. cpuCores: defaultCPU,
  209. ramMegabytes: defaultRAM,
  210. gpuCoresNvidia: 0,
  211. gpu: {
  212. enabled: false,
  213. gpuCoresNvidia: 0,
  214. },
  215. smartOptimization: false,
  216. };
  217. const defaultAutoscaling: SerializedAutoscaling = {
  218. enabled: false,
  219. minInstances: 1,
  220. maxInstances: 10,
  221. cpuThresholdPercent: 50,
  222. memoryThresholdPercent: 50,
  223. };
  224. const defaultWebHealthCheck: SerializedHealthcheck = {
  225. enabled: false,
  226. httpPath: "/healthz",
  227. };
  228. const defaultWorkerHealthCheck: SerializedHealthcheck = {
  229. enabled: false,
  230. command: "./healthz.sh",
  231. };
  232. return match(type)
  233. .with("web", () => ({
  234. ...baseService,
  235. config: {
  236. type: "web" as const,
  237. autoscaling: defaultAutoscaling,
  238. healthCheck: defaultWebHealthCheck,
  239. domains: [],
  240. private: false,
  241. ingressAnnotations: {},
  242. disableTls: false,
  243. },
  244. }))
  245. .with("worker", () => ({
  246. ...baseService,
  247. config: {
  248. type: "worker" as const,
  249. autoscaling: defaultAutoscaling,
  250. healthCheck: defaultWorkerHealthCheck,
  251. },
  252. }))
  253. .with("job", () => ({
  254. ...baseService,
  255. config: {
  256. type: "job" as const,
  257. allowConcurrent: false,
  258. cron: "",
  259. suspendCron: false,
  260. timeoutSeconds: 3600,
  261. },
  262. }))
  263. .with("predeploy", () => ({
  264. ...baseService,
  265. config: {
  266. type: "predeploy" as const,
  267. },
  268. }))
  269. .exhaustive();
  270. }
  271. // serializeService converts a ClientService to a SerializedService
  272. // A SerializedService holds just the values of a ClientService
  273. // These values can be used to create a protobuf Service
  274. export function serializeService(service: ClientService): SerializedService {
  275. return Object.freeze({
  276. name: service.name.value,
  277. run: service.run.value,
  278. instances: service.instances.value,
  279. port: service.port.value,
  280. cpuCores: service.cpuCores.value,
  281. ramMegabytes: Math.round(service.ramMegabytes.value), // RAM must be an integer
  282. smartOptimization: service.smartOptimization?.value,
  283. gpuCoresNvidia: service.gpuCoresNvidia.value,
  284. gpu: {
  285. enabled: service.gpu.enabled.value,
  286. gpuCoresNvidia: service.gpu.gpuCoresNvidia.value,
  287. },
  288. terminationGracePeriodSeconds: service.terminationGracePeriodSeconds?.value,
  289. config: match(service.config)
  290. .with({ type: "web" }, (config) =>
  291. Object.freeze({
  292. type: "web" as const,
  293. autoscaling: serializeAutoscaling({
  294. autoscaling: config.autoscaling,
  295. }),
  296. healthCheck: serializeHealth({ health: config.healthCheck }),
  297. domains: config.domains.map((domain) => ({
  298. name: domain.name.value
  299. .replace("https://", "")
  300. .replace("http://", ""),
  301. })),
  302. ingressAnnotations: Object.fromEntries(
  303. config.ingressAnnotations
  304. .filter((a) => a.key.length > 0 && a.value.length > 0)
  305. .map((annotation) => [annotation.key, annotation.value])
  306. ),
  307. private: config.private?.value,
  308. disableTls: config.disableTls?.value,
  309. })
  310. )
  311. .with({ type: "worker" }, (config) =>
  312. Object.freeze({
  313. type: "worker" as const,
  314. autoscaling: serializeAutoscaling({
  315. autoscaling: config.autoscaling,
  316. }),
  317. healthCheck: serializeHealth({ health: config.healthCheck }),
  318. })
  319. )
  320. .with({ type: "job" }, (config) =>
  321. Object.freeze({
  322. type: "job" as const,
  323. allowConcurrent: config.allowConcurrent?.value,
  324. cron: config.cron.value,
  325. suspendCron: config.suspendCron?.value,
  326. timeoutSeconds: config.timeoutSeconds.value,
  327. })
  328. )
  329. .with({ type: "predeploy" }, () =>
  330. Object.freeze({
  331. type: "predeploy" as const,
  332. })
  333. )
  334. .exhaustive(),
  335. });
  336. }
  337. // deserializeService converts a SerializedService to a ClientService
  338. // A deserialized ClientService represents the state of a service in the UI and which fields are editable
  339. export function deserializeService({
  340. service,
  341. override,
  342. expanded,
  343. setDefaults = true,
  344. lockDeletions = false,
  345. }: {
  346. service: SerializedService;
  347. override?: SerializedService;
  348. expanded?: boolean;
  349. setDefaults?: boolean;
  350. lockDeletions?: boolean;
  351. }): ClientService {
  352. const baseService = {
  353. expanded,
  354. canDelete: !override && !lockDeletions,
  355. name: ServiceField.string(service.name, override?.name),
  356. run: ServiceField.string(service.run, override?.run),
  357. instances: ServiceField.number(service.instances, override?.instances),
  358. port: ServiceField.number(service.port, override?.port),
  359. cpuCores: ServiceField.number(service.cpuCores, override?.cpuCores),
  360. gpu: {
  361. enabled: ServiceField.boolean(
  362. service.gpu?.enabled,
  363. override?.gpu?.enabled
  364. ),
  365. gpuCoresNvidia: ServiceField.number(
  366. service.gpu?.gpuCoresNvidia ?? 0,
  367. override?.gpu?.gpuCoresNvidia
  368. ),
  369. },
  370. gpuCoresNvidia: ServiceField.number(
  371. service.gpuCoresNvidia,
  372. override?.gpuCoresNvidia
  373. ),
  374. ramMegabytes: ServiceField.number(
  375. service.ramMegabytes,
  376. override?.ramMegabytes
  377. ),
  378. smartOptimization: ServiceField.boolean(
  379. service.smartOptimization,
  380. override?.smartOptimization
  381. ),
  382. terminationGracePeriodSeconds: setDefaults
  383. ? ServiceField.number(
  384. service.terminationGracePeriodSeconds ?? 30, // if not set explicitly, assume the value is at default (30 seconds)
  385. override?.terminationGracePeriodSeconds
  386. )
  387. : undefined,
  388. domainDeletions: [],
  389. ingressAnnotationDeletions: [],
  390. };
  391. if (
  392. !baseService.run.readOnly &&
  393. baseService.run.value.startsWith(LAUNCHER_PREFIX)
  394. ) {
  395. // trim launcher prefix from run command
  396. baseService.run = ServiceField.string(
  397. baseService.run.value.substring(LAUNCHER_PREFIX.length),
  398. override?.run
  399. );
  400. }
  401. return match(service.config)
  402. .with({ type: "web" }, (config) => {
  403. const overrideWebConfig =
  404. override?.config.type === "web" ? override.config : undefined;
  405. const uniqueDomains = Array.from(
  406. new Set([
  407. ...config.domains.map((domain) => domain.name),
  408. ...(overrideWebConfig?.domains ?? []).map((domain) => domain.name),
  409. ])
  410. ).map((domain) => ({ name: domain }));
  411. const uniqueAnnotations = _.uniqBy(
  412. [
  413. ...Object.entries(overrideWebConfig?.ingressAnnotations ?? {}).map(
  414. (annotation) => {
  415. return {
  416. key: annotation[0],
  417. value: annotation[1],
  418. readOnly: true,
  419. };
  420. }
  421. ),
  422. ...Object.entries(config.ingressAnnotations).map((annotation) => {
  423. return {
  424. key: annotation[0],
  425. value: annotation[1],
  426. readOnly: false,
  427. };
  428. }),
  429. ],
  430. "key"
  431. );
  432. return {
  433. ...baseService,
  434. config: {
  435. type: "web" as const,
  436. autoscaling: deserializeAutoscaling({
  437. autoscaling: config.autoscaling,
  438. override: overrideWebConfig?.autoscaling,
  439. setDefaults,
  440. }),
  441. healthCheck: deserializeHealthCheck({
  442. health: config.healthCheck,
  443. override: overrideWebConfig?.healthCheck,
  444. setDefaults,
  445. }),
  446. domains: uniqueDomains.map((domain) => ({
  447. name: ServiceField.string(
  448. domain.name,
  449. overrideWebConfig?.domains.find(
  450. (overrideDomain) => overrideDomain.name === domain.name
  451. )?.name
  452. ),
  453. })),
  454. ingressAnnotations: uniqueAnnotations,
  455. private:
  456. typeof config.private === "boolean" ||
  457. typeof overrideWebConfig?.private === "boolean"
  458. ? ServiceField.boolean(config.private, overrideWebConfig?.private)
  459. : setDefaults
  460. ? ServiceField.boolean(false, undefined)
  461. : undefined,
  462. disableTls:
  463. typeof config.disableTls === "boolean" ||
  464. typeof overrideWebConfig?.disableTls === "boolean"
  465. ? ServiceField.boolean(
  466. config.disableTls,
  467. overrideWebConfig?.disableTls
  468. )
  469. : setDefaults
  470. ? ServiceField.boolean(false, undefined)
  471. : undefined,
  472. },
  473. };
  474. })
  475. .with({ type: "worker" }, (config) => {
  476. const overrideWorkerConfig =
  477. override?.config.type === "worker" ? override.config : undefined;
  478. return {
  479. ...baseService,
  480. config: {
  481. type: "worker" as const,
  482. autoscaling: deserializeAutoscaling({
  483. autoscaling: config.autoscaling,
  484. override: overrideWorkerConfig?.autoscaling,
  485. setDefaults,
  486. }),
  487. healthCheck: deserializeHealthCheck({
  488. health: config.healthCheck,
  489. override: overrideWorkerConfig?.healthCheck,
  490. setDefaults,
  491. }),
  492. },
  493. };
  494. })
  495. .with({ type: "job" }, (config) => {
  496. const overrideJobConfig =
  497. override?.config.type === "job" ? override.config : undefined;
  498. return {
  499. ...baseService,
  500. config: {
  501. type: "job" as const,
  502. allowConcurrent:
  503. typeof config.allowConcurrent === "boolean" ||
  504. typeof overrideJobConfig?.allowConcurrent === "boolean"
  505. ? ServiceField.boolean(
  506. config.allowConcurrent,
  507. overrideJobConfig?.allowConcurrent
  508. )
  509. : setDefaults
  510. ? ServiceField.boolean(false, undefined)
  511. : undefined,
  512. cron: ServiceField.string(config.cron, overrideJobConfig?.cron),
  513. suspendCron:
  514. typeof config.suspendCron === "boolean" ||
  515. typeof overrideJobConfig?.suspendCron === "boolean"
  516. ? ServiceField.boolean(
  517. config.suspendCron,
  518. overrideJobConfig?.suspendCron
  519. )
  520. : setDefaults
  521. ? ServiceField.boolean(false, undefined)
  522. : undefined,
  523. timeoutSeconds:
  524. config.timeoutSeconds !== 0
  525. ? ServiceField.number(
  526. config.timeoutSeconds,
  527. overrideJobConfig?.timeoutSeconds
  528. )
  529. : setDefaults
  530. ? ServiceField.number(3600, overrideJobConfig?.timeoutSeconds)
  531. : ServiceField.number(0, overrideJobConfig?.timeoutSeconds),
  532. },
  533. };
  534. })
  535. .with({ type: "predeploy" }, () => ({
  536. ...baseService,
  537. config: {
  538. type: "predeploy" as const,
  539. },
  540. }))
  541. .exhaustive();
  542. }
  543. // getServiceTypeEnumProto converts the type of a ClientService to the protobuf ServiceType enum
  544. export const serviceTypeEnumProto = (type: ClientServiceType): ServiceType => {
  545. return match(type)
  546. .with("web", () => ServiceType.WEB)
  547. .with("worker", () => ServiceType.WORKER)
  548. .with("job", () => ServiceType.JOB)
  549. .with("predeploy", () => ServiceType.JOB)
  550. .exhaustive();
  551. };
  552. // serviceProto converts a SerializedService to the protobuf Service
  553. // This is used as an intermediate step to convert a ClientService to a protobuf Service
  554. export function serviceProto(service: SerializedService): Service {
  555. return match(service.config)
  556. .with(
  557. { type: "web" },
  558. (config) =>
  559. new Service({
  560. ...service,
  561. runOptional: service.run,
  562. instancesOptional: service.instances,
  563. type: serviceTypeEnumProto(config.type),
  564. config: {
  565. value: {
  566. ...config,
  567. },
  568. case: "webConfig",
  569. },
  570. })
  571. )
  572. .with(
  573. { type: "worker" },
  574. (config) =>
  575. new Service({
  576. ...service,
  577. runOptional: service.run,
  578. instancesOptional: service.instances,
  579. type: serviceTypeEnumProto(config.type),
  580. config: {
  581. value: {
  582. ...config,
  583. },
  584. case: "workerConfig",
  585. },
  586. })
  587. )
  588. .with(
  589. { type: "job" },
  590. (config) =>
  591. new Service({
  592. ...service,
  593. runOptional: service.run,
  594. instancesOptional: service.instances,
  595. type: serviceTypeEnumProto(config.type),
  596. config: {
  597. value: {
  598. ...config,
  599. allowConcurrentOptional: config.allowConcurrent,
  600. timeoutSeconds: BigInt(config.timeoutSeconds),
  601. },
  602. case: "jobConfig",
  603. },
  604. })
  605. )
  606. .with(
  607. { type: "predeploy" },
  608. (config) =>
  609. new Service({
  610. ...service,
  611. runOptional: service.run,
  612. instancesOptional: service.instances,
  613. type: serviceTypeEnumProto(config.type),
  614. config: {
  615. value: {},
  616. case: "jobConfig",
  617. },
  618. })
  619. )
  620. .exhaustive();
  621. }
  622. // serializedServiceFromProto converts a protobuf Service to a SerializedService
  623. // This is used as an intermediate step to convert a protobuf Service to a ClientService
  624. export function serializedServiceFromProto({
  625. service,
  626. isPredeploy,
  627. }: {
  628. service: Service;
  629. isPredeploy?: boolean;
  630. }): SerializedService {
  631. const config = service.config;
  632. if (!config.case) {
  633. throw new Error("No case found on service config");
  634. }
  635. return match(config)
  636. .with({ case: "webConfig" }, ({ value }) => ({
  637. ...service,
  638. run: service.runOptional ?? service.run,
  639. instances: service.instancesOptional ?? service.instances,
  640. config: {
  641. type: "web" as const,
  642. autoscaling: value.autoscaling ? value.autoscaling : undefined,
  643. healthCheck: value.healthCheck ? value.healthCheck : undefined,
  644. disableTls: value.disableTls ? value.disableTls : undefined,
  645. ...value,
  646. },
  647. }))
  648. .with({ case: "workerConfig" }, ({ value }) => ({
  649. ...service,
  650. run: service.runOptional ?? service.run,
  651. instances: service.instancesOptional ?? service.instances,
  652. config: {
  653. type: "worker" as const,
  654. autoscaling: value.autoscaling ? value.autoscaling : undefined,
  655. healthCheck: value.healthCheck ? value.healthCheck : undefined,
  656. ...value,
  657. },
  658. }))
  659. .with({ case: "jobConfig" }, ({ value }) =>
  660. isPredeploy
  661. ? {
  662. ...service,
  663. run: service.runOptional ?? service.run,
  664. instances: service.instancesOptional ?? service.instances,
  665. config: {
  666. type: "predeploy" as const,
  667. },
  668. }
  669. : {
  670. ...service,
  671. run: service.runOptional ?? service.run,
  672. instances: service.instancesOptional ?? service.instances,
  673. config: {
  674. type: "job" as const,
  675. ...value,
  676. allowConcurrent: value.allowConcurrentOptional,
  677. timeoutSeconds: Number(value.timeoutSeconds),
  678. },
  679. }
  680. )
  681. .exhaustive();
  682. }