1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitZoneOperation } from "./operations.ts";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { tagRecord } from "../../Tags.ts";
13import { GcpEnvironment } from "../Environment.ts";
14import {
15 createInternalLabels,
16 hasAlchemyLabels,
17 stripInternalLabels,
18 toLabels,
19} from "../Labels.ts";
20import type { Providers } from "../Providers.ts";
21
22const DEFAULT_ZONE = "us-central1-a";
23const DEFAULT_COOL_DOWN_SEC = 60;
24const DEFAULT_MIN_REPLICAS = 0;
25const DEFAULT_MODE = "ON";
26const DEFAULT_CPU_TARGET = 0.6;
27const MAX_NAME_LENGTH = 63;
28
29export type AutoscalingPolicyCpuUtilizationProps = {
30 /**
31 * Target average CPU utilization in `(0, 1]`.
32 * @default 0.6
33 */
34 utilizationTarget?: number;
35 /**
36 * Predictive CPU scaling (`NONE` or `OPTIMIZE_AVAILABILITY`).
37 * @default "NONE"
38 */
39 predictiveMethod?: "NONE" | "OPTIMIZE_AVAILABILITY" | (string & {});
40};
41
42export type AutoscalingPolicyLoadBalancingUtilizationProps = {
43 /**
44 * Target backend-capacity utilization as a fraction.
45 * @default 0.8
46 */
47 utilizationTarget?: number;
48};
49
50export type AutoscalingPolicyCustomMetricUtilizationProps = {
51 /** Stackdriver metric type (INT64 or DOUBLE, non-negative). */
52 metric?: string;
53 /** Monitoring filter selecting the TimeSeries to scale on. */
54 filter?: string;
55 /** Target value of the metric. */
56 utilizationTarget?: number;
57 /** How the target is expressed (`GAUGE`, `DELTA_PER_SECOND`, `DELTA_PER_MINUTE`). */
58 utilizationTargetType?: "DELTA_PER_MINUTE" | "DELTA_PER_SECOND" | "GAUGE";
59 /** Per-instance share of a per-group metric. */
60 singleInstanceAssignment?: number;
61};
62
63export type AutoscalingPolicyScaleInControlProps = {
64 /** Max replicas that may be removed in one scale-in step. */
65 maxScaledInReplicas?: {
66 /** Absolute replica count. */
67 fixed?: number;
68 /** Percentage of current size (`0`–`100`). */
69 percent?: number;
70 };
71 /** Lookback window in seconds for scale-in recommendations. */
72 timeWindowSec?: number;
73};
74
75export type AutoscalingPolicyScalingScheduleProps = {
76 /** Extended cron expression for when this schedule is active. */
77 schedule?: string;
78 /** Duration of each run, in seconds (minimum `300`). */
79 durationSec?: number;
80 /** Minimum replicas while this schedule is active. */
81 minRequiredReplicas?: number;
82 /** IANA time zone (e.g. `America/Los_Angeles`). @default "UTC" */
83 timeZone?: string;
84 /** Optional description. */
85 description?: string;
86 /** When true, the schedule is ignored. @default false */
87 disabled?: boolean;
88};
89
90export type AutoscalingPolicyProps = {
91 /**
92 * Maximum number of instances the autoscaler may scale out to. Required.
93 */
94 maxNumReplicas: number;
95 /**
96 * Minimum number of instances the autoscaler may scale in to.
97 * @default 0
98 */
99 minNumReplicas?: number;
100 /**
101 * Initialization period in seconds before metrics are trusted.
102 * @default 60
103 */
104 coolDownPeriodSec?: number;
105 /**
106 * Stabilization period in seconds before scale-in.
107 */
108 stabilizationPeriodSec?: number;
109 /**
110 * Operating mode.
111 * @default "ON"
112 */
113 mode?: "OFF" | "ON" | "ONLY_SCALE_OUT" | "ONLY_UP" | (string & {});
114 /**
115 * CPU utilization signal. When no signal is set, GCP defaults to
116 * CPU `0.6`.
117 */
118 cpuUtilization?: AutoscalingPolicyCpuUtilizationProps;
119 /** Load-balancing utilization signal. */
120 loadBalancingUtilization?: AutoscalingPolicyLoadBalancingUtilizationProps;
121 /** Custom Cloud Monitoring metric signals. */
122 customMetricUtilizations?: AutoscalingPolicyCustomMetricUtilizationProps[];
123 /** Throttle abrupt scale-in. */
124 scaleInControl?: AutoscalingPolicyScaleInControlProps;
125 /** Named scaling schedules (up to 128). */
126 scalingSchedules?: Record<string, AutoscalingPolicyScalingScheduleProps>;
127};
128
129export type AutoscalerProps = {
130 /**
131 * Autoscaler name (RFC1035, 1-63 characters). If omitted, a unique name
132 * is generated from the stack, stage, and logical id. Changing the name
133 * replaces the autoscaler.
134 */
135 autoscalerName?: string;
136 /**
137 * Zone of the managed instance group (e.g. `us-central1-a`). Immutable —
138 * changing it replaces the autoscaler.
139 * @default "us-central1-a"
140 */
141 zone?: string;
142 /**
143 * URL or name of the zonal managed instance group to scale
144 * (`projects/{project}/zones/{zone}/instanceGroupManagers/{name}`).
145 */
146 target: string;
147 /**
148 * Optional description. Compute autoscalers have no labels field, so
149 * Alchemy ownership (`alchemy-stack` / `alchemy-stage` / `alchemy-id`)
150 * and user labels are stored in a `[alchemy …]` prefix for `list` / nuke.
151 */
152 description?: string;
153 /**
154 * User labels. Encoded into the description marker alongside Alchemy
155 * ownership labels (the API has no `labels` field).
156 */
157 labels?: Record<string, string>;
158 /**
159 * Autoscaling policy. `maxNumReplicas` is required; other fields take
160 * GCP defaults when omitted.
161 */
162 autoscalingPolicy: AutoscalingPolicyProps;
163};
164
165export type Autoscaler = Resource<
166 "GCP.Compute.Autoscaler",
167 AutoscalerProps,
168 {
169 /** Autoscaler name. */
170 autoscalerName: string;
171 /** Server-assigned numeric id. */
172 autoscalerId: string | undefined;
173 /** Project id. */
174 project: string;
175 /** Zone short name (`us-central1-a`). */
176 zone: string;
177 /** Target managed instance group URL. */
178 target: string;
179 /** User-facing description (ownership marker stripped). */
180 description: string | undefined;
181 /** User labels decoded from the description marker. */
182 labels: Record<string, string>;
183 /** Autoscaling policy currently applied. */
184 autoscalingPolicy: compute.AutoscalingPolicy | undefined;
185 /** Server-reported status (`PENDING`, `ACTIVE`, `ERROR`, `DELETING`). */
186 status: string | undefined;
187 /** Human-readable status details. */
188 statusDetails: ReadonlyArray<compute.AutoscalerStatusDetails>;
189 /** Recommended MIG size, if the autoscaler has produced one. */
190 recommendedSize: number | undefined;
191 /** Compute Engine self-link. */
192 selfLink: string | undefined;
193 /** RFC3339 creation timestamp. */
194 creationTimestamp: string | undefined;
195 },
196 never,
197 Providers
198>;
199
200/**
201 * A zonal Compute Engine autoscaler for a managed instance group.
202 *
203 * Changing `autoscalerName` or `zone` replaces the autoscaler. Policy,
204 * target, description, and labels update in place via `autoscalers.patch`.
205 * Compute Engine has no labels on this resource, so Alchemy stamps
206 * ownership into the description (`[alchemy alchemy-stack=…
207 * alchemy-stage=… alchemy-id=…]`) so `list` / `pnpm nuke:gcp` can find
208 * them.
209 *
210 * ### Creating an Autoscaler
211 * **Example:** CPU policy with generated name
212 * ```typescript
213 * const scaler = yield* GCP.Compute.Autoscaler("Web", {
214 * target: mig.selfLink,
215 * autoscalingPolicy: {
216 * minNumReplicas: 1,
217 * maxNumReplicas: 5,
218 * coolDownPeriodSec: 60,
219 * cpuUtilization: { utilizationTarget: 0.6 },
220 * },
221 * });
222 * ```
223 *
224 * **Example:** Named autoscaler, labels, and OFF mode
225 * ```typescript
226 * const scaler = yield* GCP.Compute.Autoscaler("Web", {
227 * autoscalerName: "web-scaler",
228 * zone: "us-central1-a",
229 * target: "web-mig",
230 * description: "scale the web MIG",
231 * labels: { env: "prod" },
232 * autoscalingPolicy: {
233 * minNumReplicas: 0,
234 * maxNumReplicas: 3,
235 * mode: "OFF",
236 * cpuUtilization: { utilizationTarget: 0.5 },
237 * },
238 * });
239 * ```
240 *
241 * @resource
242 * @category Compute
243 */
244export const Autoscaler = Resource<Autoscaler>("GCP.Compute.Autoscaler");
245
246export class AutoscalerNotResolved extends Data.TaggedError(
247 "GCP.Compute.AutoscalerNotResolved",
248)<{
249 autoscalerName: string;
250 zone: string;
251}> {}
252
253export class AutoscalerStillExists extends Data.TaggedError(
254 "GCP.Compute.AutoscalerStillExists",
255)<{
256 autoscalerName: string;
257 zone: string;
258}> {}
259
260const lastSegment = (value: string | undefined): string => {
261 if (value === undefined || value.length === 0) return "";
262 const parts = value.split("/").filter((part) => part.length > 0);
263 return parts[parts.length - 1] ?? value;
264};
265
266const rfc1035Name = (name: string) => {
267 let next = name
268 .toLowerCase()
269 .replace(/[^a-z0-9-]/g, "-")
270 .replace(/-+/g, "-")
271 .replace(/^-+|-+$/g, "");
272 if (!/^[a-z]/.test(next)) {
273 next = `a${next}`;
274 }
275 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
276 return next.length > 0 ? next : "autoscaler";
277};
278
279const toName = (id: string, name: string | undefined, existing?: string) =>
280 Effect.gen(function* () {
281 return rfc1035Name(
282 name ??
283 existing ??
284 (yield* createPhysicalName({
285 id,
286 maxLength: MAX_NAME_LENGTH,
287 lowercase: true,
288 })),
289 );
290 });
291
292const toTargetUrl = (project: string, zone: string, target: string) =>
293 target.includes("/")
294 ? target
295 : `projects/${project}/zones/${zone}/instanceGroupManagers/${target}`;
296
297const encodeDescription = (
298 user: string | undefined,
299 labels: Record<string, string>,
300): string => {
301 const packed = Object.entries(labels)
302 .sort(([left], [right]) => left.localeCompare(right))
303 .map(([key, value]) => `${key}=${value}`)
304 .join(" ");
305 const marker = `[alchemy ${packed}]`;
306 const trimmed = user?.trim();
307 return trimmed && trimmed.length > 0 ? `${marker}\n${trimmed}` : marker;
308};
309
310const parseDescription = (
311 description: string | undefined,
312): { labels: Record<string, string>; user: string | undefined } => {
313 if (!description?.startsWith("[alchemy ")) {
314 return { labels: {}, user: description };
315 }
316 const end = description.indexOf("]");
317 if (end < 0) return { labels: {}, user: description };
318 const labels: Record<string, string> = {};
319 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
320 const eq = part.indexOf("=");
321 if (eq > 0) {
322 labels[part.slice(0, eq)] = part.slice(eq + 1);
323 }
324 }
325 const rest = description
326 .slice(end + 1)
327 .replace(/^\n/, "")
328 .trim();
329 return {
330 labels,
331 user: rest.length > 0 ? rest : undefined,
332 };
333};
334
335const hasOwnershipMarker = (description: string | undefined) =>
336 Object.keys(parseDescription(description).labels).some((key) =>
337 key.startsWith("alchemy-"),
338 );
339
340const userLabels = (
341 labels: Record<string, string | undefined> | null | undefined,
342): Record<string, string> => stripInternalLabels(tagRecord(labels));
343
344const toAttrs = (autoscaler: compute.Autoscaler, project: string) => {
345 const decoded = parseDescription(autoscaler.description);
346 return {
347 autoscalerName: autoscaler.name ?? lastSegment(autoscaler.selfLink),
348 autoscalerId: autoscaler.id,
349 project,
350 zone: lastSegment(autoscaler.zone) || DEFAULT_ZONE,
351 target: autoscaler.target ?? "",
352 description: decoded.user,
353 labels: userLabels(decoded.labels),
354 autoscalingPolicy: autoscaler.autoscalingPolicy,
355 status: autoscaler.status,
356 statusDetails: autoscaler.statusDetails ?? [],
357 recommendedSize: autoscaler.recommendedSize,
358 selfLink: autoscaler.selfLink,
359 creationTimestamp: autoscaler.creationTimestamp,
360 };
361};
362
363const hasSignal = (policy: AutoscalingPolicyProps) =>
364 policy.cpuUtilization !== undefined ||
365 policy.loadBalancingUtilization !== undefined ||
366 (policy.customMetricUtilizations?.length ?? 0) > 0;
367
368const desiredPolicy = (
369 policy: AutoscalingPolicyProps,
370): compute.AutoscalingPolicy => ({
371 maxNumReplicas: policy.maxNumReplicas,
372 minNumReplicas: policy.minNumReplicas ?? DEFAULT_MIN_REPLICAS,
373 coolDownPeriodSec: policy.coolDownPeriodSec ?? DEFAULT_COOL_DOWN_SEC,
374 stabilizationPeriodSec: policy.stabilizationPeriodSec,
375 mode: policy.mode ?? DEFAULT_MODE,
376 cpuUtilization:
377 policy.cpuUtilization ??
378 (hasSignal(policy) ? undefined : { utilizationTarget: DEFAULT_CPU_TARGET }),
379 loadBalancingUtilization: policy.loadBalancingUtilization,
380 customMetricUtilizations: policy.customMetricUtilizations,
381 scaleInControl: policy.scaleInControl,
382 scalingSchedules: policy.scalingSchedules,
383});
384
385const sortedValue = (value: unknown): unknown => {
386 if (Array.isArray(value)) return value.map(sortedValue);
387 if (value !== null && typeof value === "object") {
388 return Object.fromEntries(
389 Object.entries(value as Record<string, unknown>)
390 .filter(([, entry]) => entry !== undefined)
391 .sort(([left], [right]) => left.localeCompare(right))
392 .map(([key, entry]) => [key, sortedValue(entry)]),
393 );
394 }
395 return value;
396};
397
398const canonCpu = (cpu: compute.AutoscalingPolicyCpuUtilization | undefined) => {
399 if (cpu === undefined) return undefined;
400 const predictive =
401 cpu.predictiveMethod === undefined || cpu.predictiveMethod === "NONE"
402 ? undefined
403 : cpu.predictiveMethod;
404 return {
405 utilizationTarget: cpu.utilizationTarget,
406 predictiveMethod: predictive,
407 };
408};
409
410const canonPolicy = (policy: compute.AutoscalingPolicy | undefined) => {
411 if (policy === undefined) return undefined;
412 return sortedValue({
413 maxNumReplicas: policy.maxNumReplicas,
414 minNumReplicas: policy.minNumReplicas ?? DEFAULT_MIN_REPLICAS,
415 coolDownPeriodSec: policy.coolDownPeriodSec ?? DEFAULT_COOL_DOWN_SEC,
416 stabilizationPeriodSec: policy.stabilizationPeriodSec,
417 mode: policy.mode ?? DEFAULT_MODE,
418 cpuUtilization: canonCpu(policy.cpuUtilization),
419 loadBalancingUtilization: policy.loadBalancingUtilization
420 ? { utilizationTarget: policy.loadBalancingUtilization.utilizationTarget }
421 : undefined,
422 customMetricUtilizations: (policy.customMetricUtilizations ?? []).map(
423 (metric) => ({
424 metric: metric.metric,
425 filter: metric.filter,
426 utilizationTarget: metric.utilizationTarget,
427 utilizationTargetType: metric.utilizationTargetType,
428 singleInstanceAssignment: metric.singleInstanceAssignment,
429 }),
430 ),
431 scaleInControl: policy.scaleInControl
432 ? {
433 maxScaledInReplicas: policy.scaleInControl.maxScaledInReplicas
434 ? {
435 fixed: policy.scaleInControl.maxScaledInReplicas.fixed,
436 percent: policy.scaleInControl.maxScaledInReplicas.percent,
437 }
438 : undefined,
439 timeWindowSec: policy.scaleInControl.timeWindowSec,
440 }
441 : undefined,
442 scalingSchedules: policy.scalingSchedules,
443 });
444};
445
446const samePolicy = (
447 observed: compute.AutoscalingPolicy | undefined,
448 desired: compute.AutoscalingPolicy,
449) =>
450 JSON.stringify(canonPolicy(observed)) ===
451 JSON.stringify(canonPolicy(desired));
452
453const getByName = (project: string, zone: string, autoscaler: string) =>
454 compute
455 .getAutoscalers({ project, zone, autoscaler })
456 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
457
458const waitPresent = (project: string, zone: string, autoscalerName: string) =>
459 getByName(project, zone, autoscalerName).pipe(
460 Effect.flatMap((autoscaler) =>
461 autoscaler === undefined
462 ? Effect.fail(new AutoscalerNotResolved({ autoscalerName, zone }))
463 : Effect.succeed(autoscaler),
464 ),
465 Effect.retry({
466 while: (error) => error._tag === "GCP.Compute.AutoscalerNotResolved",
467 times: 8,
468 schedule: Schedule.exponential("250 millis"),
469 }),
470 Effect.catchTag("GCP.Compute.AutoscalerNotResolved", () =>
471 Effect.succeed(undefined),
472 ),
473 );
474
475const waitGone = (project: string, zone: string, autoscalerName: string) =>
476 getByName(project, zone, autoscalerName).pipe(
477 Effect.flatMap((autoscaler) =>
478 autoscaler === undefined
479 ? Effect.void
480 : Effect.fail(new AutoscalerStillExists({ autoscalerName, zone })),
481 ),
482 Effect.retry({
483 while: (error) => error._tag === "GCP.Compute.AutoscalerStillExists",
484 times: 8,
485 schedule: Schedule.spaced("1 second"),
486 }),
487 );
488
489export const AutoscalerProvider = () =>
490 Provider.succeed(Autoscaler, {
491 stables: [
492 "autoscalerName",
493 "autoscalerId",
494 "project",
495 "zone",
496 "selfLink",
497 "creationTimestamp",
498 ],
499
500 diff: Effect.fn(function* ({ news, olds, output }) {
501 if (!isResolved(news)) return undefined;
502 const previousName = olds?.autoscalerName ?? output?.autoscalerName;
503 const nextName = news.autoscalerName ?? previousName;
504 const previousZone = lastSegment(olds?.zone ?? output?.zone);
505 const nextZone = lastSegment(news.zone ?? DEFAULT_ZONE);
506 const nameChanged =
507 previousName !== undefined &&
508 nextName !== undefined &&
509 previousName !== nextName;
510 const zoneChanged = previousZone.length > 0 && previousZone !== nextZone;
511 if (!nameChanged && !zoneChanged) {
512 return undefined;
513 }
514 return {
515 action: "replace" as const,
516 // A MIG can only have one autoscaler; always delete the old row first.
517 deleteFirst: true,
518 };
519 }),
520
521 read: Effect.fn(function* ({ id, olds, output }) {
522 const env = yield* GcpEnvironment.current;
523 const autoscalerName = yield* toName(
524 id,
525 olds?.autoscalerName,
526 output?.autoscalerName,
527 );
528 const zone = lastSegment(olds?.zone ?? output?.zone ?? DEFAULT_ZONE);
529 const existing = yield* getByName(env.project, zone, autoscalerName);
530 if (existing === undefined) return undefined;
531 const attrs = toAttrs(existing, env.project);
532 const { labels } = parseDescription(existing.description);
533 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
534 }),
535
536 list: () =>
537 Effect.gen(function* () {
538 const env = yield* GcpEnvironment.current;
539 const pages = yield* compute.aggregatedListAutoscalers
540 .pages({
541 project: env.project,
542 maxResults: 500,
543 returnPartialSuccess: true,
544 })
545 .pipe(Stream.runCollect);
546 return Array.from(pages).flatMap((page) =>
547 Object.values(page.items ?? {}).flatMap((scoped) =>
548 (scoped?.autoscalers ?? [])
549 .filter(
550 (autoscaler) =>
551 (autoscaler.zone ?? "").length > 0 &&
552 hasOwnershipMarker(autoscaler.description),
553 )
554 .map((autoscaler) => toAttrs(autoscaler, env.project)),
555 ),
556 );
557 }),
558
559 reconcile: Effect.fn(function* ({ id, news, output }) {
560 const env = yield* GcpEnvironment.current;
561 const autoscalerName = yield* toName(
562 id,
563 news.autoscalerName,
564 output?.autoscalerName,
565 );
566 const zone = lastSegment(news.zone ?? output?.zone ?? DEFAULT_ZONE);
567 const target = toTargetUrl(env.project, zone, news.target);
568 const desiredLabels = {
569 ...toLabels(news.labels),
570 ...(yield* createInternalLabels(id)),
571 };
572 const description = encodeDescription(news.description, desiredLabels);
573 const policy = desiredPolicy(news.autoscalingPolicy);
574
575 let current = yield* getByName(env.project, zone, autoscalerName);
576
577 if (current === undefined) {
578 yield* compute
579 .insertAutoscalers({
580 project: env.project,
581 zone,
582 body: {
583 name: autoscalerName,
584 target,
585 description,
586 autoscalingPolicy: policy,
587 },
588 })
589 .pipe(
590 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
591 Effect.flatMap((operation) =>
592 operation === undefined
593 ? Effect.void
594 : waitZoneOperation(env.project, zone, operation, {
595 ignore: ["RESOURCE_ALREADY_EXISTS"],
596 }).pipe(Effect.asVoid),
597 ),
598 );
599 current = yield* waitPresent(env.project, zone, autoscalerName);
600 }
601
602 if (current === undefined) {
603 return yield* new AutoscalerNotResolved({ autoscalerName, zone });
604 }
605
606 const descriptionChanged = (current.description ?? "") !== description;
607 const policyChanged = !samePolicy(current.autoscalingPolicy, policy);
608 const targetChanged =
609 lastSegment(current.target) !== lastSegment(target) &&
610 (current.target ?? "") !== target;
611
612 if (descriptionChanged || policyChanged || targetChanged) {
613 yield* compute
614 .patchAutoscalers({
615 project: env.project,
616 zone,
617 autoscaler: autoscalerName,
618 body: {
619 name: autoscalerName,
620 target,
621 description,
622 autoscalingPolicy: policy,
623 },
624 })
625 .pipe(
626 Effect.flatMap((operation) =>
627 waitZoneOperation(env.project, zone, operation),
628 ),
629 Effect.retry({
630 while: (error) => error._tag === "Conflict",
631 times: 5,
632 schedule: Schedule.exponential("250 millis"),
633 }),
634 );
635 current =
636 (yield* getByName(env.project, zone, autoscalerName)) ?? current;
637 }
638
639 return toAttrs(current, env.project);
640 }),
641
642 delete: Effect.fn(function* ({ output }) {
643 yield* compute
644 .deleteAutoscalers({
645 project: output.project,
646 zone: output.zone,
647 autoscaler: output.autoscalerName,
648 })
649 .pipe(
650 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
651 Effect.flatMap((operation) =>
652 operation === undefined
653 ? Effect.void
654 : waitZoneOperation(output.project, output.zone, operation, {
655 ignore: ["RESOURCE_NOT_FOUND"],
656 }).pipe(Effect.asVoid),
657 ),
658 Effect.catchTag("NotFound", () => Effect.void),
659 Effect.retry({
660 while: (error) => error._tag === "Conflict",
661 times: 5,
662 schedule: Schedule.spaced("1 second"),
663 }),
664 );
665 yield* waitGone(output.project, output.zone, output.autoscalerName);
666 }),
667 });