1import * as servicequotas from "@distilled.cloud/aws/service-quotas";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9
10export interface ServiceQuotaIncreaseRequestProps {
11 /**
12 * Service identifier the quota belongs to, e.g. `"vpc"`, `"lambda"`,
13 * `"ec2"`. Discover codes with `servicequotas.listServices`.
14 * Changing this replaces the resource.
15 */
16 serviceCode: string;
17 /**
18 * Quota identifier, e.g. `"L-F678F1CE"` (VPCs per region).
19 * Discover codes with `servicequotas.listServiceQuotas`.
20 * Changing this replaces the resource.
21 */
22 quotaCode: string;
23 /**
24 * The new quota value to request. Must be greater than the currently
25 * applied value. If the applied quota already meets or exceeds this value
26 * no request is submitted. Raising it later submits a new request.
27 */
28 desiredValue: number;
29 /**
30 * Resource context for resource-level quotas (e.g. an EC2 instance family
31 * or an OpenSearch domain ARN). Omit for account-level quotas.
32 * Changing this replaces the resource.
33 */
34 contextId?: string;
35 /**
36 * Whether AWS may open a support case for the request when it cannot be
37 * auto-approved.
38 * @default AWS decides (a case is opened when required)
39 */
40 supportCaseAllowed?: boolean;
41}
42
43export interface ServiceQuotaIncreaseRequest extends Resource<
44 "AWS.ServiceQuotas.ServiceQuotaIncreaseRequest",
45 ServiceQuotaIncreaseRequestProps,
46 {
47 /**
48 * ID of the quota increase request. Undefined when the applied quota
49 * already met the desired value and no request was needed.
50 */
51 requestId: string | undefined;
52 /**
53 * Request status: `PENDING`, `CASE_OPENED`, `APPROVED`, `DENIED`,
54 * `CASE_CLOSED`, `NOT_APPROVED` or `INVALID_REQUEST`. Undefined when no
55 * request was needed.
56 */
57 status: string | undefined;
58 /** Support case ID, when the request opened a case. */
59 caseId: string | undefined;
60 /** Service identifier the quota belongs to. */
61 serviceCode: string;
62 /** Quota identifier. */
63 quotaCode: string;
64 /** The requested (desired) quota value. */
65 desiredValue: number;
66 /**
67 * The applied quota value observed at last reconcile (the AWS default
68 * value when no account override is applied).
69 */
70 appliedValue: number | undefined;
71 /** ARN of the quota. */
72 quotaArn: string | undefined;
73 /** Human-readable quota name. */
74 quotaName: string | undefined;
75 /** Human-readable service name. */
76 serviceName: string | undefined;
77 /** Unit of measurement of the quota value. */
78 unit: string | undefined;
79 /** Whether the quota is global (not region-scoped). */
80 globalQuota: boolean | undefined;
81 },
82 never,
83 Providers
84> {}
85
86/**
87 * A Service Quotas quota increase request at the account or resource level.
88 *
89 * :::caution
90 * Submitting a quota increase request may open an AWS Support case and
91 * **cannot be cancelled through the Service Quotas API**. Destroying this
92 * resource only forgets the request — a still-open request (and its support
93 * case, if any) lives on in AWS.
94 * :::
95 *
96 * The reconciler is idempotent: it first observes the applied quota value
97 * and any open request for the quota. A new request is submitted only when
98 * the applied value is below `desiredValue` and no open request already
99 * asks for it. Raising `desiredValue` later submits a new request.
100 *
101 * ### Requesting a Quota Increase
102 * **Example:** Raise the VPCs-per-region quota
103 * ```typescript
104 * const increase = yield* ServiceQuotas.ServiceQuotaIncreaseRequest("MoreVpcs", {
105 * serviceCode: "vpc",
106 * quotaCode: "L-F678F1CE",
107 * desiredValue: 10,
108 * });
109 * ```
110 *
111 * **Example:** Request without allowing a support case
112 * ```typescript
113 * const increase = yield* ServiceQuotas.ServiceQuotaIncreaseRequest("MoreFunctions", {
114 * serviceCode: "lambda",
115 * quotaCode: "L-B99A9384",
116 * desiredValue: 2000,
117 * supportCaseAllowed: false,
118 * });
119 * ```
120 */
121export const ServiceQuotaIncreaseRequest =
122 Resource<ServiceQuotaIncreaseRequest>(
123 "AWS.ServiceQuotas.ServiceQuotaIncreaseRequest",
124 );
125
126// Statuses in which a request is still in flight and represents the desired
127// state (a new submission would be rejected with ResourceAlreadyExists).
128const OPEN_STATUSES = ["PENDING", "CASE_OPENED"] as const;
129
130// Read a request by ID; a typed NoSuchResourceException collapses to
131// undefined (requests eventually age out of the 90-day history).
132const getRequest = (requestId: string) =>
133 servicequotas.getRequestedServiceQuotaChange({ RequestId: requestId }).pipe(
134 Effect.map((r) => r.RequestedQuota),
135 Effect.catchTag("NoSuchResourceException", () => Effect.succeed(undefined)),
136 );
137
138// Observe the applied quota value; quotas without an applied (account-level)
139// value fall back to the AWS default value.
140const observeQuota = (
141 serviceCode: string,
142 quotaCode: string,
143 contextId: string | undefined,
144) =>
145 servicequotas
146 .getServiceQuota({
147 ServiceCode: serviceCode,
148 QuotaCode: quotaCode,
149 ContextId: contextId,
150 })
151 .pipe(
152 Effect.map((r) => r.Quota),
153 Effect.catchTag("NoSuchResourceException", () =>
154 servicequotas
155 .getAWSDefaultServiceQuota({
156 ServiceCode: serviceCode,
157 QuotaCode: quotaCode,
158 })
159 .pipe(
160 Effect.map((r) => r.Quota),
161 Effect.catchTag("NoSuchResourceException", () =>
162 Effect.succeed(undefined),
163 ),
164 ),
165 ),
166 );
167
168// Find an open (PENDING / CASE_OPENED) increase request for the quota,
169// optionally constrained to an exact desired value.
170const findOpenRequest = Effect.fn(function* (
171 serviceCode: string,
172 quotaCode: string,
173 desiredValue?: number,
174) {
175 for (const status of OPEN_STATUSES) {
176 const page = yield* servicequotas
177 .listRequestedServiceQuotaChangeHistoryByQuota({
178 ServiceCode: serviceCode,
179 QuotaCode: quotaCode,
180 Status: status,
181 })
182 .pipe(
183 Effect.map((r) => r.RequestedQuotas ?? []),
184 Effect.catchTag("NoSuchResourceException", () =>
185 Effect.succeed([] as servicequotas.RequestedServiceQuotaChange[]),
186 ),
187 );
188 const match = page.find(
189 (r) => desiredValue === undefined || r.DesiredValue === desiredValue,
190 );
191 if (match !== undefined) return match;
192 }
193 return undefined;
194});
195
196const buildAttrs = (
197 identity: { serviceCode: string; quotaCode: string; desiredValue: number },
198 request: servicequotas.RequestedServiceQuotaChange | undefined,
199 quota: servicequotas.ServiceQuota | undefined,
200) => ({
201 requestId: request?.Id,
202 status: request?.Status,
203 caseId: request?.CaseId,
204 serviceCode: identity.serviceCode,
205 quotaCode: identity.quotaCode,
206 desiredValue: request?.DesiredValue ?? identity.desiredValue,
207 appliedValue: quota?.Value,
208 quotaArn: request?.QuotaArn ?? quota?.QuotaArn,
209 quotaName: request?.QuotaName ?? quota?.QuotaName,
210 serviceName: request?.ServiceName ?? quota?.ServiceName,
211 unit: request?.Unit ?? quota?.Unit,
212 globalQuota: request?.GlobalQuota ?? quota?.GlobalQuota,
213});
214
215export const ServiceQuotaIncreaseRequestProvider = () =>
216 Provider.effect(
217 ServiceQuotaIncreaseRequest,
218 Effect.gen(function* () {
219 return {
220 stables: [
221 "serviceCode",
222 "quotaCode",
223 "quotaArn",
224 "quotaName",
225 "serviceName",
226 "unit",
227 "globalQuota",
228 ],
229
230 // Top-level enumeration: every quota increase request in the
231 // account's 90-day request history.
232 list: () =>
233 servicequotas.listRequestedServiceQuotaChangeHistory.pages({}).pipe(
234 Stream.runCollect,
235 Effect.map((chunk) =>
236 Array.from(chunk)
237 .flatMap((page) => page.RequestedQuotas ?? [])
238 .flatMap((request) =>
239 request.Id !== undefined
240 ? [
241 buildAttrs(
242 {
243 serviceCode: request.ServiceCode ?? "",
244 quotaCode: request.QuotaCode ?? "",
245 desiredValue: request.DesiredValue ?? 0,
246 },
247 request,
248 undefined,
249 ),
250 ]
251 : [],
252 ),
253 ),
254 ),
255
256 read: Effect.fn(function* ({ olds, output }) {
257 const serviceCode = output?.serviceCode ?? olds?.serviceCode;
258 const quotaCode = output?.quotaCode ?? olds?.quotaCode;
259 const desiredValue = output?.desiredValue ?? olds?.desiredValue;
260 if (serviceCode === undefined || quotaCode === undefined) {
261 return undefined;
262 }
263 const quota = yield* observeQuota(
264 serviceCode,
265 quotaCode,
266 olds?.contextId,
267 );
268 let request =
269 output?.requestId !== undefined
270 ? yield* getRequest(output.requestId)
271 : undefined;
272 if (request === undefined) {
273 request = yield* findOpenRequest(
274 serviceCode,
275 quotaCode,
276 desiredValue,
277 );
278 }
279 if (request !== undefined) {
280 const attrs = buildAttrs(
281 {
282 serviceCode,
283 quotaCode,
284 desiredValue: request.DesiredValue ?? desiredValue ?? 0,
285 },
286 request,
287 quota,
288 );
289 // Requests are not taggable, so ownership cannot be verified
290 // from cloud state alone. An open request discovered without
291 // prior state is gated behind --adopt.
292 return output !== undefined ? attrs : Unowned(attrs);
293 }
294 // No request on record. When WE previously reconciled this
295 // resource (output present) and the applied quota still satisfies
296 // the desired value, the resource is intact. Without prior state
297 // a merely-satisfied quota is NOT a pre-existing foreign resource
298 // (there is nothing to adopt) — report it as missing.
299 if (
300 output !== undefined &&
301 desiredValue !== undefined &&
302 quota?.Value !== undefined &&
303 quota.Value >= desiredValue
304 ) {
305 return buildAttrs(
306 { serviceCode, quotaCode, desiredValue },
307 undefined,
308 quota,
309 );
310 }
311 return undefined;
312 }),
313
314 diff: Effect.fn(function* ({ news, olds }) {
315 if (!isResolved(news)) return undefined;
316 if (
317 news.serviceCode !== olds.serviceCode ||
318 news.quotaCode !== olds.quotaCode ||
319 news.contextId !== olds.contextId
320 ) {
321 return { action: "replace" } as const;
322 }
323 // desiredValue / supportCaseAllowed changes fall through to the
324 // default update path — reconcile submits a new request if needed.
325 }),
326
327 reconcile: Effect.fn(function* ({ news, output, session }) {
328 const { serviceCode, quotaCode, desiredValue } = news;
329
330 // 1. OBSERVE — the applied quota value and any existing request.
331 // Cloud state is authoritative; output only caches the request
332 // ID from a previous run.
333 const quota = yield* observeQuota(
334 serviceCode,
335 quotaCode,
336 news.contextId,
337 );
338 let request =
339 output?.requestId !== undefined
340 ? yield* getRequest(output.requestId)
341 : undefined;
342 if (
343 request !== undefined &&
344 (request.ServiceCode !== serviceCode ||
345 request.QuotaCode !== quotaCode ||
346 request.DesiredValue !== desiredValue)
347 ) {
348 // The cached request no longer reflects the desired state.
349 request = undefined;
350 }
351 if (request === undefined) {
352 request = yield* findOpenRequest(
353 serviceCode,
354 quotaCode,
355 desiredValue,
356 );
357 }
358
359 // 2. ENSURE — submit only when the applied value is below the
360 // desired value and no matching request is already in flight.
361 if (request === undefined) {
362 const satisfied =
363 quota?.Value !== undefined && quota.Value >= desiredValue;
364 if (!satisfied) {
365 request = yield* servicequotas
366 .requestServiceQuotaIncrease({
367 ServiceCode: serviceCode,
368 QuotaCode: quotaCode,
369 DesiredValue: desiredValue,
370 ContextId: news.contextId,
371 SupportCaseAllowed: news.supportCaseAllowed,
372 })
373 .pipe(
374 Effect.map((r) => r.RequestedQuota),
375 // An open request for this quota already exists (created
376 // out-of-band, or a race with a prior partial run) —
377 // converge on it instead of failing.
378 Effect.catchTag("ResourceAlreadyExistsException", () =>
379 findOpenRequest(serviceCode, quotaCode),
380 ),
381 );
382 }
383 }
384
385 // 3. A submitted request is immutable — nothing further to sync.
386 if (request?.Id !== undefined) {
387 yield* session.note(request.Id);
388 }
389 return buildAttrs(
390 { serviceCode, quotaCode, desiredValue },
391 request,
392 quota,
393 );
394 }),
395
396 delete: Effect.fn(function* ({ output }) {
397 // Service Quotas offers no API to cancel a submitted quota
398 // increase request. Deleting the resource only forgets it; a
399 // still-open request (and its support case) remains in AWS.
400 if (
401 output.requestId !== undefined &&
402 (output.status === "PENDING" || output.status === "CASE_OPENED")
403 ) {
404 yield* Effect.logWarning(
405 `Quota increase request ${output.requestId} for ${output.serviceCode}/${output.quotaCode} is still open and cannot be cancelled via the Service Quotas API; it remains in AWS.`,
406 );
407 }
408 }),
409 };
410 }),
411 );