| 1 | import * as servicequotas from "@distilled.cloud/aws/service-quotas"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Stream from "effect/Stream"; |
| 4 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 5 | import { isResolved } from "../../Diff.ts"; |
| 6 | import * as Provider from "../../Provider.ts"; |
| 7 | import { Resource } from "../../Resource.ts"; |
| 8 | import type { Providers } from "../Providers.ts"; |
| 9 | |
| 10 | export 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 | |
| 43 | export 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 | */ |
| 121 | export 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). |
| 128 | const 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). |
| 132 | const 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. |
| 140 | const 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. |
| 170 | const 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 | |
| 196 | const 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 | |
| 215 | export 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 | ); |