diff --git a/distilled b/distilled index 3737b723f7..ad09690036 160000 --- a/distilled +++ b/distilled @@ -1 +1 @@ -Subproject commit 3737b723f77674fbf26764207025d31dabdea19f +Subproject commit ad0969003670306c72bb362a9f6f7cf76def1954 diff --git a/packages/alchemy/src/AWS/CloudFront/Distribution.ts b/packages/alchemy/src/AWS/CloudFront/Distribution.ts index a7749d308f..5b217e6db4 100644 --- a/packages/alchemy/src/AWS/CloudFront/Distribution.ts +++ b/packages/alchemy/src/AWS/CloudFront/Distribution.ts @@ -3,6 +3,7 @@ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; +import { isResolved } from "../../Diff.ts"; import type { Input } from "../../Input.ts"; import * as Provider from "../../Provider.ts"; import { Resource } from "../../Resource.ts"; @@ -45,6 +46,14 @@ export interface DistributionOrigin { * @default false */ s3Origin?: boolean; + /** + * Explicit S3 origin settings (legacy Origin Access Identity, read timeout). + * When set, the origin is treated as an S3 origin regardless of `s3Origin`. + */ + s3OriginConfig?: { + originAccessIdentity?: string; + originReadTimeout?: number; + }; /** * Optional custom origin settings. */ @@ -55,7 +64,50 @@ export interface DistributionOrigin { originReadTimeout?: number; originKeepaliveTimeout?: number; originSslProtocols?: cloudfront.SslProtocol[]; + /** + * IP address type CloudFront uses to connect to the origin. + */ + ipAddressType?: cloudfront.IpAddressType; + /** + * Mutual TLS configuration for the origin connection. + */ + originMtlsConfig?: { + clientCertificateArn: string; + }; }; + /** + * Route this origin through a VPC origin (private ALB/NLB/EC2). Mutually + * exclusive with `s3Origin`/`s3OriginConfig`/`customOriginConfig`. + */ + vpcOriginConfig?: { + vpcOriginId: Input; + originReadTimeout?: number; + originKeepaliveTimeout?: number; + ownerAccountId?: string; + }; + /** + * Custom headers CloudFront adds to every request it sends to the origin. + */ + customHeaders?: Record; + /** + * Origin Shield configuration. + */ + originShield?: { + enabled: boolean; + originShieldRegion?: string; + }; + /** + * Number of times CloudFront attempts to connect to the origin (1-3). + */ + connectionAttempts?: number; + /** + * Seconds CloudFront waits when trying to establish a connection (1-10). + */ + connectionTimeout?: number; + /** + * Seconds CloudFront waits for the origin to deliver a complete response. + */ + responseCompletionTimeout?: number; } export interface DistributionBehavior { @@ -80,6 +132,32 @@ export interface DistributionBehavior { eventType: cloudfront.EventType; includeBody?: boolean; }[]; + /** + * CloudFront KeyGroup IDs whose public keys gate signed URLs/cookies. + */ + trustedKeyGroups?: Input; + /** + * Legacy trusted signer AWS account numbers for signed URLs/cookies. + */ + trustedSigners?: string[]; + /** + * Field-level encryption configuration ID. + */ + fieldLevelEncryptionId?: string; + /** + * ARN of a real-time log configuration to attach. + */ + realtimeLogConfigArn?: string; + /** + * Whether Microsoft Smooth Streaming is enabled for this behavior. + */ + smoothStreaming?: boolean; + /** + * gRPC configuration for this behavior. + */ + grpcConfig?: { + enabled: boolean; + }; } export interface DistributionViewerCertificate { @@ -87,6 +165,68 @@ export interface DistributionViewerCertificate { acmCertificateArn?: string; sslSupportMethod?: cloudfront.SSLSupportMethod; minimumProtocolVersion?: cloudfront.MinimumProtocolVersion; + /** + * Legacy IAM certificate ID. + */ + iamCertificateId?: string; + /** + * Legacy certificate identifier (IAM/ACM raw value). + */ + certificate?: string; + /** + * Source of the legacy certificate. + */ + certificateSource?: cloudfront.CertificateSource; +} + +export interface DistributionGeoRestriction { + /** + * Restriction mode. `none` disables geo restriction. + */ + restrictionType: cloudfront.GeoRestrictionType; + /** + * Two-letter ISO 3166-1 country codes the restriction applies to. + */ + locations?: string[]; +} + +export interface DistributionLogging { + /** + * Whether access logging is enabled. + * @default true + */ + enabled?: boolean; + /** + * Whether cookies are included in access logs. + */ + includeCookies?: boolean; + /** + * S3 bucket (domain name) that receives access logs. + */ + bucket?: string; + /** + * Prefix applied to access log object keys. + */ + prefix?: string; +} + +export interface DistributionOriginGroup { + /** + * Origin group identifier (target it from a cache behavior). + */ + id: string; + /** + * Member origin IDs in failover order (primary first, secondary second). + */ + members: string[]; + /** + * HTTP status codes that trigger failover to the next member. + */ + failoverStatusCodes: number[]; + /** + * How CloudFront selects the origin within the group. + */ + selectionCriteria?: cloudfront.OriginGroupSelectionCriteria; } const isFunctionAssociationPending = (error: cloudfront.InvalidArgument) => { @@ -159,6 +299,32 @@ export interface DistributionProps { * @default true */ isIpv6Enabled?: boolean; + /** + * Geographic distribution restrictions. Defaults to no restriction. + */ + geoRestriction?: DistributionGeoRestriction; + /** + * Standard access logging configuration. + */ + logging?: DistributionLogging; + /** + * Origin failover groups. Target a group id from a cache behavior's + * `targetOriginId`. + */ + originGroups?: Input; + /** + * Continuous deployment policy ID for blue/green deployments. + */ + continuousDeploymentPolicyId?: string; + /** + * Whether this is a staging distribution for blue/green deployments. + * Create-only — changing it forces a replacement. + */ + staging?: boolean; + /** + * Anycast static IP list ID to associate with the distribution. + */ + anycastIpListId?: string; /** * User-defined tags to apply to the distribution. */ @@ -449,6 +615,16 @@ export const DistributionProvider = () => "domainName", "hostedZoneId", ], + // `Staging` is create-only at the CloudFront API level; toggling it + // requires a fresh distribution. Everything else updates in place via + // the whole-config `updateDistribution` PUT. + diff: Effect.fn(function* ({ olds, news: _news }) { + if (!isResolved(_news)) return undefined; + const news = _news as DistributionProps; + if ((olds?.staging ?? false) !== (news.staging ?? false)) { + return { action: "replace" } as const; + } + }), read: Effect.fn(function* ({ output }) { if (!output?.distributionId) { return undefined; @@ -854,6 +1030,26 @@ const toBehavior = ( MinTTL: behavior.minTtl, DefaultTTL: behavior.defaultTtl, MaxTTL: behavior.maxTtl, + TrustedKeyGroups: behavior.trustedKeyGroups + ? { + Enabled: (behavior.trustedKeyGroups as string[]).length > 0, + Quantity: (behavior.trustedKeyGroups as string[]).length, + Items: behavior.trustedKeyGroups as string[], + } + : undefined, + TrustedSigners: behavior.trustedSigners + ? { + Enabled: behavior.trustedSigners.length > 0, + Quantity: behavior.trustedSigners.length, + Items: behavior.trustedSigners, + } + : undefined, + FieldLevelEncryptionId: behavior.fieldLevelEncryptionId, + RealtimeLogConfigArn: behavior.realtimeLogConfigArn, + SmoothStreaming: behavior.smoothStreaming, + GrpcConfig: behavior.grpcConfig + ? { Enabled: behavior.grpcConfig.enabled } + : undefined, FunctionAssociations: behavior.functionAssociations ? { Quantity: behavior.functionAssociations.length, @@ -875,30 +1071,83 @@ const toBehavior = ( : undefined, }); -const toOrigin = (origin: DistributionOrigin): cloudfront.Origin => ({ - Id: origin.id, - DomainName: origin.domainName as string, - OriginPath: origin.originPath as string | undefined, - OriginAccessControlId: origin.originAccessControlId as string | undefined, - S3OriginConfig: origin.s3Origin ? { OriginAccessIdentity: "" } : undefined, - CustomOriginConfig: origin.s3Origin - ? undefined - : { - HTTPPort: origin.customOriginConfig?.httpPort ?? 80, - HTTPSPort: origin.customOriginConfig?.httpsPort ?? 443, - OriginProtocolPolicy: - origin.customOriginConfig?.originProtocolPolicy ?? "https-only", - OriginSslProtocols: { - Quantity: ( - origin.customOriginConfig?.originSslProtocols ?? ["TLSv1.2"] - ).length, - Items: origin.customOriginConfig?.originSslProtocols ?? ["TLSv1.2"], - }, - OriginReadTimeout: origin.customOriginConfig?.originReadTimeout, - OriginKeepaliveTimeout: - origin.customOriginConfig?.originKeepaliveTimeout, - }, -}); +const toOrigin = (origin: DistributionOrigin): cloudfront.Origin => { + // Exactly one of the three origin-config shapes may be set. A VPC origin + // wins, then an explicit/legacy S3 origin, otherwise a custom origin. + const isVpcOrigin = origin.vpcOriginConfig !== undefined; + const isS3Origin = + !isVpcOrigin && + (origin.s3Origin === true || origin.s3OriginConfig !== undefined); + + return { + Id: origin.id, + DomainName: origin.domainName as string, + OriginPath: origin.originPath as string | undefined, + OriginAccessControlId: origin.originAccessControlId as string | undefined, + CustomHeaders: origin.customHeaders + ? { + Quantity: Object.keys(origin.customHeaders).length, + Items: Object.entries(origin.customHeaders).map(([name, value]) => ({ + HeaderName: name, + HeaderValue: value, + })), + } + : undefined, + OriginShield: origin.originShield + ? { + Enabled: origin.originShield.enabled, + OriginShieldRegion: origin.originShield.originShieldRegion, + } + : undefined, + ConnectionAttempts: origin.connectionAttempts, + ConnectionTimeout: origin.connectionTimeout, + ResponseCompletionTimeout: origin.responseCompletionTimeout, + VpcOriginConfig: isVpcOrigin + ? { + VpcOriginId: origin.vpcOriginConfig!.vpcOriginId as string, + OwnerAccountId: origin.vpcOriginConfig!.ownerAccountId, + OriginReadTimeout: origin.vpcOriginConfig!.originReadTimeout, + OriginKeepaliveTimeout: + origin.vpcOriginConfig!.originKeepaliveTimeout, + } + : undefined, + S3OriginConfig: isS3Origin + ? { + OriginAccessIdentity: + origin.s3OriginConfig?.originAccessIdentity ?? "", + OriginReadTimeout: origin.s3OriginConfig?.originReadTimeout, + } + : undefined, + CustomOriginConfig: + isVpcOrigin || isS3Origin + ? undefined + : { + HTTPPort: origin.customOriginConfig?.httpPort ?? 80, + HTTPSPort: origin.customOriginConfig?.httpsPort ?? 443, + OriginProtocolPolicy: + origin.customOriginConfig?.originProtocolPolicy ?? "https-only", + OriginSslProtocols: { + Quantity: ( + origin.customOriginConfig?.originSslProtocols ?? ["TLSv1.2"] + ).length, + Items: origin.customOriginConfig?.originSslProtocols ?? [ + "TLSv1.2", + ], + }, + OriginReadTimeout: origin.customOriginConfig?.originReadTimeout, + OriginKeepaliveTimeout: + origin.customOriginConfig?.originKeepaliveTimeout, + IpAddressType: origin.customOriginConfig?.ipAddressType, + OriginMtlsConfig: origin.customOriginConfig?.originMtlsConfig + ? { + ClientCertificateArn: + origin.customOriginConfig.originMtlsConfig + .clientCertificateArn, + } + : undefined, + }, + }; +}; const toConfig = ( callerReference: string, @@ -937,6 +1186,27 @@ const toConfig = ( ) as cloudfront.CacheBehavior[], } : undefined, + OriginGroups: props.originGroups + ? { + Quantity: (props.originGroups as DistributionOriginGroup[]).length, + Items: (props.originGroups as DistributionOriginGroup[]).map( + (group) => ({ + Id: group.id, + FailoverCriteria: { + StatusCodes: { + Quantity: group.failoverStatusCodes.length, + Items: group.failoverStatusCodes, + }, + }, + Members: { + Quantity: group.members.length, + Items: group.members.map((originId) => ({ OriginId: originId })), + }, + SelectionCriteria: group.selectionCriteria, + }), + ), + } + : undefined, CustomErrorResponses: props.customErrorResponses ? { Quantity: ( @@ -946,12 +1216,23 @@ const toConfig = ( } : undefined, Comment: props.comment ?? "", + Logging: props.logging + ? { + Enabled: props.logging.enabled ?? true, + IncludeCookies: props.logging.includeCookies ?? false, + Bucket: props.logging.bucket ?? "", + Prefix: props.logging.prefix ?? "", + } + : undefined, Enabled: props.enabled ?? true, ViewerCertificate: props.viewerCertificate ? { CloudFrontDefaultCertificate: ( props.viewerCertificate as DistributionViewerCertificate ).cloudFrontDefaultCertificate, + IAMCertificateId: ( + props.viewerCertificate as DistributionViewerCertificate + ).iamCertificateId, ACMCertificateArn: ( props.viewerCertificate as DistributionViewerCertificate ).acmCertificateArn, @@ -961,22 +1242,38 @@ const toConfig = ( MinimumProtocolVersion: ( props.viewerCertificate as DistributionViewerCertificate ).minimumProtocolVersion, + Certificate: (props.viewerCertificate as DistributionViewerCertificate) + .certificate, + CertificateSource: ( + props.viewerCertificate as DistributionViewerCertificate + ).certificateSource, } : props.aliases && props.aliases.length > 0 ? undefined : { CloudFrontDefaultCertificate: true, }, - Restrictions: { - GeoRestriction: { - RestrictionType: "none", - Quantity: 0, - }, - }, + Restrictions: props.geoRestriction + ? { + GeoRestriction: { + RestrictionType: props.geoRestriction.restrictionType, + Quantity: props.geoRestriction.locations?.length ?? 0, + Items: props.geoRestriction.locations, + }, + } + : { + GeoRestriction: { + RestrictionType: "none", + Quantity: 0, + }, + }, PriceClass: props.priceClass, WebACLId: props.webAclId, HttpVersion: props.httpVersion ?? "http2", IsIPV6Enabled: props.isIpv6Enabled ?? true, + ContinuousDeploymentPolicyId: props.continuousDeploymentPolicyId, + Staging: props.staging, + AnycastIpListId: props.anycastIpListId, }); const toAttrs = ( diff --git a/packages/alchemy/src/AWS/CloudFront/VpcOrigin.ts b/packages/alchemy/src/AWS/CloudFront/VpcOrigin.ts new file mode 100644 index 0000000000..e6ce0a797f --- /dev/null +++ b/packages/alchemy/src/AWS/CloudFront/VpcOrigin.ts @@ -0,0 +1,472 @@ +import * as cloudfront from "@distilled.cloud/aws/cloudfront"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import { isResolved } from "../../Diff.ts"; +import { createPhysicalName } from "../../PhysicalName.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import { createInternalTags, createTagsList, diffTags } from "../../Tags.ts"; +import type { Providers } from "../Providers.ts"; + +class VpcOriginPendingDeployment extends Data.TaggedError( + "VpcOriginPendingDeployment", +)<{ + message: string; +}> {} + +class VpcOriginStillInUse extends Data.TaggedError("VpcOriginStillInUse")<{ + message: string; +}> {} + +export interface VpcOriginProps { + /** + * Name of the VPC origin. If omitted, a deterministic name is generated. + */ + name?: string; + /** + * ARN of the resource the VPC origin fronts (an Application/Network Load + * Balancer or an EC2 instance in a VPC). Changing the target ARN forces a + * replacement. + */ + arn: string; + /** + * HTTP port CloudFront uses to connect to the origin. + * @default 80 + */ + httpPort?: number; + /** + * HTTPS port CloudFront uses to connect to the origin. + * @default 443 + */ + httpsPort?: number; + /** + * Origin protocol policy CloudFront uses to connect to the origin. + * @default "https-only" + */ + originProtocolPolicy?: cloudfront.OriginProtocolPolicy; + /** + * SSL/TLS protocols CloudFront uses when establishing an HTTPS connection. + * @default ["TLSv1.2"] + */ + originSslProtocols?: cloudfront.SslProtocol[]; + /** + * User-defined tags to apply to the VPC origin. + */ + tags?: Record; +} + +export interface VpcOrigin extends Resource< + "AWS.CloudFront.VpcOrigin", + VpcOriginProps, + { + /** + * CloudFront-assigned VPC origin identifier. + */ + vpcOriginId: string; + /** + * ARN of the VPC origin. + */ + vpcOriginArn: string; + /** + * Current deployment status of the VPC origin. + */ + status: string; + /** + * Name of the VPC origin. + */ + name: string; + /** + * ARN of the resource the VPC origin fronts. + */ + arn: string; + /** + * Creation timestamp. + */ + createdTime: Date | undefined; + /** + * Last CloudFront modification timestamp. + */ + lastModifiedTime: Date | undefined; + /** + * Most recent entity tag for update/delete operations. + */ + etag: string | undefined; + /** + * Current tags on the VPC origin. + */ + tags: Record; + }, + never, + Providers +> {} + +/** + * A CloudFront VPC origin. + * + * `VpcOrigin` lets a CloudFront distribution route to a private Application + * Load Balancer, Network Load Balancer, or EC2 instance inside a VPC without + * exposing it to the public internet. Reference the resulting `vpcOriginId` + * from a distribution origin's `vpcOriginConfig`. + * + * @section Creating VPC Origins + * @example Private ALB Origin + * ```typescript + * const vpcOrigin = yield* VpcOrigin("AppOrigin", { + * arn: loadBalancer.arn, + * httpPort: 80, + * httpsPort: 443, + * originProtocolPolicy: "https-only", + * }); + * ``` + * + * @example Attaching a VPC Origin to a Distribution + * ```typescript + * const distribution = yield* Distribution("AppCdn", { + * origins: [ + * { + * id: "app", + * domainName: loadBalancer.dnsName, + * vpcOriginConfig: { vpcOriginId: vpcOrigin.vpcOriginId }, + * }, + * ], + * defaultCacheBehavior: { + * targetOriginId: "app", + * viewerProtocolPolicy: "redirect-to-https", + * }, + * }); + * ``` + */ +export const VpcOrigin = Resource("AWS.CloudFront.VpcOrigin"); + +export const VpcOriginProvider = () => + Provider.effect( + VpcOrigin, + Effect.gen(function* () { + // Observe — locate the VPC origin by id, tolerating a concurrent delete. + const getById = Effect.fn(function* (id: string) { + const result = yield* cloudfront + .getVpcOrigin({ Id: id }) + .pipe( + Effect.catchTag("EntityNotFound", () => Effect.succeed(undefined)), + ); + if (!result?.VpcOrigin?.Id) { + return undefined; + } + return { vpcOrigin: result.VpcOrigin, etag: result.ETag }; + }); + + // Crash recovery — a create can succeed in the cloud but fail to persist + // locally. Recover by listing and matching on the fronted ARN. + const getByArn = Effect.fn(function* (arn: string) { + let marker: string | undefined; + do { + const listed: cloudfront.ListVpcOriginsResult = + yield* cloudfront.listVpcOrigins({ Marker: marker }); + const summary = listed.VpcOriginList?.Items?.find( + (item) => item.OriginEndpointArn === arn, + ); + if (summary?.Id) { + return yield* getById(summary.Id); + } + marker = listed.VpcOriginList?.IsTruncated + ? listed.VpcOriginList.NextMarker + : undefined; + } while (marker); + return undefined; + }); + + const fetchTags = Effect.fn(function* (arn: string) { + const response = yield* cloudfront.listTagsForResource({ + Resource: arn, + }); + return toTagsRecord(response.Tags.Items); + }); + + // Wait — poll until the VPC origin reaches the terminal `Deployed` state, + // mirroring the Distribution deployment wait. + const waitForDeployment = Effect.fn(function* (id: string) { + return yield* getById(id).pipe( + Effect.flatMap((current) => + current?.vpcOrigin.Status === "Deployed" + ? Effect.succeed(current) + : Effect.fail( + new VpcOriginPendingDeployment({ + message: `VPC origin ${id} is not yet deployed (status=${current?.vpcOrigin.Status ?? "unknown"})`, + }), + ), + ), + Effect.retry({ + while: (error) => error._tag === "VpcOriginPendingDeployment", + // CloudFront VPC-origin deployment is slow (global propagation) and + // routinely exceeds 10 min; budget ~20 min (120 * 10s) so a real + // deploy doesn't fail spuriously. + schedule: Schedule.fixed("10 seconds").pipe( + Schedule.both(Schedule.recurs(120)), + ), + }), + ); + }); + + const desiredEndpointConfig = ( + name: string, + props: VpcOriginProps, + ): cloudfront.VpcOriginEndpointConfig => ({ + Name: name, + Arn: props.arn, + HTTPPort: props.httpPort ?? 80, + HTTPSPort: props.httpsPort ?? 443, + OriginProtocolPolicy: props.originProtocolPolicy ?? "https-only", + OriginSslProtocols: { + Quantity: (props.originSslProtocols ?? ["TLSv1.2"]).length, + Items: props.originSslProtocols ?? ["TLSv1.2"], + }, + }); + + const endpointConfigEquals = ( + a: cloudfront.VpcOriginEndpointConfig | undefined, + b: cloudfront.VpcOriginEndpointConfig, + ) => + !!a && + a.Name === b.Name && + a.Arn === b.Arn && + a.HTTPPort === b.HTTPPort && + a.HTTPSPort === b.HTTPSPort && + a.OriginProtocolPolicy === b.OriginProtocolPolicy && + (a.OriginSslProtocols?.Items ?? []).join(",") === + (b.OriginSslProtocols?.Items ?? []).join(","); + + const syncTags = Effect.fn(function* ( + arn: string, + observedTags: Record, + desiredTags: Record, + ) { + const { removed, upsert } = diffTags(observedTags, desiredTags); + if (upsert.length > 0) { + yield* cloudfront.tagResource({ + Resource: arn, + Tags: { Items: upsert }, + }); + } + if (removed.length > 0) { + yield* cloudfront.untagResource({ + Resource: arn, + TagKeys: { Items: removed }, + }); + } + }); + + return { + stables: ["vpcOriginId", "vpcOriginArn"], + list: () => + Effect.gen(function* () { + const items: VpcOrigin["Attributes"][] = []; + let marker: string | undefined; + do { + const listed: cloudfront.ListVpcOriginsResult = + yield* cloudfront.listVpcOrigins({ Marker: marker }); + for (const summary of listed.VpcOriginList?.Items ?? []) { + if (!summary.Id) continue; + const current = yield* getById(summary.Id); + if (!current) continue; + const tags = yield* fetchTags(current.vpcOrigin.Arn); + items.push(toAttrs(current.vpcOrigin, current.etag, tags)); + } + marker = listed.VpcOriginList?.IsTruncated + ? listed.VpcOriginList.NextMarker + : undefined; + } while (marker); + return items; + }), + diff: Effect.fn(function* ({ id, olds, news: _news }) { + if (!isResolved(_news)) return undefined; + const news = _news as VpcOriginProps; + // `arn` is create-only — a changed target endpoint forces replace. + if (olds?.arn !== undefined && olds.arn !== news.arn) { + return { action: "replace" } as const; + } + // A changed name also forces replace (it is the immutable identity). + if ( + (yield* createName(id, olds ?? ({} as VpcOriginProps))) !== + (yield* createName(id, news)) + ) { + return { action: "replace" } as const; + } + }), + read: Effect.fn(function* ({ id, olds, output }) { + const existing = output?.vpcOriginId + ? yield* getById(output.vpcOriginId) + : yield* getByArn((olds ?? ({} as VpcOriginProps)).arn ?? ""); + if (!existing?.vpcOrigin.Id) { + return undefined; + } + const tags = yield* fetchTags(existing.vpcOrigin.Arn); + return toAttrs(existing.vpcOrigin, existing.etag, tags); + }), + reconcile: Effect.fn(function* ({ id, news, output, session }) { + const name = yield* createName(id, news); + const desiredTags = { + ...(yield* createInternalTags(id)), + ...news.tags, + }; + + // Observe — locate by cached id or recover by fronted ARN. + let observed = output?.vpcOriginId + ? yield* getById(output.vpcOriginId) + : undefined; + if (!observed) { + observed = yield* getByArn(news.arn); + } + + // Ensure — create the VPC origin if it's missing. Tolerate an + // `EntityAlreadyExists` race by recovering via the fronted ARN. + if (!observed) { + const created = yield* cloudfront + .createVpcOrigin({ + VpcOriginEndpointConfig: desiredEndpointConfig(name, news), + Tags: { Items: createTagsList(desiredTags) }, + }) + .pipe( + Effect.map((result) => + result.VpcOrigin?.Id + ? { vpcOrigin: result.VpcOrigin, etag: result.ETag } + : undefined, + ), + Effect.catchTag("EntityAlreadyExists", () => + getByArn(news.arn), + ), + ); + + if (!created?.vpcOrigin.Id) { + return yield* Effect.fail( + new Error("createVpcOrigin returned no identifier"), + ); + } + + yield* session.note(created.vpcOrigin.Id); + const deployed = yield* waitForDeployment(created.vpcOrigin.Id); + const tags = yield* fetchTags(deployed.vpcOrigin.Arn); + return toAttrs(deployed.vpcOrigin, deployed.etag, tags); + } + + // Sync endpoint config — patch only when the observed config differs + // from desired, using the freshly observed ETag for concurrency. + const desired = desiredEndpointConfig(name, news); + let current = observed; + if ( + !endpointConfigEquals( + observed.vpcOrigin.VpcOriginEndpointConfig, + desired, + ) + ) { + yield* cloudfront.updateVpcOrigin({ + Id: observed.vpcOrigin.Id, + IfMatch: observed.etag ?? "", + VpcOriginEndpointConfig: desired, + }); + current = yield* waitForDeployment(observed.vpcOrigin.Id); + } else if (observed.vpcOrigin.Status !== "Deployed") { + current = yield* waitForDeployment(observed.vpcOrigin.Id); + } + + // Sync tags — diff against observed cloud tags so adoption converges. + const observedTags = yield* fetchTags(current.vpcOrigin.Arn); + yield* syncTags(current.vpcOrigin.Arn, observedTags, desiredTags); + + yield* session.note(current.vpcOrigin.Id); + return toAttrs(current.vpcOrigin, current.etag, desiredTags); + }), + delete: Effect.fn(function* ({ output }) { + if (!output.vpcOriginId) { + return; + } + // Observe current state. `deleteVpcOrigin` requires a terminal + // `Deployed` status and the *current* ETag — `output.etag` may be + // stale. If the origin is still `Deploying` (e.g. an interrupted + // create), CloudFront refuses the delete and leaves the origin — and + // its managed ENIs, which then block the VPC/ALB teardown — + // orphaned. Wait for it to settle so it becomes deletable. + const observed = yield* getById(output.vpcOriginId); + if (!observed) { + return; // already gone — idempotent + } + const current = + observed.vpcOrigin.Status === "Deployed" + ? observed + : yield* waitForDeployment(output.vpcOriginId).pipe( + Effect.catch(() => Effect.succeed(observed)), + ); + yield* cloudfront + .deleteVpcOrigin({ + Id: output.vpcOriginId, + IfMatch: current.etag ?? observed.etag ?? "", + }) + .pipe( + Effect.catchTag("EntityNotFound", () => Effect.void), + // The VPC origin may still be referenced by a distribution origin + // that is mid-removal; retry on the in-use signal. + Effect.catchTag("CannotDeleteEntityWhileInUse", (error) => + Effect.fail( + new VpcOriginStillInUse({ + message: error.Message ?? "VPC origin still in use", + }), + ), + ), + Effect.retry({ + while: (error) => error._tag === "VpcOriginStillInUse", + schedule: Schedule.fixed("10 seconds").pipe( + Schedule.both(Schedule.recurs(30)), + ), + }), + ); + // Block until the origin record is fully gone so dependents (the + // fronted ALB/VPC, held by CloudFront's ENIs) can be torn down. + yield* Effect.repeat( + getById(output.vpcOriginId).pipe( + Effect.map((o) => o !== undefined), + ), + { + schedule: Schedule.fixed("10 seconds").pipe( + Schedule.both(Schedule.recurs(30)), + ), + until: (exists) => exists === false, + }, + ).pipe(Effect.catch(() => Effect.void)); + }), + }; + }), + ); + +const createName = (id: string, props: VpcOriginProps) => + props.name + ? Effect.succeed(props.name) + : createPhysicalName({ + id, + maxLength: 64, + }); + +const toTagsRecord = (tags: cloudfront.Tag[] | undefined) => + Object.fromEntries( + (tags ?? []) + .filter( + (tag): tag is { Key: string; Value: string } => + typeof tag.Key === "string" && typeof tag.Value === "string", + ) + .map((tag) => [tag.Key, tag.Value]), + ); + +const toAttrs = ( + vpcOrigin: cloudfront.VpcOrigin, + etag: string | undefined, + tags: Record, +): VpcOrigin["Attributes"] => ({ + vpcOriginId: vpcOrigin.Id, + vpcOriginArn: vpcOrigin.Arn, + status: vpcOrigin.Status, + name: vpcOrigin.VpcOriginEndpointConfig.Name, + arn: vpcOrigin.VpcOriginEndpointConfig.Arn, + createdTime: vpcOrigin.CreatedTime, + lastModifiedTime: vpcOrigin.LastModifiedTime, + etag, + tags, +}); diff --git a/packages/alchemy/src/AWS/CloudFront/index.ts b/packages/alchemy/src/AWS/CloudFront/index.ts index 572e5e09ef..6a9efc6026 100644 --- a/packages/alchemy/src/AWS/CloudFront/index.ts +++ b/packages/alchemy/src/AWS/CloudFront/index.ts @@ -24,3 +24,4 @@ export { ResponseHeadersPolicy, ResponseHeadersPolicyProvider, } from "./ResponseHeadersPolicy.ts"; +export { VpcOrigin, VpcOriginProvider } from "./VpcOrigin.ts"; diff --git a/packages/alchemy/src/AWS/EC2/Subnet.ts b/packages/alchemy/src/AWS/EC2/Subnet.ts index 0fe09addde..2c0e2bd4e9 100644 --- a/packages/alchemy/src/AWS/EC2/Subnet.ts +++ b/packages/alchemy/src/AWS/EC2/Subnet.ts @@ -417,7 +417,10 @@ export const SubnetProvider = () => .pipe( Effect.tapError(Effect.logDebug), Effect.catchTag("InvalidSubnetID.NotFound", () => Effect.void), - // Retry on dependency violations (resources still being deleted) + // Retry on dependency violations (resources still being deleted). + // ENIs from a just-deleted ALB or CloudFront VPC origin can take + // several minutes to detach after the owning resource is gone, so + // budget ~12 min (fast exponential start, capped at 30s steps). Effect.retry({ while: (e) => { // DependencyViolation means there are still dependent resources @@ -425,7 +428,8 @@ export const SubnetProvider = () => return e._tag === "DependencyViolation"; }, schedule: Schedule.exponential(1000, 1.5).pipe( - Schedule.both(Schedule.recurs(10)), // Try up to 10 times + Schedule.either(Schedule.spaced("30 seconds")), + Schedule.both(Schedule.recurs(30)), Schedule.tapOutput(([, attempt]) => session.note( `Waiting for dependencies to clear... (attempt ${attempt + 1})`, diff --git a/packages/alchemy/src/AWS/ECS/Service.ts b/packages/alchemy/src/AWS/ECS/Service.ts index 1abdc4cdd4..9f43df874a 100644 --- a/packages/alchemy/src/AWS/ECS/Service.ts +++ b/packages/alchemy/src/AWS/ECS/Service.ts @@ -1,6 +1,7 @@ import * as ecs from "@distilled.cloud/aws/ecs"; import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2"; import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; import { deepEqual, isResolved } from "../../Diff.ts"; import type { Input } from "../../Input.ts"; @@ -8,7 +9,7 @@ import { createPhysicalName } from "../../PhysicalName.ts"; import * as Provider from "../../Provider.ts"; import { Resource } from "../../Resource.ts"; import type { Providers } from "../Providers.ts"; -import { createInternalTags } from "../../Tags.ts"; +import { createInternalTags, diffTags } from "../../Tags.ts"; import type { AccountID } from "../Environment.ts"; import type { RegionID } from "../Region.ts"; import type { ClusterArn } from "./Cluster.ts"; @@ -47,11 +48,13 @@ export interface ServiceProps { /** * Name of the ECS service. * If omitted, a unique name will be generated. + * + * Changing this replaces the service (delete-first). */ serviceName?: string; /** - * Desired number of running tasks. + * Desired number of running tasks. Updated in place. * @default 1 */ desiredCount?: number; @@ -62,25 +65,59 @@ export interface ServiceProps { vpcId: string; /** - * Subnets used by the service's awsvpc network configuration. + * Subnets used by the service's awsvpc network configuration. Updated in + * place via `updateService`. */ subnets: string[]; /** * Security groups attached to the service ENIs and, when `public: true`, the - * generated Application Load Balancer. + * generated Application Load Balancer. Updated in place. */ securityGroups?: string[]; /** - * Whether the service ENIs should receive public IPs. + * Whether the service ENIs should receive public IPs. Updated in place. * @default false */ assignPublicIp?: boolean; + /** + * Launch type for the service. Mutually exclusive with + * {@link capacityProviderStrategy}. Switching between launch type and + * capacity-provider strategy replaces the service. + * @default "FARGATE" + */ + launchType?: ecs.LaunchType; + + /** + * Capacity provider strategy for the service (e.g. `FARGATE`/`FARGATE_SPOT` + * weights, or a custom ASG-backed provider). Mutually exclusive with + * {@link launchType}. Switching to/from a launch type replaces the service; + * weight/base changes apply in place. + */ + capacityProviderStrategy?: ecs.CapacityProviderStrategyItem[]; + + /** + * Load balancer target groups to wire to the service. **User-supplied** — + * Alchemy does NOT create these. Each entry references an existing ELBv2 + * target group (or CLB) plus the container/port that receives traffic. + * Updated in place for rolling deployments. + * + * For an Alchemy-managed public ALB instead, set {@link public} to `true`. + */ + loadBalancers?: ecs.LoadBalancer[]; + + /** + * Cloud Map service registries (service discovery) to associate with the + * service. + */ + serviceRegistries?: ecs.ServiceRegistry[]; + /** * Whether Alchemy should provision a public Application Load Balancer and - * listener in front of the service. + * listener in front of the service. When set, the generated target group is + * appended to {@link loadBalancers}. * @default false */ public?: boolean; @@ -104,23 +141,88 @@ export interface ServiceProps { healthCheckPath?: string; /** - * Fargate platform version for the service. + * Fargate platform version for the service. Updated in place. */ platformVersion?: string; /** - * Raw ECS deployment configuration overrides. + * Raw ECS deployment configuration (rolling update percentages, circuit + * breaker, deployment strategy, alarms). Updated in place. */ deploymentConfiguration?: ecs.DeploymentConfiguration; /** - * Grace period before ECS starts evaluating target health checks. + * Deployment controller (`ECS`, `CODE_DEPLOY`, `EXTERNAL`). The controller + * type is immutable — changing it replaces the service. + */ + deploymentController?: ecs.DeploymentController; + + /** + * Placement constraints (`distinctInstance` / `memberOf`). Updated in place. + */ + placementConstraints?: ecs.PlacementConstraint[]; + + /** + * Placement strategy (`random` / `spread` / `binpack`). Updated in place. + */ + placementStrategy?: ecs.PlacementStrategy[]; + + /** + * Scheduling strategy. `REPLICA` runs and maintains `desiredCount` copies; + * `DAEMON` runs one task per eligible instance. Immutable — changing it + * replaces the service. + * @default "REPLICA" + */ + schedulingStrategy?: ecs.SchedulingStrategy; + + /** + * Whether to enable ECS Exec on the service tasks. Updated in place. + * @default false + */ + enableExecuteCommand?: boolean; + + /** + * Whether to enable ECS managed tags. Immutable post-create. + * @default true + */ + enableECSManagedTags?: boolean; + + /** + * How to propagate tags to tasks (`TASK_DEFINITION`, `SERVICE`, `NONE`). + * Updated in place. + */ + propagateTags?: ecs.PropagateTags; + + /** + * Availability zone rebalancing behavior. Updated in place. + */ + availabilityZoneRebalancing?: ecs.AvailabilityZoneRebalancing; + + /** + * ECS Service Connect configuration. Updated in place. + */ + serviceConnectConfiguration?: ecs.ServiceConnectConfiguration; + + /** + * Service-managed volume configurations. Updated in place. + */ + volumeConfigurations?: ecs.ServiceVolumeConfiguration[]; + + /** + * IAM role for the ELB integration (only for non-awsvpc / CLB services). + * Immutable — changing it replaces the service. + */ + role?: string; + + /** + * Grace period before ECS starts evaluating target health checks. Updated in + * place. */ healthCheckGracePeriodSeconds?: number; /** * User-defined tags to apply to the ECS service and generated ingress - * resources. + * resources. Reconciled in place against observed service tags. */ tags?: Record; } @@ -180,15 +282,35 @@ export interface Service extends Resource< > {} /** - * An ECS Fargate service for running long-lived tasks. + * An ECS service for running long-lived tasks. * - * `Service` turns a bundled `AWS.ECS.Task` into a continuously running Fargate - * deployment with awsvpc networking. Phase 1 focuses on the public HTTP path, - * so the resource can optionally provision an Application Load Balancer, - * target group, and listener when `public: true`. + * `Service` keeps a registered task definition running with awsvpc networking. + * Load balancing is **explicit**: pass user-supplied `loadBalancers` target + * groups, or set `public: true` to have Alchemy provision a public ALB + + * listener + target group as a convenience. Launch behavior is controlled via + * `launchType` (default `FARGATE`) or a `capacityProviderStrategy`. + * + * Most configuration is updated **in place** via `updateService` + * (desiredCount, task definition, network, deployment config, placement, + * exec, load balancers, tags). Only truly-immutable aspects — `serviceName`, + * `cluster`, launchType↔capacityProviderStrategy switch, `deploymentController` + * type, `schedulingStrategy`, `enableECSManagedTags`, `role` — replace the + * service. * * @section Creating Services - * @example Public HTTP Service + * @example Internal Service + * ```typescript + * const service = yield* Service("WorkerService", { + * cluster, + * task: workerTask, + * vpcId: vpc.vpcId, + * subnets: [privateSubnet1.subnetId, privateSubnet2.subnetId], + * securityGroups: [workerSecurityGroup.groupId], + * desiredCount: 2, + * }); + * ``` + * + * @example Public HTTP Service (Alchemy-managed ALB) * ```typescript * const service = yield* Service("ApiService", { * cluster, @@ -200,47 +322,54 @@ export interface Service extends Resource< * }); * ``` * - * @example Internal Service + * @section Load Balancing + * @example Manual (User-Supplied) Target Group * ```typescript - * const service = yield* Service("WorkerService", { + * const service = yield* Service("ApiService", { * cluster, - * task: workerTask, + * task: apiTask, * vpcId: vpc.vpcId, - * subnets: [privateSubnet1.subnetId, privateSubnet2.subnetId], - * securityGroups: [workerSecurityGroup.groupId], - * desiredCount: 2, + * subnets: [subnet1.subnetId, subnet2.subnetId], + * loadBalancers: [ + * { + * targetGroupArn, + * containerName: apiTask.containerName, + * containerPort: apiTask.port, + * }, + * ], * }); * ``` * - * @section Public Ingress - * @example HTTPS Service + * @section Capacity & Placement + * @example FARGATE_SPOT Capacity Provider Strategy * ```typescript - * const service = yield* Service("SecureApiService", { + * const service = yield* Service("WorkerService", { * cluster, - * task: apiTask, + * task: workerTask, * vpcId: vpc.vpcId, - * subnets: [publicSubnet1.subnetId, publicSubnet2.subnetId], - * securityGroups: [serviceSecurityGroup.groupId], - * public: true, - * certificateArn, - * healthCheckPath: "/health", + * subnets: [subnet.subnetId], + * capacityProviderStrategy: [ + * { capacityProvider: "FARGATE_SPOT", weight: 4 }, + * { capacityProvider: "FARGATE", weight: 1, base: 1 }, + * ], + * placementStrategy: [{ type: "spread", field: "attribute:ecs.availability-zone" }], * }); * ``` * * @section Deployment - * @example Rolling Update Configuration + * @example Rolling Update with Circuit Breaker * ```typescript * const service = yield* Service("ApiService", { * cluster, * task: apiTask, * vpcId: vpc.vpcId, - * subnets: [publicSubnet1.subnetId, publicSubnet2.subnetId], - * securityGroups: [serviceSecurityGroup.groupId], - * public: true, + * subnets: [subnet1.subnetId, subnet2.subnetId], * desiredCount: 3, + * enableExecuteCommand: true, * deploymentConfiguration: { * minimumHealthyPercent: 100, * maximumPercent: 200, + * deploymentCircuitBreaker: { enable: true, rollback: true }, * }, * healthCheckGracePeriodSeconds: 30, * }); @@ -362,61 +491,95 @@ export const ServiceProvider = () => }; }); - const serviceInput = ( + const networkConfigurationOf = (news: ServiceProps) => ({ + awsvpcConfiguration: { + subnets: news.subnets, + securityGroups: news.securityGroups, + assignPublicIp: (news.assignPublicIp ? "ENABLED" : "DISABLED") as + | "ENABLED" + | "DISABLED", + }, + }); + + // load balancers passed to create/update: explicit user-supplied list + // plus the Alchemy-managed ingress target group (when `public: true`). + const loadBalancersOf = ( news: ServiceProps, - output?: Service["Attributes"], - ) => ({ - cluster: clusterArnOf(news.cluster), - service: output?.serviceName, - serviceName: output?.serviceName, + ingress: { targetGroupArn?: string } | undefined, + ): ecs.LoadBalancer[] | undefined => { + const managed: ecs.LoadBalancer[] = + ingress?.targetGroupArn && news.public + ? [ + { + targetGroupArn: ingress.targetGroupArn, + containerName: news.task.containerName, + containerPort: news.task.port ?? 3000, + }, + ] + : []; + const all = [...(news.loadBalancers ?? []), ...managed]; + return all.length > 0 ? all : undefined; + }; + + // In-place mutable fields shared by createService and updateService. + const mutableInput = (news: ServiceProps) => ({ taskDefinition: news.task.taskDefinitionArn, desiredCount: news.desiredCount ?? 1, - launchType: "FARGATE" as const, platformVersion: news.platformVersion, deploymentConfiguration: news.deploymentConfiguration, healthCheckGracePeriodSeconds: news.healthCheckGracePeriodSeconds, - networkConfiguration: { - awsvpcConfiguration: { - subnets: news.subnets, - securityGroups: news.securityGroups, - assignPublicIp: news.assignPublicIp ? "ENABLED" : "DISABLED", - }, - }, + networkConfiguration: networkConfigurationOf(news), + capacityProviderStrategy: news.capacityProviderStrategy, + placementConstraints: news.placementConstraints, + placementStrategy: news.placementStrategy, + enableExecuteCommand: news.enableExecuteCommand, + propagateTags: news.propagateTags, + availabilityZoneRebalancing: news.availabilityZoneRebalancing, + serviceConnectConfiguration: news.serviceConnectConfiguration, + volumeConfigurations: news.volumeConfigurations, + // launchType and capacityProviderStrategy are mutually exclusive; + // only send launchType when no strategy is provided. + launchType: news.capacityProviderStrategy + ? undefined + : (news.launchType ?? "FARGATE"), }); return { stables: ["serviceArn", "serviceName", "clusterArn"], diff: Effect.fn(function* ({ id, olds, news }) { if (!isResolved(news)) return; + // serviceName change → delete-first replace (name is the identity). if ( (yield* toServiceName(id, olds ?? {})) !== (yield* toServiceName(id, news ?? {})) ) { return { action: "replace", deleteFirst: true } as const; } + // cluster change → replace (a service can't move clusters). + if (clusterArnOf(olds.cluster) !== clusterArnOf(news.cluster)) { + return { action: "replace", deleteFirst: true } as const; + } + // Truly-immutable post-create fields. Everything else (desiredCount, + // taskDefinition, network, deployment config, placement, loadBalancers, + // exec, tags, …) is applied in place by `updateService`. if ( !deepEqual( { - cluster: olds.cluster, - vpcId: olds.vpcId, - subnets: olds.subnets, - securityGroups: olds.securityGroups ?? [], - assignPublicIp: olds.assignPublicIp ?? false, - public: olds.public ?? false, - listenerPort: olds.listenerPort, - certificateArn: olds.certificateArn, - healthCheckPath: olds.healthCheckPath, + // launchType ↔ capacityProviderStrategy switch is immutable. + usesStrategy: !!olds.capacityProviderStrategy, + schedulingStrategy: olds.schedulingStrategy ?? "REPLICA", + deploymentControllerType: + olds.deploymentController?.type ?? "ECS", + enableECSManagedTags: olds.enableECSManagedTags ?? true, + role: olds.role, }, { - cluster: news.cluster, - vpcId: news.vpcId, - subnets: news.subnets, - securityGroups: news.securityGroups ?? [], - assignPublicIp: news.assignPublicIp ?? false, - public: news.public ?? false, - listenerPort: news.listenerPort, - certificateArn: news.certificateArn, - healthCheckPath: news.healthCheckPath, + usesStrategy: !!news.capacityProviderStrategy, + schedulingStrategy: news.schedulingStrategy ?? "REPLICA", + deploymentControllerType: + news.deploymentController?.type ?? "ECS", + enableECSManagedTags: news.enableECSManagedTags ?? true, + role: news.role, }, ) ) { @@ -571,25 +734,22 @@ export const ServiceProvider = () => : undefined; if (!observed?.serviceArn) { + // Provision Alchemy-managed ALB ingress only when requested. if (news.public && !ingress) { ingress = yield* createIngress({ id, news }); } const created = yield* ecs.createService({ - ...serviceInput(news), + ...mutableInput(news), serviceName, cluster: clusterArn, - loadBalancers: ingress - ? [ - { - targetGroupArn: ingress.targetGroupArn!, - containerName: news.task.containerName, - containerPort: news.task.port ?? 3000, - }, - ] - : undefined, + loadBalancers: loadBalancersOf(news, ingress), + serviceRegistries: news.serviceRegistries, + deploymentController: news.deploymentController, + schedulingStrategy: news.schedulingStrategy, + role: news.role, tags: toEcsTags(desiredTags), - enableECSManagedTags: true, + enableECSManagedTags: news.enableECSManagedTags ?? true, }); const service = created.service; if (!service?.serviceArn) { @@ -611,25 +771,58 @@ export const ServiceProvider = () => }; } - // Sync — apply mutable fields (taskDefinition, desiredCount, - // network, deployment) via updateService with a forced new - // deployment. - const updated = yield* ecs.updateService({ - ...serviceInput(news, output), - service: serviceName, - cluster: clusterArn, - loadBalancers: ingress?.targetGroupArn - ? [ - { - targetGroupArn: ingress.targetGroupArn, - containerName: news.task.containerName, - containerPort: news.task.port ?? 3000, - }, - ] - : undefined, - forceNewDeployment: true, - }); + // Sync — apply in-place mutable fields via updateService. Force a new + // deployment so a changed task definition (same revision-less ARN) or + // load-balancer wiring rolls out. + const updated = yield* ecs + .updateService({ + ...mutableInput(news), + service: serviceName, + cluster: clusterArn, + loadBalancers: loadBalancersOf(news, ingress), + enableExecuteCommand: news.enableExecuteCommand, + forceNewDeployment: true, + }) + .pipe( + // The service may still be transitioning (e.g. a prior + // deployment settling). updateService rejects with + // ServiceNotActiveException until it returns to ACTIVE — retry + // bounded. + Effect.retry({ + while: (e) => e._tag === "ServiceNotActiveException", + schedule: Schedule.spaced("5 seconds").pipe( + Schedule.both(Schedule.recurs(8)), + ), + }), + ); const service = updated.service; + + // Sync tags — diff observed service tags against desired. + const observedTags = Object.fromEntries( + (observed.tags ?? []) + .filter( + (t): t is { key: string; value: string } => + typeof t.key === "string" && typeof t.value === "string", + ) + .map((t) => [t.key, t.value]), + ); + const { removed: removedTags, upsert: upsertTags } = diffTags( + observedTags, + desiredTags, + ); + if (upsertTags.length > 0) { + yield* ecs.tagResource({ + resourceArn: observed.serviceArn, + tags: upsertTags.map((t) => ({ key: t.Key, value: t.Value })), + }); + } + if (removedTags.length > 0) { + yield* ecs.untagResource({ + resourceArn: observed.serviceArn, + tagKeys: removedTags, + }); + } + yield* session.note(observed.serviceArn); return { serviceArn: observed.serviceArn as ServiceArn, @@ -648,6 +841,10 @@ export const ServiceProvider = () => }; }), delete: Effect.fn(function* ({ output }) { + // Scale to zero first so `deleteService` has no running tasks to + // drain. If the service is mid-transition (`ServiceNotActiveException`) + // we skip the scale-down — `deleteService({ force: true })` below + // tears it down regardless. yield* ecs .updateService({ cluster: output.clusterArn, @@ -657,6 +854,7 @@ export const ServiceProvider = () => .pipe( Effect.catchTag("ServiceNotFoundException", () => Effect.void), Effect.catchTag("ClusterNotFoundException", () => Effect.void), + Effect.catchTag("ServiceNotActiveException", () => Effect.void), ); yield* ecs diff --git a/packages/alchemy/src/AWS/ECS/Task.ts b/packages/alchemy/src/AWS/ECS/Task.ts index 83acb8ccde..eab8b7c739 100644 --- a/packages/alchemy/src/AWS/ECS/Task.ts +++ b/packages/alchemy/src/AWS/ECS/Task.ts @@ -29,7 +29,12 @@ import * as Provider from "../../Provider.ts"; import { Resource, type ResourceBinding } from "../../Resource.ts"; import type { ProcessContext, ServerHost } from "../../Server/Process.ts"; import { Stack } from "../../Stack.ts"; -import { createInternalTags, createTagsList, hasTags } from "../../Tags.ts"; +import { + createInternalTags, + createTagsList, + diffTags, + hasTags, +} from "../../Tags.ts"; import type { Credentials } from "../Credentials.ts"; import { AWSEnvironment } from "../Environment.ts"; import type { PolicyStatement } from "../IAM/Policy.ts"; @@ -104,11 +109,74 @@ export interface TaskProps extends PlatformProps { dockerfile?: string; }; /** - * Container definition overrides applied after Alchemy's defaults. + * Container definition overrides applied after Alchemy's defaults for the + * primary (bundled) container. */ container?: Partial; /** - * Additional task definition overrides. + * Additional sidecar containers appended to the task definition after the + * primary bundled container. Each entry is a full, typed + * {@link ecs.ContainerDefinition} (image URIs supplied by the user, e.g. + * from an `ECR.Image` or an external registry). + * + * Use this to declare multi-container tasks: log routers (firelens), + * proxies (Envoy/App Mesh), metric agents (otel/cloudwatch), or any + * companion process that shares the task's network namespace. + */ + sidecars?: ecs.ContainerDefinition[]; + /** + * Task definition network mode. + * @default "awsvpc" + */ + networkMode?: ecs.NetworkMode; + /** + * Launch-type compatibilities the task definition must support. + * @default ["FARGATE"] + */ + requiresCompatibilities?: ecs.Compatibility[]; + /** + * Task-level data volumes (host / docker / EFS / FSx Windows / S3 / + * configured-at-launch). Containers reference these via `mountPoints`. + */ + volumes?: ecs.Volume[]; + /** + * Task definition placement constraints (`memberOf` expressions). Only + * applies to EC2/EXTERNAL launch types. + */ + placementConstraints?: ecs.TaskDefinitionPlacementConstraint[]; + /** + * CPU architecture and operating-system family the task runs on, e.g. + * `{ cpuArchitecture: "ARM64", operatingSystemFamily: "LINUX" }`. + */ + runtimePlatform?: ecs.RuntimePlatform; + /** + * Amount of ephemeral storage (in GiB) to allocate for the task on Fargate. + */ + ephemeralStorage?: ecs.EphemeralStorage; + /** + * IPC resource namespace to use for the containers in the task. + */ + ipcMode?: ecs.IpcMode; + /** + * Process namespace to use for the containers in the task. + */ + pidMode?: ecs.PidMode; + /** + * App Mesh proxy configuration. + */ + proxyConfiguration?: ecs.ProxyConfiguration; + /** + * Elastic Inference accelerators to attach to the task. + */ + inferenceAccelerators?: ecs.InferenceAccelerator[]; + /** + * Whether to enable AWS Fault Injection (FIS) actions on the task. + * @default false + */ + enableFaultInjection?: boolean; + /** + * Additional task definition overrides applied last (escape hatch for + * fields not yet surfaced as first-class props). */ taskDefinition?: Partial< Omit< @@ -119,8 +187,6 @@ export interface TaskProps extends PlatformProps { | "taskRoleArn" | "cpu" | "memory" - | "networkMode" - | "requiresCompatibilities" > >; /** @@ -173,6 +239,65 @@ export interface TaskRuntimeContext extends ProcessContext { readonly Type: "AWS.ECS.Task"; } +/** + * A bundled ECS task definition. + * + * `Task` bundles an inline Effect program, builds and pushes a Docker image to + * a generated ECR repository, provisions task + execution IAM roles and a + * CloudWatch log group, and registers a Fargate task definition. Each reconcile + * registers a new immutable revision. + * + * Beyond the single bundled container you can declare task-level configuration + * (volumes, runtime platform, ephemeral storage, IPC/PID mode, placement + * constraints) and append additional `sidecars` for multi-container tasks. + * + * @section Creating a Task + * @example Basic Task + * ```typescript + * const task = yield* Task("ApiTask", { + * main: import.meta.filename, + * cpu: 256, + * memory: 512, + * port: 3000, + * }); + * ``` + * + * @section Multi-Container Tasks + * @example Task with a Sidecar + * ```typescript + * const task = yield* Task("ApiTask", { + * main: import.meta.filename, + * port: 3000, + * sidecars: [ + * { + * name: "otel-collector", + * image: "public.ecr.aws/aws-observability/aws-otel-collector:latest", + * essential: false, + * portMappings: [{ containerPort: 4317, protocol: "tcp" }], + * }, + * ], + * }); + * ``` + * + * @section Task-Level Configuration + * @example ARM64 with EFS Volume and Ephemeral Storage + * ```typescript + * const task = yield* Task("WorkerTask", { + * main: import.meta.filename, + * runtimePlatform: { cpuArchitecture: "ARM64", operatingSystemFamily: "LINUX" }, + * ephemeralStorage: { sizeInGiB: 40 }, + * volumes: [ + * { + * name: "data", + * efsVolumeConfiguration: { fileSystemId: fileSystem.fileSystemId }, + * }, + * ], + * container: { + * mountPoints: [{ sourceVolume: "data", containerPath: "/data" }], + * }, + * }); + * ``` + */ export const Task: Platform = Platform("AWS.ECS.Task", { createRuntimeContext: (id): TaskRuntimeContext => { @@ -623,6 +748,7 @@ await Effect.runPromise(program); taskRoleArn, executionRoleArn, logGroupName, + tags, }: { props: TaskProps; family: string; @@ -630,51 +756,58 @@ await Effect.runPromise(program); taskRoleArn: string; executionRoleArn: string; logGroupName: string; + tags: Record; }) { const { region } = yield* AWSEnvironment.current; const containerName = props.container?.name ?? family; + const primaryContainer: ecs.ContainerDefinition = { + essential: true, + name: containerName, + image: imageUri, + portMappings: + props.port !== undefined + ? [ + { + containerPort: props.port, + hostPort: props.port, + protocol: "tcp", + }, + ] + : undefined, + environment: Object.entries(props.env ?? {}).map(([name, value]) => ({ + name, + value: typeof value === "string" ? value : JSON.stringify(value), + })), + logConfiguration: { + logDriver: "awslogs", + options: { + "awslogs-group": logGroupName, + "awslogs-region": region, + "awslogs-stream-prefix": family, + }, + }, + ...props.container, + }; const response = yield* ecs.registerTaskDefinition({ family, taskRoleArn, executionRoleArn, - networkMode: "awsvpc", - requiresCompatibilities: ["FARGATE"], + networkMode: props.networkMode ?? "awsvpc", + requiresCompatibilities: props.requiresCompatibilities ?? ["FARGATE"], cpu: String(props.cpu ?? 256), memory: String(props.memory ?? 512), + volumes: props.volumes, + placementConstraints: props.placementConstraints, + runtimePlatform: props.runtimePlatform, + ephemeralStorage: props.ephemeralStorage, + ipcMode: props.ipcMode, + pidMode: props.pidMode, + proxyConfiguration: props.proxyConfiguration, + inferenceAccelerators: props.inferenceAccelerators, + enableFaultInjection: props.enableFaultInjection, ...props.taskDefinition, - containerDefinitions: [ - { - essential: true, - name: containerName, - image: imageUri, - portMappings: - props.port !== undefined - ? [ - { - containerPort: props.port, - hostPort: props.port, - protocol: "tcp", - }, - ] - : undefined, - environment: Object.entries(props.env ?? {}).map( - ([name, value]) => ({ - name, - value: - typeof value === "string" ? value : JSON.stringify(value), - }), - ), - logConfiguration: { - logDriver: "awslogs", - options: { - "awslogs-group": logGroupName, - "awslogs-region": region, - "awslogs-stream-prefix": family, - }, - }, - ...props.container, - }, - ], + containerDefinitions: [primaryContainer, ...(props.sidecars ?? [])], + tags: Object.entries(tags).map(([key, value]) => ({ key, value })), }); const taskDefinition = response.taskDefinition; if (!taskDefinition?.taskDefinitionArn) { @@ -895,8 +1028,46 @@ await Effect.runPromise(program); taskRoleArn, executionRoleArn, logGroupName, + tags, }); + // Sync tags — task definition revisions carry tags at register + // time, but tags are mutable on the revision ARN. Diff the observed + // revision tags against desired so tag-only updates converge. + const revisionArn = taskDefinition.taskDefinitionArn!; + const observedTags = Object.fromEntries( + ( + (yield* ecs + .listTagsForResource({ resourceArn: revisionArn }) + .pipe( + Effect.catchTag("ClientException", () => + Effect.succeed({ tags: undefined } as { tags?: ecs.Tag[] }), + ), + )).tags ?? [] + ) + .filter( + (t): t is { key: string; value: string } => + typeof t.key === "string" && typeof t.value === "string", + ) + .map((t) => [t.key, t.value]), + ); + const { removed: removedTags, upsert: upsertTags } = diffTags( + observedTags, + tags, + ); + if (upsertTags.length > 0) { + yield* ecs.tagResource({ + resourceArn: revisionArn, + tags: upsertTags.map((t) => ({ key: t.Key, value: t.Value })), + }); + } + if (removedTags.length > 0) { + yield* ecs.untagResource({ + resourceArn: revisionArn, + tagKeys: removedTags, + }); + } + yield* session.note(taskDefinition.taskDefinitionArn!); return { taskDefinitionArn: taskDefinition.taskDefinitionArn!, diff --git a/packages/alchemy/src/AWS/ELBv2/Listener.ts b/packages/alchemy/src/AWS/ELBv2/Listener.ts index ce8835f127..b67b810dd1 100644 --- a/packages/alchemy/src/AWS/ELBv2/Listener.ts +++ b/packages/alchemy/src/AWS/ELBv2/Listener.ts @@ -8,6 +8,7 @@ import { Resource } from "../../Resource.ts"; import type { AccountID } from "../Environment.ts"; import type { Providers } from "../Providers.ts"; import type { RegionID } from "../Region.ts"; +import { type ListenerAction, serializeActions } from "./common.ts"; import type { LoadBalancer, LoadBalancerArn } from "./LoadBalancer.ts"; import type { TargetGroup, TargetGroupArn } from "./TargetGroup.ts"; @@ -15,12 +16,53 @@ export type ListenerArn = `arn:aws:elasticloadbalancing:${RegionID}:${AccountID}:listener/${string}`; export interface ListenerProps { + /** The load balancer this listener belongs to. Changing it replaces the listener. */ loadBalancerArn: Input | LoadBalancer; - targetGroupArn: Input | TargetGroup; + /** + * Single forward target group. Convenience sugar that desugars to a single + * `{ type: "forward" }` default action. Prefer {@link defaultActions} for the + * full action surface. Mutually exclusive with `defaultActions`. + */ + targetGroupArn?: Input | TargetGroup; + /** + * The default actions for the listener (forward / redirect / fixedResponse / + * authenticateOidc / authenticateCognito). Takes precedence over + * {@link targetGroupArn}. + */ + defaultActions?: ListenerAction[]; + /** The port on which the load balancer listens. Updated in place. */ port: number; - protocol?: "HTTP" | "HTTPS" | "TCP"; + /** + * The listener protocol. + * @default "HTTP" + */ + protocol?: "HTTP" | "HTTPS" | "TCP" | "TLS" | "UDP" | "TCP_UDP"; + /** + * The default (and any additional SNI) certificate ARNs. The first entry is + * the default certificate; the rest are attached as SNI certificates. + * Prefer this over the legacy single {@link certificateArn}. + */ + certificates?: string[]; + /** + * The default certificate ARN (legacy single-cert form). Folded into + * {@link certificates} as the default certificate. + */ certificateArn?: string; + /** The security policy that defines supported protocols and ciphers (HTTPS/TLS). */ sslPolicy?: string; + /** The ALPN policy for TLS listeners (e.g. `HTTP2Optional`). */ + alpnPolicy?: string[]; + /** Mutual TLS (mTLS) configuration for HTTPS listeners. */ + mutualAuthentication?: { + /** The mTLS mode. */ + mode: "off" | "passthrough" | "verify"; + /** The trust store ARN. Required when `mode` is `verify`. */ + trustStoreArn?: string; + /** Whether to ignore expired client certificates. */ + ignoreClientCertificateExpiry?: boolean; + /** Whether to advertise the trust-store CA names in the TLS handshake. */ + advertiseTrustStoreCaNames?: "on" | "off"; + }; } export interface Listener extends Resource< @@ -29,7 +71,7 @@ export interface Listener extends Resource< { listenerArn: ListenerArn; loadBalancerArn: LoadBalancerArn; - targetGroupArn: TargetGroupArn; + targetGroupArn: TargetGroupArn | undefined; port: number; protocol: string; }, @@ -37,8 +79,147 @@ export interface Listener extends Resource< Providers > {} +/** + * An ELBv2 (Application/Network) Load Balancer listener. A listener checks for + * connection requests using its configured protocol and port, then routes them + * to target groups via its default actions (and any attached + * {@link ListenerRule}s). + * + * @section Creating a Listener + * @example Basic HTTP forward listener + * ```typescript + * const listener = yield* Listener("http", { + * loadBalancerArn: lb.loadBalancerArn, + * targetGroupArn: tg.targetGroupArn, + * port: 80, + * protocol: "HTTP", + * }); + * ``` + * + * @example HTTPS listener with certificate and SSL policy + * ```typescript + * const listener = yield* Listener("https", { + * loadBalancerArn: lb.loadBalancerArn, + * defaultActions: [ + * { type: "forward", targetGroups: [{ targetGroupArn: tg.targetGroupArn }] }, + * ], + * port: 443, + * protocol: "HTTPS", + * certificates: [primaryCertArn, sniCertArn], + * sslPolicy: "ELBSecurityPolicy-TLS13-1-2-2021-06", + * }); + * ``` + * + * @section Default Actions + * @example Redirect HTTP to HTTPS + * ```typescript + * const redirect = yield* Listener("redirect", { + * loadBalancerArn: lb.loadBalancerArn, + * defaultActions: [ + * { type: "redirect", statusCode: "HTTP_301", protocol: "HTTPS", port: "443" }, + * ], + * port: 80, + * protocol: "HTTP", + * }); + * ``` + * + * @example Fixed response + * ```typescript + * const maintenance = yield* Listener("maintenance", { + * loadBalancerArn: lb.loadBalancerArn, + * defaultActions: [ + * { type: "fixedResponse", statusCode: "503", contentType: "text/plain", messageBody: "down" }, + * ], + * port: 80, + * }); + * ``` + * + * @example Weighted forward with stickiness + * ```typescript + * const weighted = yield* Listener("weighted", { + * loadBalancerArn: lb.loadBalancerArn, + * defaultActions: [ + * { + * type: "forward", + * targetGroups: [ + * { targetGroupArn: blue.targetGroupArn, weight: 90 }, + * { targetGroupArn: green.targetGroupArn, weight: 10 }, + * ], + * stickiness: { enabled: true, durationSeconds: 3600 }, + * }, + * ], + * port: 80, + * }); + * ``` + * + * @section Mutual TLS + * @example mTLS verify mode with a trust store + * ```typescript + * const mtls = yield* Listener("mtls", { + * loadBalancerArn: lb.loadBalancerArn, + * defaultActions: [ + * { type: "forward", targetGroups: [{ targetGroupArn: tg.targetGroupArn }] }, + * ], + * port: 443, + * protocol: "HTTPS", + * certificates: [certArn], + * mutualAuthentication: { mode: "verify", trustStoreArn: trustStore.trustStoreArn }, + * }); + * ``` + */ export const Listener = Resource("AWS.ELBv2.Listener"); +// Build the default-action wire shape from desugared props. +const desiredDefaultActions = (props: ListenerProps): elbv2.Action[] => { + if (props.defaultActions && props.defaultActions.length > 0) { + return serializeActions(props.defaultActions); + } + if (props.targetGroupArn) { + return serializeActions([ + { + type: "forward", + targetGroups: [ + { targetGroupArn: props.targetGroupArn as TargetGroupArn }, + ], + }, + ]); + } + return []; +}; + +// The default forward target group (if any) for the Attributes shape. +const defaultForwardTargetGroup = ( + actions: elbv2.Action[] | undefined, +): TargetGroupArn | undefined => { + const forward = (actions ?? []).find((a) => a.Type === "forward"); + return (forward?.TargetGroupArn ?? + forward?.ForwardConfig?.TargetGroups?.[0]?.TargetGroupArn) as + | TargetGroupArn + | undefined; +}; + +// Build certificate list: first = default, the rest are SNI extras. +const desiredCertificates = (props: ListenerProps): string[] => { + if (props.certificates && props.certificates.length > 0) { + return props.certificates; + } + return props.certificateArn ? [props.certificateArn] : []; +}; + +const desiredMutualAuth = ( + props: ListenerProps, +): elbv2.MutualAuthenticationAttributes | undefined => + props.mutualAuthentication + ? { + Mode: props.mutualAuthentication.mode, + TrustStoreArn: props.mutualAuthentication.trustStoreArn, + IgnoreClientCertificateExpiry: + props.mutualAuthentication.ignoreClientCertificateExpiry, + AdvertiseTrustStoreCaNames: + props.mutualAuthentication.advertiseTrustStoreCaNames, + } + : undefined; + export const ListenerProvider = () => Provider.succeed(Listener, { stables: ["listenerArn", "loadBalancerArn"], @@ -65,14 +246,12 @@ export const ListenerProvider = () => if (!listener?.ListenerArn) { return undefined; } - const defaultForward = (listener.DefaultActions ?? []).find( - (action) => action.Type === "forward", - ); return { listenerArn: listener.ListenerArn as ListenerArn, loadBalancerArn: listener.LoadBalancerArn as LoadBalancerArn, - targetGroupArn: (defaultForward?.TargetGroupArn ?? - output.targetGroupArn) as TargetGroupArn, + targetGroupArn: + defaultForwardTargetGroup(listener.DefaultActions) ?? + output.targetGroupArn, port: listener.Port!, protocol: listener.Protocol!, }; @@ -107,20 +286,16 @@ export const ListenerProvider = () => (l): l is typeof l & { ListenerArn: string } => l.ListenerArn != null, ) - .map((listener) => { - const defaultForward = ( - listener.DefaultActions ?? [] - ).find((action) => action.Type === "forward"); - return { - listenerArn: listener.ListenerArn as ListenerArn, - loadBalancerArn: - listener.LoadBalancerArn as LoadBalancerArn, - targetGroupArn: (defaultForward?.TargetGroupArn ?? - "") as TargetGroupArn, - port: listener.Port!, - protocol: listener.Protocol!, - }; - }), + .map((listener) => ({ + listenerArn: listener.ListenerArn as ListenerArn, + loadBalancerArn: + listener.LoadBalancerArn as LoadBalancerArn, + targetGroupArn: defaultForwardTargetGroup( + listener.DefaultActions, + ), + port: listener.Port!, + protocol: listener.Protocol!, + })), ), ), // The LB may vanish between enumeration and per-LB listing. @@ -138,8 +313,10 @@ export const ListenerProvider = () => }), reconcile: Effect.fn(function* ({ news, output, session }) { const loadBalancerArn = news.loadBalancerArn as LoadBalancerArn; - const desiredTargetGroupArn = news.targetGroupArn as TargetGroupArn; const desiredProtocol = news.protocol ?? "HTTP"; + const defaultActions = desiredDefaultActions(news); + const certs = desiredCertificates(news); + const mutualAuthentication = desiredMutualAuth(news); // Observe — describe the listener if we have a prior ARN; otherwise // list listeners on the load balancer and find one matching port. @@ -172,22 +349,18 @@ export const ListenerProvider = () => listener = listed?.Listeners?.find((l) => l.Port === news.port); } - // Ensure — create if missing. + // Ensure — create if missing. The first certificate is the default. if (!listener?.ListenerArn) { const created = yield* elbv2.createListener({ LoadBalancerArn: loadBalancerArn, Port: news.port, Protocol: desiredProtocol, - Certificates: news.certificateArn - ? [{ CertificateArn: news.certificateArn }] - : undefined, + Certificates: + certs.length > 0 ? [{ CertificateArn: certs[0] }] : undefined, SslPolicy: news.sslPolicy, - DefaultActions: [ - { - Type: "forward", - TargetGroupArn: desiredTargetGroupArn, - }, - ], + AlpnPolicy: news.alpnPolicy, + MutualAuthentication: mutualAuthentication, + DefaultActions: defaultActions, }); listener = created.Listeners?.[0]; if (!listener?.ListenerArn) { @@ -195,41 +368,60 @@ export const ListenerProvider = () => new Error("createListener returned no listener"), ); } - yield* session.note(listener.ListenerArn); - return { - listenerArn: listener.ListenerArn as ListenerArn, - loadBalancerArn: listener.LoadBalancerArn as LoadBalancerArn, - targetGroupArn: desiredTargetGroupArn, - port: listener.Port!, - protocol: listener.Protocol!, - }; + } else { + // Sync — modifyListener fully replaces these mutable fields. + const modified = yield* elbv2.modifyListener({ + ListenerArn: listener.ListenerArn, + Port: news.port, + Protocol: desiredProtocol, + Certificates: + certs.length > 0 ? [{ CertificateArn: certs[0] }] : undefined, + SslPolicy: news.sslPolicy, + AlpnPolicy: news.alpnPolicy, + MutualAuthentication: mutualAuthentication, + DefaultActions: defaultActions, + }); + listener = modified.Listeners?.[0] ?? listener; } - // Sync — apply mutable fields (port, protocol, certificates, - // sslPolicy, defaultActions). modifyListener fully replaces these. - const modified = yield* elbv2.modifyListener({ - ListenerArn: listener.ListenerArn, - Port: news.port, - Protocol: desiredProtocol, - Certificates: news.certificateArn - ? [{ CertificateArn: news.certificateArn }] - : undefined, - SslPolicy: news.sslPolicy, - DefaultActions: [ - { - Type: "forward", - TargetGroupArn: desiredTargetGroupArn, - }, - ], - }); - const final = modified.Listeners?.[0] ?? listener; - yield* session.note(listener.ListenerArn); + const listenerArn = listener.ListenerArn!; + + // Sync additional SNI certificates — observed ↔ desired. The default + // certificate (certs[0]) is carried by modifyListener and is excluded + // from the SNI set. + const desiredSni = new Set(certs.slice(1)); + const observedCerts = yield* elbv2 + .describeListenerCertificates({ ListenerArn: listenerArn }) + .pipe( + Effect.catchTag("ListenerNotFoundException", () => + Effect.succeed(undefined), + ), + ); + const observedSni = (observedCerts?.Certificates ?? []) + .filter((c) => !c.IsDefault && c.CertificateArn) + .map((c) => c.CertificateArn!); + const toAdd = [...desiredSni].filter((arn) => !observedSni.includes(arn)); + const toRemove = observedSni.filter((arn) => !desiredSni.has(arn)); + if (toAdd.length > 0) { + yield* elbv2.addListenerCertificates({ + ListenerArn: listenerArn, + Certificates: toAdd.map((arn) => ({ CertificateArn: arn })), + }); + } + if (toRemove.length > 0) { + yield* elbv2.removeListenerCertificates({ + ListenerArn: listenerArn, + Certificates: toRemove.map((arn) => ({ CertificateArn: arn })), + }); + } + + yield* session.note(listenerArn); return { - listenerArn: listener.ListenerArn as ListenerArn, + listenerArn: listenerArn as ListenerArn, loadBalancerArn: listener.LoadBalancerArn as LoadBalancerArn, - targetGroupArn: desiredTargetGroupArn, - port: final.Port ?? news.port, - protocol: final.Protocol ?? desiredProtocol, + targetGroupArn: defaultForwardTargetGroup(defaultActions), + port: listener.Port ?? news.port, + protocol: listener.Protocol ?? desiredProtocol, }; }), delete: Effect.fn(function* ({ output }) { diff --git a/packages/alchemy/src/AWS/ELBv2/ListenerRule.ts b/packages/alchemy/src/AWS/ELBv2/ListenerRule.ts new file mode 100644 index 0000000000..b93bf6d583 --- /dev/null +++ b/packages/alchemy/src/AWS/ELBv2/ListenerRule.ts @@ -0,0 +1,289 @@ +import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; +import { isResolved } from "../../Diff.ts"; +import type { Input } from "../../Input.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import { createInternalTags, diffTags } from "../../Tags.ts"; +import type { AccountID } from "../Environment.ts"; +import type { Providers } from "../Providers.ts"; +import type { RegionID } from "../Region.ts"; +import { + type ListenerAction, + type ListenerRuleCondition, + serializeActions, + serializeConditions, +} from "./common.ts"; +import type { Listener, ListenerArn } from "./Listener.ts"; + +export type RuleArn = + `arn:aws:elasticloadbalancing:${RegionID}:${AccountID}:listener-rule/${string}`; + +export interface ListenerRuleProps { + /** The listener this rule attaches to. Changing it replaces the rule. */ + listenerArn: Input | Listener; + /** + * The rule priority (1-50000). Lower numbers are evaluated first. Updated in + * place via `setRulePriorities`. + */ + priority: number; + /** The conditions under which the rule matches a request (AND-ed). */ + conditions: ListenerRuleCondition[]; + /** The actions to take when the rule matches. */ + actions: ListenerAction[]; + /** Tags to apply to the rule. */ + tags?: Record; +} + +export interface ListenerRule extends Resource< + "AWS.ELBv2.ListenerRule", + ListenerRuleProps, + { + ruleArn: RuleArn; + listenerArn: ListenerArn; + priority: number; + isDefault: boolean; + }, + never, + Providers +> {} + +/** + * An ELBv2 listener rule. Rules attach to an Application Load Balancer listener + * and route requests to target groups (or other actions) based on conditions + * such as host header, path pattern, HTTP header, query string, request method, + * and source IP. + * + * @section Creating a Rule + * @example Path-based routing + * ```typescript + * const rule = yield* ListenerRule("api", { + * listenerArn: listener.listenerArn, + * priority: 10, + * conditions: [{ pathPattern: { values: ["/api/*"] } }], + * actions: [ + * { type: "forward", targetGroups: [{ targetGroupArn: apiTg.targetGroupArn }] }, + * ], + * }); + * ``` + * + * @example Host-header routing + * ```typescript + * const rule = yield* ListenerRule("admin", { + * listenerArn: listener.listenerArn, + * priority: 20, + * conditions: [{ hostHeader: { values: ["admin.example.com"] } }], + * actions: [ + * { type: "forward", targetGroups: [{ targetGroupArn: adminTg.targetGroupArn }] }, + * ], + * }); + * ``` + * + * @section Conditions + * @example Combining query-string and HTTP-header conditions + * ```typescript + * const rule = yield* ListenerRule("beta", { + * listenerArn: listener.listenerArn, + * priority: 30, + * conditions: [ + * { queryString: { values: [{ key: "version", value: "beta" }] } }, + * { httpHeader: { name: "X-Channel", values: ["internal"] } }, + * ], + * actions: [{ type: "fixedResponse", statusCode: "200", messageBody: "beta" }], + * }); + * ``` + */ +export const ListenerRule = Resource("AWS.ELBv2.ListenerRule"); + +export const ListenerRuleProvider = () => + Provider.succeed(ListenerRule, { + stables: ["ruleArn", "listenerArn"], + diff: Effect.fn(function* ({ olds, news }) { + if (!isResolved(news)) return; + // priority is mutable in place via setRulePriorities; only the listener + // forces replacement. + if (olds.listenerArn !== news.listenerArn) { + return { action: "replace" } as const; + } + }), + read: Effect.fn(function* ({ output }) { + if (!output) { + return undefined; + } + const described = yield* elbv2 + .describeRules({ RuleArns: [output.ruleArn] }) + .pipe( + Effect.catchTag("RuleNotFoundException", () => + Effect.succeed(undefined), + ), + ); + const rule = described?.Rules?.[0]; + if (!rule?.RuleArn) { + return undefined; + } + return { + ruleArn: rule.RuleArn as RuleArn, + listenerArn: output.listenerArn, + priority: Number(rule.Priority ?? output.priority), + isDefault: rule.IsDefault ?? false, + }; + }), + // Rules belong to a listener, which belongs to a load balancer. Enumerate + // every load balancer, then every listener, then every rule. + list: Effect.fn(function* () { + const loadBalancerArns = yield* elbv2.describeLoadBalancers + .pages({}) + .pipe( + Stream.runCollect, + Effect.map((chunk) => + Array.from(chunk).flatMap((page) => + (page.LoadBalancers ?? []).flatMap((lb) => + lb.LoadBalancerArn ? [lb.LoadBalancerArn] : [], + ), + ), + ), + ); + const listenerArns = yield* Effect.forEach( + loadBalancerArns, + (loadBalancerArn) => + elbv2.describeListeners + .pages({ LoadBalancerArn: loadBalancerArn }) + .pipe( + Stream.runCollect, + Effect.map((chunk) => + Array.from(chunk).flatMap((page) => + (page.Listeners ?? []).flatMap((l) => + l.ListenerArn ? [l.ListenerArn as ListenerArn] : [], + ), + ), + ), + Effect.catchTag("LoadBalancerNotFoundException", () => + Effect.succeed([]), + ), + Effect.catchTag("ListenerNotFoundException", () => + Effect.succeed([]), + ), + ), + { concurrency: 10 }, + ); + const rows = yield* Effect.forEach( + listenerArns.flat(), + (listenerArn) => + elbv2.describeRules({ ListenerArn: listenerArn }).pipe( + Effect.map((res) => + (res.Rules ?? []) + .filter( + (r): r is typeof r & { RuleArn: string } => + r.RuleArn != null && !r.IsDefault, + ) + .map((rule) => ({ + ruleArn: rule.RuleArn as RuleArn, + listenerArn, + priority: Number(rule.Priority ?? 0), + isDefault: rule.IsDefault ?? false, + })), + ), + Effect.catchTag("ListenerNotFoundException", () => + Effect.succeed([]), + ), + Effect.catchTag("RuleNotFoundException", () => Effect.succeed([])), + ), + { concurrency: 10 }, + ); + const result: ListenerRule["Attributes"][] = rows.flat(); + return result; + }), + reconcile: Effect.fn(function* ({ id, news, output, session }) { + const listenerArn = news.listenerArn as ListenerArn; + const desiredTags = { + ...(yield* createInternalTags(id)), + ...news.tags, + }; + const conditions = serializeConditions(news.conditions); + const actions = serializeActions(news.actions); + + // Observe — look up the rule by our prior ARN. + let rule: elbv2.Rule | undefined; + if (output?.ruleArn) { + const described = yield* elbv2 + .describeRules({ RuleArns: [output.ruleArn] }) + .pipe( + Effect.catchTag("RuleNotFoundException", () => + Effect.succeed(undefined), + ), + ); + rule = described?.Rules?.[0]; + } + + // Ensure — create if missing. + if (!rule?.RuleArn) { + const created = yield* elbv2.createRule({ + ListenerArn: listenerArn, + Priority: news.priority, + Conditions: conditions, + Actions: actions, + Tags: Object.entries(desiredTags).map(([Key, Value]) => ({ + Key, + Value, + })), + }); + rule = created.Rules?.[0]; + if (!rule?.RuleArn) { + return yield* Effect.die(new Error("createRule returned no rule")); + } + } else { + // Sync conditions + actions — modifyRule fully replaces these lists. + const modified = yield* elbv2.modifyRule({ + RuleArn: rule.RuleArn, + Conditions: conditions, + Actions: actions, + }); + rule = modified.Rules?.[0] ?? rule; + + // Sync priority — not mutable via modifyRule. + if (Number(rule.Priority) !== news.priority) { + yield* elbv2.setRulePriorities({ + RulePriorities: [ + { RuleArn: rule.RuleArn, Priority: news.priority }, + ], + }); + } + } + + const ruleArn = rule.RuleArn!; + + // Sync tags — diff observed cloud tags against desired. + const tagDescriptions = yield* elbv2.describeTags({ + ResourceArns: [ruleArn], + }); + const observedTags = Object.fromEntries( + (tagDescriptions.TagDescriptions?.[0]?.Tags ?? []) + .filter( + (t): t is { Key: string; Value: string } => + typeof t.Key === "string" && typeof t.Value === "string", + ) + .map((t) => [t.Key, t.Value]), + ); + const { removed, upsert } = diffTags(observedTags, desiredTags); + if (upsert.length > 0) { + yield* elbv2.addTags({ ResourceArns: [ruleArn], Tags: upsert }); + } + if (removed.length > 0) { + yield* elbv2.removeTags({ ResourceArns: [ruleArn], TagKeys: removed }); + } + + yield* session.note(ruleArn); + return { + ruleArn: ruleArn as RuleArn, + listenerArn, + priority: news.priority, + isDefault: rule.IsDefault ?? false, + }; + }), + delete: Effect.fn(function* ({ output }) { + yield* elbv2 + .deleteRule({ RuleArn: output.ruleArn }) + .pipe(Effect.catchTag("RuleNotFoundException", () => Effect.void)); + }), + }); diff --git a/packages/alchemy/src/AWS/ELBv2/LoadBalancer.ts b/packages/alchemy/src/AWS/ELBv2/LoadBalancer.ts index f2dd487f3e..d549ba40b7 100644 --- a/packages/alchemy/src/AWS/ELBv2/LoadBalancer.ts +++ b/packages/alchemy/src/AWS/ELBv2/LoadBalancer.ts @@ -18,13 +18,55 @@ export type LoadBalancerArn = `arn:aws:elasticloadbalancing:${RegionID}:${AccountID}:loadbalancer/${string}`; export interface LoadBalancerProps { + /** The load balancer name. If omitted, a unique name is generated. Changing it replaces the load balancer. */ name?: string; + /** + * Whether the load balancer is internet-facing or internal. Changing it + * replaces the load balancer. + * @default "internet-facing" + */ scheme?: "internal" | "internet-facing"; - type?: "application" | "network"; - subnets: Input; + /** + * The load balancer type. Changing it replaces the load balancer. + * @default "application" + */ + type?: "application" | "network" | "gateway"; + /** + * The subnets to attach. Mutually exclusive with {@link subnetMappings}. + * Updated in place via `setSubnets`. + */ + subnets?: Input; + /** + * Per-subnet mappings for static/EIP addresses (Network Load Balancers). + * Mutually exclusive with {@link subnets}. Updated in place via `setSubnets`. + */ + subnetMappings?: { + subnetId: Input; + /** The allocation ID of an Elastic IP (NLB). */ + allocationId?: string; + /** A private IPv4 address from the subnet (internal NLB). */ + privateIPv4Address?: string; + /** An IPv6 address from the subnet (dualstack NLB). */ + iPv6Address?: string; + /** A source NAT IPv6 prefix. */ + sourceNatIpv6Prefix?: string; + }[]; + /** The security groups to attach. Updated in place via `setSecurityGroups`. */ securityGroups?: Input; + /** The IP address type (`ipv4`, `dualstack`, ...). Updated in place via `setIpAddressType`. */ ipAddressType?: string; + /** The ID of the customer-owned IPv4 pool (Outposts). Changing it replaces the load balancer. */ + customerOwnedIpv4Pool?: string; + /** Whether to prefix-delegate IPv6 for source NAT (`on`/`off`). */ + enablePrefixForIpv6SourceNat?: "on" | "off"; + /** + * Whether to enforce security-group inbound rules on PrivateLink traffic + * (`on`/`off`). Carried by `setSecurityGroups`. + */ + enforceSecurityGroupInboundRulesOnPrivateLinkTraffic?: "on" | "off"; + /** Raw load-balancer attributes (idle timeout, deletion protection, access logs, ...). */ attributes?: Record; + /** Tags to apply to the load balancer. */ tags?: Record; } @@ -47,6 +89,45 @@ export interface LoadBalancer extends Resource< Providers > {} +/** + * An ELBv2 (Application / Network / Gateway) load balancer. + * + * @section Creating a Load Balancer + * @example Internet-facing Application Load Balancer + * ```typescript + * const lb = yield* LoadBalancer("web", { + * type: "application", + * scheme: "internet-facing", + * subnets: [subnet1.subnetId, subnet2.subnetId], + * securityGroups: [sg.groupId], + * }); + * ``` + * + * @example Network Load Balancer with static EIPs + * ```typescript + * const nlb = yield* LoadBalancer("edge", { + * type: "network", + * scheme: "internet-facing", + * subnetMappings: [ + * { subnetId: subnet1.subnetId, allocationId: eip1.allocationId }, + * { subnetId: subnet2.subnetId, allocationId: eip2.allocationId }, + * ], + * }); + * ``` + * + * @section Attributes + * @example Idle timeout and deletion protection + * ```typescript + * const lb = yield* LoadBalancer("web", { + * type: "application", + * subnets: [subnet1.subnetId, subnet2.subnetId], + * attributes: { + * "idle_timeout.timeout_seconds": "120", + * "deletion_protection.enabled": "true", + * }, + * }); + * ``` + */ export const LoadBalancer = Resource("AWS.ELBv2.LoadBalancer"); export const LoadBalancerProvider = () => @@ -73,21 +154,20 @@ export const LoadBalancerProvider = () => if (oldName !== newName) { return { action: "replace" } as const; } + // Only scheme, type and customerOwnedIpv4Pool are immutable. + // subnets, securityGroups and ipAddressType are mutated in place + // during reconcile (setSubnets / setSecurityGroups / setIpAddressType). if ( !deepEqual( { scheme: olds.scheme ?? "internet-facing", type: olds.type ?? "application", - subnets: olds.subnets, - securityGroups: olds.securityGroups ?? [], - ipAddressType: olds.ipAddressType, + customerOwnedIpv4Pool: olds.customerOwnedIpv4Pool, }, { scheme: news.scheme ?? "internet-facing", type: news.type ?? "application", - subnets: news.subnets, - securityGroups: news.securityGroups ?? [], - ipAddressType: news.ipAddressType, + customerOwnedIpv4Pool: news.customerOwnedIpv4Pool, }, ) ) { @@ -218,17 +298,28 @@ export const LoadBalancerProvider = () => ); let loadBalancer = described?.LoadBalancers?.[0]; + const subnetMappings = news.subnetMappings?.map((m) => ({ + SubnetId: m.subnetId as string, + AllocationId: m.allocationId, + PrivateIPv4Address: m.privateIPv4Address, + IPv6Address: m.iPv6Address, + SourceNatIpv6Prefix: m.sourceNatIpv6Prefix, + })); + // Ensure — create if missing. The replacement axes (scheme, type, - // subnets, securityGroups, ipAddressType) are handled by diff so - // we don't need to deal with mismatches here. + // customerOwnedIpv4Pool) are handled by diff so we don't need to + // deal with mismatches here. if (!loadBalancer?.LoadBalancerArn) { const created = yield* elbv2.createLoadBalancer({ Name: name, Scheme: news.scheme ?? "internet-facing", Type: news.type ?? "application", - Subnets: news.subnets as string[], + Subnets: news.subnets as string[] | undefined, + SubnetMappings: subnetMappings, SecurityGroups: news.securityGroups as string[] | undefined, IpAddressType: news.ipAddressType, + CustomerOwnedIpv4Pool: news.customerOwnedIpv4Pool, + EnablePrefixForIpv6SourceNat: news.enablePrefixForIpv6SourceNat, Tags: Object.entries(desiredTags).map(([Key, Value]) => ({ Key, Value, @@ -245,6 +336,60 @@ export const LoadBalancerProvider = () => const loadBalancerArn = loadBalancer.LoadBalancerArn as LoadBalancerArn; + // Sync subnets — diff observed against desired. Only applies to + // application/network LBs that manage subnets in place. + const observedSubnets = + loadBalancer.AvailabilityZones?.flatMap((z) => + z.SubnetId ? [z.SubnetId] : [], + ) ?? []; + if (news.subnets) { + const desiredSubnets = news.subnets as string[]; + if ( + !deepEqual( + [...observedSubnets].sort(), + [...desiredSubnets].sort(), + ) + ) { + yield* elbv2.setSubnets({ + LoadBalancerArn: loadBalancerArn, + Subnets: desiredSubnets, + }); + } + } else if (subnetMappings) { + yield* elbv2.setSubnets({ + LoadBalancerArn: loadBalancerArn, + SubnetMappings: subnetMappings, + }); + } + + // Sync security groups — diff observed against desired. + if (news.securityGroups) { + const observedSgs = loadBalancer.SecurityGroups ?? []; + const desiredSgs = news.securityGroups as string[]; + if ( + !deepEqual([...observedSgs].sort(), [...desiredSgs].sort()) || + news.enforceSecurityGroupInboundRulesOnPrivateLinkTraffic + ) { + yield* elbv2.setSecurityGroups({ + LoadBalancerArn: loadBalancerArn, + SecurityGroups: desiredSgs, + EnforceSecurityGroupInboundRulesOnPrivateLinkTraffic: + news.enforceSecurityGroupInboundRulesOnPrivateLinkTraffic, + }); + } + } + + // Sync IP address type — diff observed against desired. + if ( + news.ipAddressType && + news.ipAddressType !== loadBalancer.IpAddressType + ) { + yield* elbv2.setIpAddressType({ + LoadBalancerArn: loadBalancerArn, + IpAddressType: news.ipAddressType, + }); + } + // Sync attributes — observed ↔ desired. We always apply when // desired attrs are non-empty; AWS rejects an empty list anyway, // and reading observed attributes is an extra round-trip we diff --git a/packages/alchemy/src/AWS/ELBv2/TargetGroup.ts b/packages/alchemy/src/AWS/ELBv2/TargetGroup.ts index 59eb3c8a0c..b5136314f1 100644 --- a/packages/alchemy/src/AWS/ELBv2/TargetGroup.ts +++ b/packages/alchemy/src/AWS/ELBv2/TargetGroup.ts @@ -1,5 +1,6 @@ import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2"; import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; import { deepEqual, isResolved } from "../../Diff.ts"; import { createPhysicalName } from "../../PhysicalName.ts"; @@ -15,16 +16,51 @@ export type TargetGroupArn = `arn:aws:elasticloadbalancing:${RegionID}:${AccountID}:targetgroup/${string}`; export interface TargetGroupProps { + /** The target group name. If omitted, a unique name is generated. Changing it replaces the target group. */ name?: string; - vpcId: string; - port: number; - protocol?: "HTTP" | "HTTPS" | "TCP"; - targetType?: "ip" | "instance"; + /** The VPC the targets live in. Not required for `lambda` targets. Changing it replaces the target group. */ + vpcId?: string; + /** The port on which targets receive traffic. Changing it replaces the target group. */ + port?: number; + /** + * The protocol for routing traffic to targets. Changing it replaces the + * target group. + * @default "HTTP" + */ + protocol?: "HTTP" | "HTTPS" | "TCP" | "UDP" | "TCP_UDP" | "TLS" | "GENEVE"; + /** + * The application protocol version. Use `GRPC` for gRPC, `HTTP2` for HTTP/2. + * Changing it replaces the target group. + */ + protocolVersion?: "HTTP1" | "HTTP2" | "GRPC"; + /** + * The target type. Changing it replaces the target group. + * @default "ip" + */ + targetType?: "ip" | "instance" | "lambda" | "alb"; + /** The IP address type (`ipv4`/`ipv6`). Changing it replaces the target group. */ + ipAddressType?: "ipv4" | "ipv6"; + /** The health-check path (HTTP/HTTPS). Updated in place. */ healthCheckPath?: string; + /** The health-check port. Updated in place. */ healthCheckPort?: string; + /** The health-check protocol. Updated in place. */ healthCheckProtocol?: string; + /** Whether health checks are enabled. Updated in place. */ + healthCheckEnabled?: boolean; + /** The approximate interval between health checks, in seconds. Updated in place. */ + healthCheckIntervalSeconds?: number; + /** The amount of time, in seconds, to wait for a health-check response. Updated in place. */ + healthCheckTimeoutSeconds?: number; + /** The number of consecutive successes before a target is healthy. Updated in place. */ + healthyThresholdCount?: number; + /** The number of consecutive failures before a target is unhealthy. Updated in place. */ + unhealthyThresholdCount?: number; + /** The HTTP/gRPC codes used to determine a healthy response. Updated in place. */ matcher?: { HttpCode?: string; GrpcCode?: string }; + /** Raw target-group attributes (deregistration delay, stickiness, slow start, ...). */ attributes?: Record; + /** Tags to apply to the target group. */ tags?: Record; } @@ -44,6 +80,47 @@ export interface TargetGroup extends Resource< Providers > {} +/** + * An ELBv2 target group. A target group routes requests to one or more + * registered targets (instances, IPs, Lambda functions, or another ALB) using + * the configured protocol and port, and runs health checks against them. + * + * @section Creating a Target Group + * @example HTTP target group + * ```typescript + * const tg = yield* TargetGroup("web", { + * vpcId: vpc.vpcId, + * port: 80, + * protocol: "HTTP", + * targetType: "ip", + * }); + * ``` + * + * @example gRPC target group + * ```typescript + * const tg = yield* TargetGroup("grpc", { + * vpcId: vpc.vpcId, + * port: 50051, + * protocol: "HTTP", + * protocolVersion: "GRPC", + * matcher: { GrpcCode: "0" }, + * }); + * ``` + * + * @section Health Checks + * @example Custom health-check thresholds + * ```typescript + * const tg = yield* TargetGroup("api", { + * vpcId: vpc.vpcId, + * port: 8080, + * protocol: "HTTP", + * healthCheckPath: "/healthz", + * healthCheckIntervalSeconds: 15, + * healthyThresholdCount: 3, + * unhealthyThresholdCount: 3, + * }); + * ``` + */ export const TargetGroup = Resource("AWS.ELBv2.TargetGroup"); export const TargetGroupProvider = () => @@ -69,14 +146,18 @@ export const TargetGroupProvider = () => { vpcId: olds.vpcId, protocol: olds.protocol ?? "HTTP", + protocolVersion: olds.protocolVersion, port: olds.port, targetType: olds.targetType ?? "ip", + ipAddressType: olds.ipAddressType, }, { vpcId: news.vpcId, protocol: news.protocol ?? "HTTP", + protocolVersion: news.protocolVersion, port: news.port, targetType: news.targetType ?? "ip", + ipAddressType: news.ipAddressType, }, ) ) { @@ -187,11 +268,18 @@ export const TargetGroupProvider = () => Name: name, Port: news.port, Protocol: news.protocol ?? "HTTP", + ProtocolVersion: news.protocolVersion, VpcId: news.vpcId, TargetType: news.targetType ?? "ip", + IpAddressType: news.ipAddressType, HealthCheckPath: news.healthCheckPath, HealthCheckPort: news.healthCheckPort, HealthCheckProtocol: news.healthCheckProtocol, + HealthCheckEnabled: news.healthCheckEnabled, + HealthCheckIntervalSeconds: news.healthCheckIntervalSeconds, + HealthCheckTimeoutSeconds: news.healthCheckTimeoutSeconds, + HealthyThresholdCount: news.healthyThresholdCount, + UnhealthyThresholdCount: news.unhealthyThresholdCount, Matcher: news.matcher, Tags: Object.entries(desiredTags).map(([Key, Value]) => ({ Key, @@ -208,15 +296,45 @@ export const TargetGroupProvider = () => const targetGroupArn = targetGroup.TargetGroupArn as TargetGroupArn; - // Sync health check — modifyTargetGroup fully replaces these - // mutable fields. - yield* elbv2.modifyTargetGroup({ - TargetGroupArn: targetGroupArn, - HealthCheckPath: news.healthCheckPath, - HealthCheckPort: news.healthCheckPort, - HealthCheckProtocol: news.healthCheckProtocol, - Matcher: news.matcher, - }); + // Sync health check — diff observed against desired; only call + // modifyTargetGroup when a health-check field actually changed. + const observedHc = { + HealthCheckPath: targetGroup.HealthCheckPath, + HealthCheckPort: targetGroup.HealthCheckPort, + HealthCheckProtocol: targetGroup.HealthCheckProtocol, + HealthCheckEnabled: targetGroup.HealthCheckEnabled, + HealthCheckIntervalSeconds: targetGroup.HealthCheckIntervalSeconds, + HealthCheckTimeoutSeconds: targetGroup.HealthCheckTimeoutSeconds, + HealthyThresholdCount: targetGroup.HealthyThresholdCount, + UnhealthyThresholdCount: targetGroup.UnhealthyThresholdCount, + Matcher: targetGroup.Matcher, + }; + const desiredHc = { + HealthCheckPath: news.healthCheckPath ?? observedHc.HealthCheckPath, + HealthCheckPort: news.healthCheckPort ?? observedHc.HealthCheckPort, + HealthCheckProtocol: + news.healthCheckProtocol ?? observedHc.HealthCheckProtocol, + HealthCheckEnabled: + news.healthCheckEnabled ?? observedHc.HealthCheckEnabled, + HealthCheckIntervalSeconds: + news.healthCheckIntervalSeconds ?? + observedHc.HealthCheckIntervalSeconds, + HealthCheckTimeoutSeconds: + news.healthCheckTimeoutSeconds ?? + observedHc.HealthCheckTimeoutSeconds, + HealthyThresholdCount: + news.healthyThresholdCount ?? observedHc.HealthyThresholdCount, + UnhealthyThresholdCount: + news.unhealthyThresholdCount ?? + observedHc.UnhealthyThresholdCount, + Matcher: news.matcher ?? observedHc.Matcher, + }; + if (!deepEqual(observedHc, desiredHc)) { + yield* elbv2.modifyTargetGroup({ + TargetGroupArn: targetGroupArn, + ...desiredHc, + }); + } // Sync attributes — observed ↔ desired. Always apply when desired // attrs are non-empty. @@ -270,11 +388,23 @@ export const TargetGroupProvider = () => }; }), delete: Effect.fn(function* ({ output }) { + // deleteTargetGroup is idempotent on a missing target group (returns + // success). It only fails with ResourceInUseException while a + // listener/rule still references it — retry briefly for the + // eventual-consistency window after the dependents are removed. yield* elbv2 .deleteTargetGroup({ TargetGroupArn: output.targetGroupArn, }) - .pipe(Effect.catch(() => Effect.void)); + .pipe( + Effect.retry({ + while: (e) => e._tag === "ResourceInUseException", + schedule: Schedule.spaced("3 seconds").pipe( + Schedule.both(Schedule.recurs(8)), + ), + }), + Effect.catchTag("ResourceInUseException", () => Effect.void), + ); }), }; }), diff --git a/packages/alchemy/src/AWS/ELBv2/TrustStore.ts b/packages/alchemy/src/AWS/ELBv2/TrustStore.ts new file mode 100644 index 0000000000..406beac62e --- /dev/null +++ b/packages/alchemy/src/AWS/ELBv2/TrustStore.ts @@ -0,0 +1,277 @@ +import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import * as Stream from "effect/Stream"; +import { isResolved } from "../../Diff.ts"; +import { createPhysicalName } from "../../PhysicalName.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import { createInternalTags, diffTags } from "../../Tags.ts"; +import type { AccountID } from "../Environment.ts"; +import type { Providers } from "../Providers.ts"; +import type { RegionID } from "../Region.ts"; + +export type TrustStoreArn = + `arn:aws:elasticloadbalancing:${RegionID}:${AccountID}:truststore/${string}`; + +export interface TrustStoreProps { + /** The trust store name. If omitted, a unique name is generated. Changing it replaces the trust store. */ + name?: string; + /** The S3 bucket holding the CA certificate bundle (PEM). */ + caCertificatesBundleS3Bucket: string; + /** The S3 key of the CA certificate bundle. */ + caCertificatesBundleS3Key: string; + /** The S3 object version of the CA certificate bundle. */ + caCertificatesBundleS3ObjectVersion?: string; + /** Tags to apply to the trust store. */ + tags?: Record; +} + +export interface TrustStore extends Resource< + "AWS.ELBv2.TrustStore", + TrustStoreProps, + { + trustStoreArn: TrustStoreArn; + name: string; + status: string; + numberOfCaCertificates: number; + tags: Record; + }, + never, + Providers +> {} + +/** + * An ELBv2 trust store. A trust store holds the CA certificate bundle used by + * an HTTPS listener configured for mutual TLS (mTLS) `verify` mode to validate + * client certificates. + * + * @section Creating a Trust Store + * @example Basic trust store from an S3 CA bundle + * ```typescript + * const trustStore = yield* TrustStore("mtls", { + * caCertificatesBundleS3Bucket: "my-ca-bundles", + * caCertificatesBundleS3Key: "ca-bundle.pem", + * }); + * ``` + * + * @example Using a trust store on an mTLS listener + * ```typescript + * const listener = yield* Listener("https", { + * loadBalancerArn: lb.loadBalancerArn, + * port: 443, + * protocol: "HTTPS", + * certificates: [certArn], + * mutualAuthentication: { + * mode: "verify", + * trustStoreArn: trustStore.trustStoreArn, + * }, + * defaultActions: [ + * { type: "forward", targetGroups: [{ targetGroupArn: tg.targetGroupArn }] }, + * ], + * }); + * ``` + */ +export const TrustStore = Resource("AWS.ELBv2.TrustStore"); + +export const TrustStoreProvider = () => + Provider.effect( + TrustStore, + Effect.gen(function* () { + const toName = (id: string, props: { name?: string } = {}) => + props.name + ? Effect.succeed(props.name) + : createPhysicalName({ id, maxLength: 32, lowercase: true }); + + const observedTags = (arn: string) => + Effect.gen(function* () { + const tagDescriptions = yield* elbv2.describeTags({ + ResourceArns: [arn], + }); + return Object.fromEntries( + (tagDescriptions.TagDescriptions?.[0]?.Tags ?? []) + .filter( + (t): t is { Key: string; Value: string } => + typeof t.Key === "string" && typeof t.Value === "string", + ) + .map((t) => [t.Key, t.Value]), + ); + }); + + return { + stables: ["trustStoreArn", "name"], + diff: Effect.fn(function* ({ id, olds, news }) { + if (!isResolved(news)) return; + if ( + (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {})) + ) { + return { action: "replace" } as const; + } + }), + read: Effect.fn(function* ({ output }) { + if (!output) { + return undefined; + } + const described = yield* elbv2 + .describeTrustStores({ + TrustStoreArns: [output.trustStoreArn], + }) + .pipe( + Effect.catchTag("TrustStoreNotFoundException", () => + Effect.succeed(undefined), + ), + ); + const trustStore = described?.TrustStores?.[0]; + if (!trustStore?.TrustStoreArn) { + return undefined; + } + return { + ...output, + name: trustStore.Name!, + status: trustStore.Status!, + numberOfCaCertificates: trustStore.NumberOfCaCertificates ?? 0, + }; + }), + list: () => + Effect.gen(function* () { + const trustStores = yield* elbv2.describeTrustStores.pages({}).pipe( + Stream.runCollect, + Effect.map((chunk) => + Array.from(chunk).flatMap((page) => + (page.TrustStores ?? []).filter( + (ts): ts is elbv2.TrustStore & { TrustStoreArn: string } => + ts.TrustStoreArn != null, + ), + ), + ), + ); + return yield* Effect.forEach( + trustStores, + (ts) => + Effect.gen(function* () { + const tags = yield* observedTags(ts.TrustStoreArn).pipe( + Effect.catchTag("TrustStoreNotFoundException", () => + Effect.succeed({} as Record), + ), + ); + return { + trustStoreArn: ts.TrustStoreArn as TrustStoreArn, + name: ts.Name!, + status: ts.Status!, + numberOfCaCertificates: ts.NumberOfCaCertificates ?? 0, + tags, + }; + }), + { concurrency: 10 }, + ); + }), + reconcile: Effect.fn(function* ({ id, news, session }) { + const name = yield* toName(id, news); + const desiredTags = { + ...(yield* createInternalTags(id)), + ...news.tags, + }; + + // Observe — look up by deterministic name. + const described = yield* elbv2 + .describeTrustStores({ Names: [name] }) + .pipe( + Effect.catchTag("TrustStoreNotFoundException", () => + Effect.succeed(undefined), + ), + ); + let trustStore = described?.TrustStores?.[0]; + + // Ensure — create if missing. + if (!trustStore?.TrustStoreArn) { + const created = yield* elbv2.createTrustStore({ + Name: name, + CaCertificatesBundleS3Bucket: news.caCertificatesBundleS3Bucket, + CaCertificatesBundleS3Key: news.caCertificatesBundleS3Key, + CaCertificatesBundleS3ObjectVersion: + news.caCertificatesBundleS3ObjectVersion, + Tags: Object.entries(desiredTags).map(([Key, Value]) => ({ + Key, + Value, + })), + }); + trustStore = created.TrustStores?.[0]; + if (!trustStore?.TrustStoreArn) { + return yield* Effect.die( + new Error("createTrustStore returned no trust store"), + ); + } + } else { + // Sync the CA bundle in place. + const modified = yield* elbv2.modifyTrustStore({ + TrustStoreArn: trustStore.TrustStoreArn, + CaCertificatesBundleS3Bucket: news.caCertificatesBundleS3Bucket, + CaCertificatesBundleS3Key: news.caCertificatesBundleS3Key, + CaCertificatesBundleS3ObjectVersion: + news.caCertificatesBundleS3ObjectVersion, + }); + trustStore = modified.TrustStores?.[0] ?? trustStore; + } + + const trustStoreArn = trustStore.TrustStoreArn as TrustStoreArn; + + // Wait until the trust store is ACTIVE (bundle validation completes). + const active = yield* elbv2 + .describeTrustStores({ TrustStoreArns: [trustStoreArn] }) + .pipe( + Effect.map((res) => res.TrustStores?.[0]), + Effect.repeat({ + schedule: Schedule.spaced("3 seconds"), + until: (ts) => ts?.Status === "ACTIVE", + times: 10, + }), + ); + + // Sync tags — diff observed cloud tags against desired. + const observed = yield* observedTags(trustStoreArn); + const { removed, upsert } = diffTags(observed, desiredTags); + if (upsert.length > 0) { + yield* elbv2.addTags({ + ResourceArns: [trustStoreArn], + Tags: upsert, + }); + } + if (removed.length > 0) { + yield* elbv2.removeTags({ + ResourceArns: [trustStoreArn], + TagKeys: removed, + }); + } + + yield* session.note(trustStoreArn); + return { + trustStoreArn, + name: trustStore.Name!, + status: active?.Status ?? trustStore.Status!, + numberOfCaCertificates: + active?.NumberOfCaCertificates ?? + trustStore.NumberOfCaCertificates ?? + 0, + tags: desiredTags, + }; + }), + delete: Effect.fn(function* ({ output }) { + yield* elbv2 + .deleteTrustStore({ TrustStoreArn: output.trustStoreArn }) + .pipe( + // In-use trust stores must wait for the listener to detach; the + // engine deletes dependents first, but retry briefly for the + // eventual-consistency window. + Effect.retry({ + while: (e) => e._tag === "TrustStoreInUseException", + schedule: Schedule.spaced("3 seconds").pipe( + Schedule.both(Schedule.recurs(8)), + ), + }), + Effect.catchTag("TrustStoreNotFoundException", () => Effect.void), + Effect.catchTag("TrustStoreInUseException", () => Effect.void), + ); + }), + }; + }), + ); diff --git a/packages/alchemy/src/AWS/ELBv2/common.ts b/packages/alchemy/src/AWS/ELBv2/common.ts new file mode 100644 index 0000000000..161a1102bd --- /dev/null +++ b/packages/alchemy/src/AWS/ELBv2/common.ts @@ -0,0 +1,309 @@ +import type * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2"; +import type { TargetGroupArn } from "./TargetGroup.ts"; + +/** + * A single forward target with an optional traffic weight. Used by the + * weighted-forward action to split traffic across multiple target groups. + */ +export interface ForwardTarget { + /** + * The target group to forward to. Accepts a `TargetGroup` resource reference + * — the engine resolves it to the ARN automatically (the `Input` machinery + * applies deeply to nested props). + */ + targetGroupArn: TargetGroupArn; + /** + * The weight applied to this target group when splitting traffic. The + * proportion a target group receives is its weight divided by the sum of all + * weights in the action. + * @default 1 + */ + weight?: number; +} + +/** + * Forward action: routes the request to one or more target groups, optionally + * with session stickiness so a client stays pinned to a single target group. + */ +export interface ForwardAction { + type: "forward"; + /** One or more target groups to forward to (weighted). */ + targetGroups: ForwardTarget[]; + /** Session stickiness across the target groups in this action. */ + stickiness?: { + /** Whether target-group stickiness is enabled. */ + enabled: boolean; + /** + * The time, in seconds, a client remains pinned to a target group. + * @default 3600 + */ + durationSeconds?: number; + }; +} + +/** + * Redirect action: returns an HTTP redirect (301/302) to a computed URL. Any + * component (protocol, host, path, query, port) may use the reserved keywords + * `#{protocol}`, `#{host}`, `#{path}`, `#{query}`, `#{port}` to copy from the + * original request. + */ +export interface RedirectAction { + type: "redirect"; + /** The redirect status code. */ + statusCode: "HTTP_301" | "HTTP_302"; + /** The protocol (`HTTP`, `HTTPS`, or `#{protocol}`). */ + protocol?: string; + /** The port. */ + port?: string; + /** The hostname. */ + host?: string; + /** The absolute path, starting with `/`. */ + path?: string; + /** The query parameters, not including the leading `?`. */ + query?: string; +} + +/** + * Fixed-response action: returns a static HTTP response directly from the load + * balancer without forwarding to any target. + */ +export interface FixedResponseAction { + type: "fixedResponse"; + /** The HTTP response status code (2XX/4XX/5XX). */ + statusCode: string; + /** The content type of the response body. */ + contentType?: string; + /** The response body. */ + messageBody?: string; +} + +/** + * Authenticate-OIDC action: authenticates the request through an OpenID Connect + * (OIDC) identity provider before forwarding to the next action. + */ +export interface AuthenticateOidcAction { + type: "authenticateOidc"; + issuer: string; + authorizationEndpoint: string; + tokenEndpoint: string; + userInfoEndpoint: string; + clientId: string; + /** Required on first use; omit with `useExistingClientSecret: true` to keep the existing one. */ + clientSecret?: string; + scope?: string; + sessionCookieName?: string; + sessionTimeout?: number; + onUnauthenticatedRequest?: "deny" | "allow" | "authenticate"; + useExistingClientSecret?: boolean; +} + +/** + * Authenticate-Cognito action: authenticates the request through an Amazon + * Cognito user pool before forwarding to the next action. + */ +export interface AuthenticateCognitoAction { + type: "authenticateCognito"; + userPoolArn: string; + userPoolClientId: string; + userPoolDomain: string; + scope?: string; + sessionCookieName?: string; + sessionTimeout?: number; + onUnauthenticatedRequest?: "deny" | "allow" | "authenticate"; +} + +/** + * A listener/rule action. The terminal action of a listener default-action or a + * rule must be `forward`, `redirect`, or `fixedResponse`; authentication + * actions are non-terminal and are ordered before the terminal action. + */ +export type ListenerAction = + | ForwardAction + | RedirectAction + | FixedResponseAction + | AuthenticateOidcAction + | AuthenticateCognitoAction; + +// At reconcile time the engine has already resolved Input/resource refs to +// plain ARN strings. +const resolveTargetGroupArn = (ref: TargetGroupArn): string => ref; + +/** + * Serialize a list of {@link ListenerAction} into the wire `Action[]` shape AWS + * expects, assigning `Order` so authentication actions run before the terminal + * action. Shared by both Listener default-actions and ListenerRule actions. + */ +export const serializeActions = (actions: ListenerAction[]): elbv2.Action[] => + actions.map((action, index): elbv2.Action => { + const Order = index + 1; + switch (action.type) { + case "forward": + return { + Type: "forward", + Order, + ForwardConfig: { + TargetGroups: action.targetGroups.map((t) => ({ + TargetGroupArn: resolveTargetGroupArn(t.targetGroupArn), + Weight: t.weight, + })), + TargetGroupStickinessConfig: action.stickiness + ? { + Enabled: action.stickiness.enabled, + DurationSeconds: action.stickiness.durationSeconds, + } + : undefined, + }, + // For a single, unweighted target group, AWS also accepts the legacy + // top-level TargetGroupArn; sending only ForwardConfig is canonical. + TargetGroupArn: + action.targetGroups.length === 1 && + action.targetGroups[0].weight === undefined + ? resolveTargetGroupArn(action.targetGroups[0].targetGroupArn) + : undefined, + }; + case "redirect": + return { + Type: "redirect", + Order, + RedirectConfig: { + StatusCode: action.statusCode, + Protocol: action.protocol, + Port: action.port, + Host: action.host, + Path: action.path, + Query: action.query, + }, + }; + case "fixedResponse": + return { + Type: "fixed-response", + Order, + FixedResponseConfig: { + StatusCode: action.statusCode, + ContentType: action.contentType, + MessageBody: action.messageBody, + }, + }; + case "authenticateOidc": + return { + Type: "authenticate-oidc", + Order, + AuthenticateOidcConfig: { + Issuer: action.issuer, + AuthorizationEndpoint: action.authorizationEndpoint, + TokenEndpoint: action.tokenEndpoint, + UserInfoEndpoint: action.userInfoEndpoint, + ClientId: action.clientId, + ClientSecret: action.clientSecret, + Scope: action.scope, + SessionCookieName: action.sessionCookieName, + SessionTimeout: action.sessionTimeout, + OnUnauthenticatedRequest: action.onUnauthenticatedRequest, + UseExistingClientSecret: action.useExistingClientSecret, + }, + }; + case "authenticateCognito": + return { + Type: "authenticate-cognito", + Order, + AuthenticateCognitoConfig: { + UserPoolArn: action.userPoolArn, + UserPoolClientId: action.userPoolClientId, + UserPoolDomain: action.userPoolDomain, + Scope: action.scope, + SessionCookieName: action.sessionCookieName, + SessionTimeout: action.sessionTimeout, + OnUnauthenticatedRequest: action.onUnauthenticatedRequest, + }, + }; + } + }); + +/** + * A condition under which a {@link ListenerRule} matches a request. Exactly one + * of the config fields should be set per condition; multiple conditions on a + * rule are AND-ed together, while values within a single condition are OR-ed. + */ +export interface ListenerRuleCondition { + /** Match on the `Host` header. Supports `*` and `?` wildcards. */ + hostHeader?: { values?: string[]; regexValues?: string[] }; + /** Match on the request path. Supports `*` and `?` wildcards. */ + pathPattern?: { values?: string[]; regexValues?: string[] }; + /** Match on a named HTTP header. */ + httpHeader?: { + name: string; + values?: string[]; + regexValues?: string[]; + }; + /** Match on query-string key/value pairs. Supports `*` and `?` wildcards. */ + queryString?: { values: { key?: string; value: string }[] }; + /** Match on the HTTP request method (GET, POST, ...). */ + httpRequestMethod?: { values: string[]; regexValues?: string[] }; + /** Match on the source IP CIDR. */ + sourceIp?: { values: string[] }; +} + +/** + * Serialize a list of {@link ListenerRuleCondition} into the wire + * `RuleCondition[]` shape AWS expects. + */ +export const serializeConditions = ( + conditions: ListenerRuleCondition[], +): elbv2.RuleCondition[] => + conditions.flatMap((condition): elbv2.RuleCondition[] => { + const out: elbv2.RuleCondition[] = []; + if (condition.hostHeader) { + out.push({ + Field: "host-header", + HostHeaderConfig: { + Values: condition.hostHeader.values, + RegexValues: condition.hostHeader.regexValues, + }, + }); + } + if (condition.pathPattern) { + out.push({ + Field: "path-pattern", + PathPatternConfig: { + Values: condition.pathPattern.values, + RegexValues: condition.pathPattern.regexValues, + }, + }); + } + if (condition.httpHeader) { + out.push({ + Field: "http-header", + HttpHeaderConfig: { + HttpHeaderName: condition.httpHeader.name, + Values: condition.httpHeader.values, + RegexValues: condition.httpHeader.regexValues, + }, + }); + } + if (condition.queryString) { + out.push({ + Field: "query-string", + QueryStringConfig: { + Values: condition.queryString.values.map((v) => ({ + Key: v.key, + Value: v.value, + })), + }, + }); + } + if (condition.httpRequestMethod) { + out.push({ + Field: "http-request-method", + HttpRequestMethodConfig: { + Values: condition.httpRequestMethod.values, + }, + }); + } + if (condition.sourceIp) { + out.push({ + Field: "source-ip", + SourceIpConfig: { Values: condition.sourceIp.values }, + }); + } + return out; + }); diff --git a/packages/alchemy/src/AWS/ELBv2/index.ts b/packages/alchemy/src/AWS/ELBv2/index.ts index fa0b0c7e86..7cb8475b8d 100644 --- a/packages/alchemy/src/AWS/ELBv2/index.ts +++ b/packages/alchemy/src/AWS/ELBv2/index.ts @@ -1,3 +1,6 @@ +export * from "./common.ts"; export { Listener, ListenerProvider } from "./Listener.ts"; +export { ListenerRule, ListenerRuleProvider } from "./ListenerRule.ts"; export { LoadBalancer, LoadBalancerProvider } from "./LoadBalancer.ts"; export { TargetGroup, TargetGroupProvider } from "./TargetGroup.ts"; +export { TrustStore, TrustStoreProvider } from "./TrustStore.ts"; diff --git a/packages/alchemy/src/AWS/Providers.ts b/packages/alchemy/src/AWS/Providers.ts index 50631bd911..fa8cc332ea 100644 --- a/packages/alchemy/src/AWS/Providers.ts +++ b/packages/alchemy/src/AWS/Providers.ts @@ -96,6 +96,7 @@ export const providers = () => CloudFront.OriginRequestPolicy, CloudFront.PublicKey, CloudFront.ResponseHeadersPolicy, + CloudFront.VpcOrigin, CloudWatch.Alarm, CloudWatch.AlarmMuteRule, CloudWatch.AnomalyDetector, @@ -177,8 +178,10 @@ export const providers = () => EKS.Cluster, EKS.PodIdentityAssociation, ELBv2.Listener, + ELBv2.ListenerRule, ELBv2.LoadBalancer, ELBv2.TargetGroup, + ELBv2.TrustStore, EventBridge.DescribeEventBusPolicy, EventBridge.DescribeRulePolicy, EventBridge.EventBus, @@ -267,6 +270,8 @@ export const providers = () => RDSData.ExecuteSqlPolicy, RDSData.ExecuteStatementPolicy, RDSData.RollbackTransactionPolicy, + Route53.HealthCheck, + Route53.HostedZone, Route53.Record, S3.AbortMultipartUploadPolicy, S3.Bucket, @@ -349,6 +354,7 @@ export const providers = () => CloudFront.OriginRequestPolicyProvider(), CloudFront.PublicKeyProvider(), CloudFront.ResponseHeadersPolicyProvider(), + CloudFront.VpcOriginProvider(), CloudWatch.AlarmMuteRuleProvider(), CloudWatch.AlarmProvider(), CloudWatch.AnomalyDetectorProvider(), @@ -430,8 +436,10 @@ export const providers = () => EKS.ClusterProvider(), EKS.PodIdentityAssociationProvider(), ELBv2.ListenerProvider(), + ELBv2.ListenerRuleProvider(), ELBv2.LoadBalancerProvider(), ELBv2.TargetGroupProvider(), + ELBv2.TrustStoreProvider(), EventBridge.DescribeEventBusPolicyLive, EventBridge.DescribeRulePolicyLive, EventBridge.EventBusProvider(), @@ -520,6 +528,8 @@ export const providers = () => RDSData.ExecuteSqlPolicyLive, RDSData.ExecuteStatementPolicyLive, RDSData.RollbackTransactionPolicyLive, + Route53.HealthCheckProvider(), + Route53.HostedZoneProvider(), Route53.RecordProvider(), S3.AbortMultipartUploadPolicyLive, S3.BucketProvider(), diff --git a/packages/alchemy/src/AWS/RDS/Aurora.ts b/packages/alchemy/src/AWS/RDS/Aurora.ts index 3288b6f52c..81b69e7859 100644 --- a/packages/alchemy/src/AWS/RDS/Aurora.ts +++ b/packages/alchemy/src/AWS/RDS/Aurora.ts @@ -191,6 +191,83 @@ export interface AuroraProps { * @default true */ dataApi?: boolean; + /** + * Backup retention period in days, forwarded to the cluster. + */ + backupRetentionPeriod?: number; + /** + * Daily backup window (`hh:mm-hh:mm` UTC), forwarded to the cluster. + */ + preferredBackupWindow?: string; + /** + * Weekly maintenance window, forwarded to the cluster. + */ + preferredMaintenanceWindow?: string; + /** + * Encrypt cluster storage. Forwarded to the cluster. + */ + storageEncrypted?: boolean; + /** + * KMS key for storage encryption. Forwarded to the cluster. + */ + kmsKeyId?: string; + /** + * Enable IAM database authentication. Forwarded to the cluster. + */ + enableIAMDatabaseAuthentication?: boolean; + /** + * Log types to export to CloudWatch Logs. Forwarded to the cluster. + */ + enableCloudwatchLogsExports?: string[]; + /** + * Block accidental deletion. Forwarded to the cluster and instances. + * @default false + */ + deletionProtection?: boolean; + /** + * CA certificate identifier. Forwarded to the cluster. + */ + caCertificateIdentifier?: string; + /** + * Listener port. Forwarded to the cluster. + */ + port?: number; + /** + * Aurora MySQL backtrack window in seconds. Forwarded to the cluster. + */ + backtrackWindow?: number; + /** + * Enhanced-monitoring + Performance Insights settings. Forwarded to the + * cluster (and the enhanced-monitoring role to the instances). + */ + monitoring?: { + /** + * Enhanced-monitoring granularity in seconds (0, 1, 5, 10, 15, 30, 60). + */ + interval?: number; + /** + * Existing IAM role ARN for enhanced monitoring. When omitted and + * `interval > 0`, Aurora creates one automatically. + */ + roleArn?: Input; + /** + * Enable Performance Insights on the cluster. + */ + performanceInsights?: boolean; + }; + /** + * Serverless v2 min/max ACUs. Shorthand for + * `serverlessV2ScalingConfiguration`. + */ + scaling?: { + minCapacity?: number; + maxCapacity?: number; + }; + /** + * Provisioned (non-serverless) instance class for the writer/readers, e.g. + * `db.r6g.large`. Defaults to `db.serverless`. + */ + instanceClass?: string; /** * Opt in to an auto-wired RDS Proxy. */ @@ -324,6 +401,33 @@ export const Aurora = (id: string, props: AuroraProps) => }) : undefined; + // Enhanced-monitoring role: reuse an explicit ARN, otherwise auto-create + // one when a non-zero interval is requested. + const monitoringInterval = props.monitoring?.interval; + const monitoringRole = + monitoringInterval && + monitoringInterval > 0 && + !props.monitoring?.roleArn + ? yield* IAM.Role("MonitoringRole", { + assumeRolePolicyDocument: { + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { Service: "monitoring.rds.amazonaws.com" }, + Action: ["sts:AssumeRole"], + Resource: ["*"], + }, + ], + }, + managedPolicyArns: [ + "arn:aws:iam::aws:policy/service-role/AmazonRDSEnhancedMonitoringRole", + ], + tags: mergeTags(commonTags, undefined), + }) + : undefined; + const monitoringRoleArn = props.monitoring?.roleArn ?? monitoringRole?.roleArn; // prettier-ignore + const cluster = yield* DBCluster("Cluster", { engine, engineVersion, @@ -334,20 +438,63 @@ export const Aurora = (id: string, props: AuroraProps) => vpcSecurityGroupIds: securityGroupIds, enableHttpEndpoint: props.dataApi ?? true, copyTagsToSnapshot: props.cluster?.copyTagsToSnapshot ?? true, - deletionProtection: props.cluster?.deletionProtection ?? false, - serverlessV2ScalingConfiguration: props.cluster - ?.serverlessV2ScalingConfiguration ?? { - MinCapacity: 0.5, - MaxCapacity: 1, - }, + deletionProtection: + props.cluster?.deletionProtection ?? + props.deletionProtection ?? + false, + backupRetentionPeriod: + props.cluster?.backupRetentionPeriod ?? props.backupRetentionPeriod, + preferredBackupWindow: + props.cluster?.preferredBackupWindow ?? props.preferredBackupWindow, + preferredMaintenanceWindow: + props.cluster?.preferredMaintenanceWindow ?? + props.preferredMaintenanceWindow, + storageEncrypted: + props.cluster?.storageEncrypted ?? props.storageEncrypted, + kmsKeyId: props.cluster?.kmsKeyId ?? props.kmsKeyId, + enableIAMDatabaseAuthentication: + props.cluster?.enableIAMDatabaseAuthentication ?? + props.enableIAMDatabaseAuthentication, + enableCloudwatchLogsExports: + props.cluster?.enableCloudwatchLogsExports ?? + props.enableCloudwatchLogsExports, + caCertificateIdentifier: + props.cluster?.caCertificateIdentifier ?? + props.caCertificateIdentifier, + port: props.cluster?.port ?? props.port, + backtrackWindow: + props.cluster?.backtrackWindow ?? props.backtrackWindow, + monitoringInterval: + props.cluster?.monitoringInterval ?? monitoringInterval, + monitoringRoleArn: + props.cluster?.monitoringRoleArn ?? monitoringRoleArn, + enablePerformanceInsights: + props.cluster?.enablePerformanceInsights ?? + props.monitoring?.performanceInsights, + serverlessV2ScalingConfiguration: + props.cluster?.serverlessV2ScalingConfiguration ?? + (props.scaling + ? { + MinCapacity: props.scaling.minCapacity ?? 0.5, + MaxCapacity: props.scaling.maxCapacity ?? 1, + } + : { + MinCapacity: 0.5, + MaxCapacity: 1, + }), masterUserSecretArn: secret.secretArn, tags: mergeTags(commonTags, props.cluster?.tags), ...props.cluster, }); + const defaultInstanceClass = + props.instance?.dbInstanceClass ?? + props.instanceClass ?? + "db.serverless"; + const writer = yield* DBInstance("Writer", { dbClusterIdentifier: cluster.dbClusterIdentifier, - dbInstanceClass: props.instance?.dbInstanceClass ?? "db.serverless", + dbInstanceClass: defaultInstanceClass, engine, engineVersion, dbSubnetGroupName: subnetGroup.dbSubnetGroupName, @@ -358,6 +505,12 @@ export const Aurora = (id: string, props: AuroraProps) => autoMinorVersionUpgrade: props.instance?.autoMinorVersionUpgrade ?? true, copyTagsToSnapshot: props.instance?.copyTagsToSnapshot ?? true, + monitoringInterval: props.instance?.monitoringInterval ?? monitoringInterval, // prettier-ignore + monitoringRoleArn: + props.instance?.monitoringRoleArn ?? monitoringRoleArn, + enablePerformanceInsights: + props.instance?.enablePerformanceInsights ?? + props.monitoring?.performanceInsights, tags: mergeTags(commonTags, props.instance?.tags), ...props.instance, }); @@ -366,7 +519,7 @@ export const Aurora = (id: string, props: AuroraProps) => Array.from({ length: props.readers ?? 0 }, (_, index) => DBInstance(`Reader${index + 1}`, { dbClusterIdentifier: cluster.dbClusterIdentifier, - dbInstanceClass: props.instance?.dbInstanceClass ?? "db.serverless", + dbInstanceClass: defaultInstanceClass, engine, engineVersion, dbSubnetGroupName: subnetGroup.dbSubnetGroupName, @@ -377,6 +530,12 @@ export const Aurora = (id: string, props: AuroraProps) => autoMinorVersionUpgrade: props.instance?.autoMinorVersionUpgrade ?? true, copyTagsToSnapshot: props.instance?.copyTagsToSnapshot ?? true, + monitoringInterval: props.instance?.monitoringInterval ?? monitoringInterval, // prettier-ignore + monitoringRoleArn: + props.instance?.monitoringRoleArn ?? monitoringRoleArn, + enablePerformanceInsights: + props.instance?.enablePerformanceInsights ?? + props.monitoring?.performanceInsights, tags: mergeTags(commonTags, props.instance?.tags), ...props.instance, }), diff --git a/packages/alchemy/src/AWS/RDS/DBCluster.ts b/packages/alchemy/src/AWS/RDS/DBCluster.ts index 98f1580f26..a08472abab 100644 --- a/packages/alchemy/src/AWS/RDS/DBCluster.ts +++ b/packages/alchemy/src/AWS/RDS/DBCluster.ts @@ -54,12 +54,126 @@ export interface DBClusterProps { enableHttpEndpoint?: boolean; /** * Engine mode, for example `provisioned` or `serverless`. + * Changing it forces replacement unless `AllowEngineModeChange` applies. */ engineMode?: string; /** * Serverless v2 scaling configuration. */ serverlessV2ScalingConfiguration?: rds.ServerlessV2ScalingConfiguration; + /** + * Serverless v1 scaling configuration. In-place modify. + */ + scalingConfiguration?: rds.ScalingConfiguration; + /** + * Availability zones for cluster placement. Immutable — forces replacement. + */ + availabilityZones?: string[]; + /** + * Backup retention period in days. In-place modify. + */ + backupRetentionPeriod?: number; + /** + * Daily backup window, e.g. `07:00-09:00`. In-place modify. + */ + preferredBackupWindow?: string; + /** + * Weekly maintenance window, e.g. `Mon:00:00-Mon:03:00`. In-place modify. + */ + preferredMaintenanceWindow?: string; + /** + * Backtrack window in seconds (Aurora MySQL only). In-place modify. + */ + backtrackWindow?: number; + /** + * Option group name. In-place modify. + */ + optionGroupName?: string; + /** + * Log types to export to CloudWatch Logs. Diffed against observed state and + * applied via the delta-shaped `CloudwatchLogsExportConfiguration` on modify. + */ + enableCloudwatchLogsExports?: string[]; + /** + * Auto minor version upgrade. In-place modify. + */ + autoMinorVersionUpgrade?: boolean; + /** + * Allow a major engine-version upgrade during a modify. Modify-only flag. + */ + allowMajorVersionUpgrade?: boolean; + /** + * Enhanced-monitoring granularity in seconds. In-place modify. + */ + monitoringInterval?: number; + /** + * IAM role ARN for enhanced monitoring. In-place modify. + */ + monitoringRoleArn?: string; + /** + * Enable Performance Insights. In-place modify. + */ + enablePerformanceInsights?: boolean; + /** + * KMS key for Performance Insights. In-place modify. + */ + performanceInsightsKMSKeyId?: string; + /** + * Performance Insights retention in days. In-place modify. + */ + performanceInsightsRetentionPeriod?: number; + /** + * Network type: `IPV4` | `DUAL`. In-place modify. + */ + networkType?: string; + /** + * CA certificate identifier. In-place modify. + */ + caCertificateIdentifier?: string; + /** + * KMS key used to encrypt the managed master user secret. In-place modify. + */ + masterUserSecretKmsKeyId?: string; + /** + * Rotate the managed master user password on the next reconcile. + */ + rotateMasterUserPassword?: boolean; + /** + * Enable global write forwarding (secondary regions of a global cluster). + */ + enableGlobalWriteForwarding?: boolean; + /** + * Enable local write forwarding (Aurora reader endpoints). In-place modify. + */ + enableLocalWriteForwarding?: boolean; + /** + * Join this cluster to an Aurora global cluster. Immutable on create. + */ + globalClusterIdentifier?: string; + /** + * Instance class for a provisioned multi-AZ cluster. In-place modify. + */ + dbClusterInstanceClass?: string; + /** + * Allocated storage (GiB) for a provisioned multi-AZ cluster. In-place. + */ + allocatedStorage?: number; + /** + * Storage type (provisioned multi-AZ cluster). In-place modify. + */ + storageType?: string; + /** + * Provisioned IOPS (provisioned multi-AZ cluster). In-place modify. + */ + iops?: number; + /** + * Whether a provisioned cluster is publicly reachable. In-place modify. + */ + publiclyAccessible?: boolean; + /** + * Engine lifecycle support setting. Immutable — forces replacement. + */ + engineLifecycleSupport?: string; /** * Whether to copy tags to snapshots. */ @@ -69,11 +183,11 @@ export interface DBClusterProps { */ deletionProtection?: boolean; /** - * Whether the storage is encrypted. + * Whether the storage is encrypted. Immutable — forces replacement. */ storageEncrypted?: boolean; /** - * Optional KMS key used for storage encryption. + * Optional KMS key used for storage encryption. Immutable — forces replace. */ kmsKeyId?: string; /** @@ -117,6 +231,34 @@ export interface DBCluster extends Resource< masterUserSecretArn: string | undefined; vpcSecurityGroupIds: string[]; httpEndpointEnabled: boolean | undefined; + allocatedStorage: number | undefined; + backupRetentionPeriod: number | undefined; + preferredBackupWindow: string | undefined; + preferredMaintenanceWindow: string | undefined; + storageEncrypted: boolean | undefined; + kmsKeyId: string | undefined; + deletionProtection: boolean | undefined; + iamDatabaseAuthenticationEnabled: boolean | undefined; + engineMode: string | undefined; + dbClusterMembers: Array<{ + dbInstanceIdentifier: string | undefined; + isClusterWriter: boolean | undefined; + promotionTier: number | undefined; + }>; + dbClusterResourceId: string | undefined; + hostedZoneId: string | undefined; + multiAZ: boolean | undefined; + enabledCloudwatchLogsExports: string[]; + copyTagsToSnapshot: boolean | undefined; + clusterCreateTime: string | undefined; + serverlessV2PlatformVersion: string | undefined; + monitoringInterval: number | undefined; + performanceInsightsEnabled: boolean | undefined; + dbClusterInstanceClass: string | undefined; + storageType: string | undefined; + iops: number | undefined; + networkType: string | undefined; + customEndpoints: string[]; tags: Record; }, never, @@ -129,6 +271,40 @@ export interface DBCluster extends Resource< * `DBCluster` owns the writer and reader endpoints, cluster-wide networking, * and Data API enablement. It can bootstrap master credentials directly or by * reading a Secrets Manager secret that contains `username` and `password`. + * + * It exposes the full backup, maintenance, monitoring, performance-insights, + * encryption, scaling, and log-export surface of `createDBCluster` / + * `modifyDBCluster`. Mutable fields are reconciled in place against the + * observed cloud state; immutable fields (`engine`, `databaseName`, + * `dbSubnetGroupName`, `storageEncrypted`, `kmsKeyId`, `engineMode`, + * `globalClusterIdentifier`, `availabilityZones`, `engineLifecycleSupport`) + * force a replacement. + * + * @section Serverless v2 Cluster + * @example Aurora Postgres serverless-v2 + * ```typescript + * const cluster = yield* DBCluster("Cluster", { + * engine: "aurora-postgresql", + * engineMode: "provisioned", + * serverlessV2ScalingConfiguration: { MinCapacity: 0.5, MaxCapacity: 4 }, + * manageMasterUserPassword: true, + * masterUsername: "alchemy", + * backupRetentionPeriod: 7, + * deletionProtection: false, + * }); + * ``` + * + * @section Logs & Monitoring + * @example Export logs and enable Performance Insights + * ```typescript + * const cluster = yield* DBCluster("Cluster", { + * engine: "aurora-postgresql", + * enableCloudwatchLogsExports: ["postgresql"], + * enablePerformanceInsights: true, + * monitoringInterval: 60, + * monitoringRoleArn: monitoringRole.roleArn, + * }); + * ``` */ export const DBCluster = Resource("AWS.RDS.DBCluster"); @@ -196,9 +372,60 @@ const toAttrs = ({ group.VpcSecurityGroupId ? [group.VpcSecurityGroupId] : [], ), httpEndpointEnabled: cluster.HttpEndpointEnabled, + allocatedStorage: cluster.AllocatedStorage, + backupRetentionPeriod: cluster.BackupRetentionPeriod, + preferredBackupWindow: cluster.PreferredBackupWindow, + preferredMaintenanceWindow: cluster.PreferredMaintenanceWindow, + storageEncrypted: cluster.StorageEncrypted, + kmsKeyId: cluster.KmsKeyId, + deletionProtection: cluster.DeletionProtection, + iamDatabaseAuthenticationEnabled: cluster.IAMDatabaseAuthenticationEnabled, + engineMode: cluster.EngineMode, + dbClusterMembers: (cluster.DBClusterMembers ?? []).map((member) => ({ + dbInstanceIdentifier: member.DBInstanceIdentifier, + isClusterWriter: member.IsClusterWriter, + promotionTier: member.PromotionTier, + })), + dbClusterResourceId: cluster.DbClusterResourceId, + hostedZoneId: cluster.HostedZoneId, + multiAZ: cluster.MultiAZ, + enabledCloudwatchLogsExports: cluster.EnabledCloudwatchLogsExports ?? [], + copyTagsToSnapshot: cluster.CopyTagsToSnapshot, + clusterCreateTime: cluster.ClusterCreateTime?.toISOString(), + serverlessV2PlatformVersion: cluster.ServerlessV2PlatformVersion, + monitoringInterval: cluster.MonitoringInterval, + performanceInsightsEnabled: cluster.PerformanceInsightsEnabled, + dbClusterInstanceClass: cluster.DBClusterInstanceClass, + storageType: cluster.StorageType, + iops: cluster.Iops, + networkType: cluster.NetworkType, + customEndpoints: cluster.CustomEndpoints ?? [], tags, }); +/** + * Compute the CloudWatch Logs export delta. The modify API is delta-shaped + * (`EnableLogTypes`/`DisableLogTypes`), so it must NOT carry the full set. + * Returns `undefined` when there is no change. + */ +const logExportDelta = ( + observed: string[] | undefined, + desired: string[] | undefined, +): rds.CloudwatchLogsExportConfiguration | undefined => { + if (desired === undefined) return undefined; + const have = new Set(observed ?? []); + const want = new Set(desired); + const EnableLogTypes = [...want].filter((t) => !have.has(t)); + const DisableLogTypes = [...have].filter((t) => !want.has(t)); + if (EnableLogTypes.length === 0 && DisableLogTypes.length === 0) { + return undefined; + } + return { + ...(EnableLogTypes.length > 0 ? { EnableLogTypes } : {}), + ...(DisableLogTypes.length > 0 ? { DisableLogTypes } : {}), + }; +}; + export const DBClusterProvider = () => Provider.effect( DBCluster, @@ -221,16 +448,33 @@ export const DBClusterProvider = () => return response?.DBClusters?.[0]; }); - const waitForCluster = Effect.fn(function* (clusterId: string) { - const readinessPolicy = Schedule.fixed("2 seconds").pipe( - Schedule.both(Schedule.recurs(30)), + // Bounded readiness wait. Gate on cluster `Status === "available"` so a + // follow-on `modifyDBCluster` doesn't hit `InvalidDBClusterStateFault`. + // Budgets ~10 min (60 * 10s) for slow provisioning. `requireAvailable: + // false` only waits for the ARN to appear. + const waitForCluster = Effect.fn(function* ( + clusterId: string, + { requireAvailable = true }: { requireAvailable?: boolean } = {}, + ) { + const readinessPolicy = Schedule.fixed("10 seconds").pipe( + Schedule.both(Schedule.recurs(60)), ); return yield* readCluster(clusterId).pipe( - Effect.flatMap((cluster) => - cluster?.DBClusterArn - ? Effect.succeed(cluster) - : Effect.fail(new Error(`DB cluster '${clusterId}' not ready`)), - ), + Effect.flatMap((cluster) => { + if (!cluster?.DBClusterArn) { + return Effect.fail( + new Error(`DB cluster '${clusterId}' not found`), + ); + } + if (requireAvailable && cluster.Status !== "available") { + return Effect.fail( + new Error( + `DB cluster '${clusterId}' not available (status: ${cluster.Status})`, + ), + ); + } + return Effect.succeed(cluster); + }), Effect.retry({ schedule: readinessPolicy }), ); }); @@ -265,7 +509,20 @@ export const DBClusterProvider = () => ) { return { action: "replace" } as const; } - if (olds?.engine !== news.engine) { + // Immutable props — any change forces a fresh cluster. + if ( + olds !== undefined && + (olds.engine !== news.engine || + olds.databaseName !== news.databaseName || + olds.dbSubnetGroupName !== news.dbSubnetGroupName || + olds.storageEncrypted !== news.storageEncrypted || + olds.kmsKeyId !== news.kmsKeyId || + olds.engineMode !== news.engineMode || + olds.globalClusterIdentifier !== news.globalClusterIdentifier || + olds.engineLifecycleSupport !== news.engineLifecycleSupport || + JSON.stringify(olds.availabilityZones ?? []) !== + JSON.stringify(news.availabilityZones ?? [])) + ) { return { action: "replace" } as const; } }), @@ -311,12 +568,39 @@ export const DBClusterProvider = () => DBClusterParameterGroupName: news.dbClusterParameterGroupName, VpcSecurityGroupIds: news.vpcSecurityGroupIds, Port: news.port, + AvailabilityZones: news.availabilityZones, + BackupRetentionPeriod: news.backupRetentionPeriod, + PreferredBackupWindow: news.preferredBackupWindow, + PreferredMaintenanceWindow: news.preferredMaintenanceWindow, + BacktrackWindow: news.backtrackWindow, + OptionGroupName: news.optionGroupName, + EnableCloudwatchLogsExports: news.enableCloudwatchLogsExports, EnableIAMDatabaseAuthentication: news.enableIAMDatabaseAuthentication, EnableHttpEndpoint: news.enableHttpEndpoint, EngineMode: news.engineMode, + ScalingConfiguration: news.scalingConfiguration, ServerlessV2ScalingConfiguration: news.serverlessV2ScalingConfiguration, + AutoMinorVersionUpgrade: news.autoMinorVersionUpgrade, + MonitoringInterval: news.monitoringInterval, + MonitoringRoleArn: news.monitoringRoleArn, + EnablePerformanceInsights: news.enablePerformanceInsights, + PerformanceInsightsKMSKeyId: news.performanceInsightsKMSKeyId, + PerformanceInsightsRetentionPeriod: + news.performanceInsightsRetentionPeriod, + NetworkType: news.networkType, + CACertificateIdentifier: news.caCertificateIdentifier, + MasterUserSecretKmsKeyId: news.masterUserSecretKmsKeyId, + EnableGlobalWriteForwarding: news.enableGlobalWriteForwarding, + EnableLocalWriteForwarding: news.enableLocalWriteForwarding, + GlobalClusterIdentifier: news.globalClusterIdentifier, + DBClusterInstanceClass: news.dbClusterInstanceClass, + AllocatedStorage: news.allocatedStorage, + StorageType: news.storageType, + Iops: news.iops, + PubliclyAccessible: news.publiclyAccessible, + EngineLifecycleSupport: news.engineLifecycleSupport, CopyTagsToSnapshot: news.copyTagsToSnapshot, DeletionProtection: news.deletionProtection, StorageEncrypted: news.storageEncrypted, @@ -337,26 +621,99 @@ export const DBClusterProvider = () => observed = yield* waitForCluster(identifier); } else { - // Sync mutable cluster config — push the desired shape via - // `modifyDBCluster`. Many fields land in `PendingModifiedValues` - // and apply on next reboot; `ApplyImmediately` shortens that. - yield* rds.modifyDBCluster({ + // Wait for the cluster to settle before any modify so the call + // doesn't hit `InvalidDBClusterStateFault`. + observed = yield* waitForCluster(identifier); + + // syncCoreSettings — single `modifyDBCluster` carrying scalar + // in-place fields, only emitting a field when the desired value + // differs from the observed cloud state. + const core: rds.ModifyDBClusterMessage = { DBClusterIdentifier: identifier, - EngineVersion: news.engineVersion, - DBClusterParameterGroupName: news.dbClusterParameterGroupName, - VpcSecurityGroupIds: news.vpcSecurityGroupIds, - Port: news.port, - EnableIAMDatabaseAuthentication: - news.enableIAMDatabaseAuthentication, - EnableHttpEndpoint: news.enableHttpEndpoint, - ServerlessV2ScalingConfiguration: - news.serverlessV2ScalingConfiguration, - CopyTagsToSnapshot: news.copyTagsToSnapshot, - DeletionProtection: news.deletionProtection, - MasterUserPassword: credentials.MasterUserPassword, ApplyImmediately: true, - }); - observed = yield* waitForCluster(identifier); + }; + let coreDirty = false; + const setIf = ( + key: K, + desired: rds.ModifyDBClusterMessage[K] | undefined, + observedValue: unknown, + ) => { + if (desired !== undefined && desired !== observedValue) { + core[key] = desired; + coreDirty = true; + } + }; + setIf("EngineVersion", news.engineVersion, observed.EngineVersion); + setIf("Port", news.port, observed.Port); + setIf("BackupRetentionPeriod", news.backupRetentionPeriod, observed.BackupRetentionPeriod); // prettier-ignore + setIf("PreferredBackupWindow", news.preferredBackupWindow, observed.PreferredBackupWindow); // prettier-ignore + setIf("PreferredMaintenanceWindow", news.preferredMaintenanceWindow, observed.PreferredMaintenanceWindow); // prettier-ignore + setIf("BacktrackWindow", news.backtrackWindow, observed.BacktrackWindow); // prettier-ignore + setIf("DeletionProtection", news.deletionProtection, observed.DeletionProtection); // prettier-ignore + setIf("CopyTagsToSnapshot", news.copyTagsToSnapshot, observed.CopyTagsToSnapshot); // prettier-ignore + setIf("EnableIAMDatabaseAuthentication", news.enableIAMDatabaseAuthentication, observed.IAMDatabaseAuthenticationEnabled); // prettier-ignore + setIf("EnableHttpEndpoint", news.enableHttpEndpoint, observed.HttpEndpointEnabled); // prettier-ignore + setIf("AutoMinorVersionUpgrade", news.autoMinorVersionUpgrade, observed.AutoMinorVersionUpgrade); // prettier-ignore + setIf("MonitoringInterval", news.monitoringInterval, observed.MonitoringInterval); // prettier-ignore + setIf("MonitoringRoleArn", news.monitoringRoleArn, observed.MonitoringRoleArn); // prettier-ignore + setIf("EnablePerformanceInsights", news.enablePerformanceInsights, observed.PerformanceInsightsEnabled); // prettier-ignore + setIf("PerformanceInsightsKMSKeyId", news.performanceInsightsKMSKeyId, observed.PerformanceInsightsKMSKeyId); // prettier-ignore + setIf("PerformanceInsightsRetentionPeriod", news.performanceInsightsRetentionPeriod, observed.PerformanceInsightsRetentionPeriod); // prettier-ignore + setIf("NetworkType", news.networkType, observed.NetworkType); + setIf("DBClusterInstanceClass", news.dbClusterInstanceClass, observed.DBClusterInstanceClass); // prettier-ignore + setIf("AllocatedStorage", news.allocatedStorage, observed.AllocatedStorage); // prettier-ignore + setIf("StorageType", news.storageType, observed.StorageType); + setIf("Iops", news.iops, observed.Iops); + setIf("OptionGroupName", news.optionGroupName, undefined); + setIf("DBClusterParameterGroupName", news.dbClusterParameterGroupName, observed.DBClusterParameterGroup); // prettier-ignore + setIf("EnableGlobalWriteForwarding", news.enableGlobalWriteForwarding, undefined); // prettier-ignore + setIf("EnableLocalWriteForwarding", news.enableLocalWriteForwarding, undefined); // prettier-ignore + setIf("CACertificateIdentifier", news.caCertificateIdentifier, undefined); // prettier-ignore + if (news.scalingConfiguration !== undefined) { + core.ScalingConfiguration = news.scalingConfiguration; + coreDirty = true; + } + if (news.serverlessV2ScalingConfiguration !== undefined) { + core.ServerlessV2ScalingConfiguration = + news.serverlessV2ScalingConfiguration; + coreDirty = true; + } + if (news.vpcSecurityGroupIds !== undefined) { + core.VpcSecurityGroupIds = news.vpcSecurityGroupIds; + coreDirty = true; + } + if (news.allowMajorVersionUpgrade) { + core.AllowMajorVersionUpgrade = true; + } + // syncMasterPassword — rotation or explicit password update. + if ( + news.manageMasterUserPassword && + news.rotateMasterUserPassword + ) { + core.RotateMasterUserPassword = true; + coreDirty = true; + } else if (credentials.MasterUserPassword !== undefined) { + core.MasterUserPassword = credentials.MasterUserPassword; + coreDirty = true; + } + if (coreDirty) { + yield* rds.modifyDBCluster(core); + observed = yield* waitForCluster(identifier); + } + + // syncCloudwatchLogsExports — delta-shaped; separate call. + const logDelta = logExportDelta( + observed.EnabledCloudwatchLogsExports, + news.enableCloudwatchLogsExports, + ); + if (logDelta) { + yield* rds.modifyDBCluster({ + DBClusterIdentifier: identifier, + CloudwatchLogsExportConfiguration: logDelta, + ApplyImmediately: true, + }); + observed = yield* waitForCluster(identifier); + } } const dbClusterArn = observed.DBClusterArn ?? ""; @@ -389,6 +746,28 @@ export const DBClusterProvider = () => SkipFinalSnapshot: true, }) .pipe(Effect.catchTag("DBClusterNotFoundFault", () => Effect.void)); + // Block until the cluster is fully gone. RDS deletion is async; if we + // return while it is still `deleting`, a dependent (e.g. a + // DBSubnetGroup or VPC) is torn down next and AWS rejects it with + // `InvalidDBSubnetGroupStateFault: ... still using it`. + yield* Effect.repeat( + rds + .describeDBClusters({ + DBClusterIdentifier: output.dbClusterIdentifier, + }) + .pipe( + Effect.as(true), + Effect.catchTag("DBClusterNotFoundFault", () => + Effect.succeed(false), + ), + ), + { + schedule: Schedule.fixed("15 seconds").pipe( + Schedule.both(Schedule.recurs(40)), + ), + until: (exists) => exists === false, + }, + ).pipe(Effect.catch(() => Effect.void)); }), }; }), diff --git a/packages/alchemy/src/AWS/RDS/DBInstance.ts b/packages/alchemy/src/AWS/RDS/DBInstance.ts index 67f9d3ebb4..8a4080f829 100644 --- a/packages/alchemy/src/AWS/RDS/DBInstance.ts +++ b/packages/alchemy/src/AWS/RDS/DBInstance.ts @@ -1,5 +1,6 @@ import * as rds from "@distilled.cloud/aws/rds"; import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; import { isResolved } from "../../Diff.ts"; @@ -15,35 +16,169 @@ export interface DBInstanceProps { */ dbInstanceIdentifier?: string; /** - * Aurora cluster the instance belongs to. + * Aurora cluster the instance belongs to. When set, the instance is a + * cluster member and most storage/backup props are managed by the cluster. + * Replacing this forces a new instance. */ dbClusterIdentifier?: string; /** - * Instance class such as `db.serverless`. + * Instance class such as `db.serverless` or `db.t3.micro`. */ dbInstanceClass: string; /** - * Database engine, usually matching the cluster engine. + * Database engine, e.g. `mysql`, `postgres`, `aurora-postgresql`. + * Changing the engine forces replacement. */ engine: string; /** - * Optional engine version. + * Optional engine version. Changed in place via `modifyDBInstance`. */ engineVersion?: string; /** - * Optional DB subnet group. + * Standalone (non-Aurora) database name created with the instance. + * Immutable — forces replacement. + */ + dbName?: string; + /** + * Allocated storage in GiB (standalone instances). In-place modify. + * @default undefined + */ + allocatedStorage?: number; + /** + * Upper limit (GiB) for storage autoscaling. In-place modify. + */ + maxAllocatedStorage?: number; + /** + * Storage type: `gp2` | `gp3` | `io1` | `io2` | `standard`. In-place modify. + */ + storageType?: string; + /** + * Provisioned IOPS (io1/io2/gp3). In-place modify (rate-limited by AWS). + */ + iops?: number; + /** + * Storage throughput in MiBps (gp3). In-place modify. + */ + storageThroughput?: number; + /** + * Master username (standalone instances). Immutable — forces replacement. + */ + masterUsername?: string; + /** + * Master password (standalone instances). In-place modify. + */ + masterUserPassword?: Redacted.Redacted; + /** + * Let RDS manage the master user password in Secrets Manager. + */ + manageMasterUserPassword?: boolean; + /** + * Rotate the managed master user password on the next reconcile. + */ + rotateMasterUserPassword?: boolean; + /** + * KMS key used to encrypt the managed master user secret. + */ + masterUserSecretKmsKeyId?: string; + /** + * Listener port. In-place modify (sent as `DBPortNumber` on modify). + */ + port?: number; + /** + * Multi-AZ deployment (standalone instances). In-place modify. + */ + multiAZ?: boolean; + /** + * Availability zone (standalone single-AZ). Immutable — forces replacement. + */ + availabilityZone?: string; + /** + * Backup retention period in days. In-place modify. + */ + backupRetentionPeriod?: number; + /** + * Daily backup window, e.g. `07:00-09:00`. In-place modify. + */ + preferredBackupWindow?: string; + /** + * Weekly maintenance window, e.g. `Mon:00:00-Mon:03:00`. In-place modify. + */ + preferredMaintenanceWindow?: string; + /** + * Optional DB subnet group. Effectively immutable for an in-VPC instance. */ dbSubnetGroupName?: string; /** - * Optional DB parameter group. + * Optional DB parameter group. In-place modify. */ dbParameterGroupName?: string; /** - * VPC security groups attached to the instance. + * VPC security groups attached to the instance. In-place modify. */ vpcSecurityGroupIds?: string[]; /** - * Whether the instance is publicly reachable. + * Option group (MySQL/Oracle/SQL Server). In-place modify. + */ + optionGroupName?: string; + /** + * License model, e.g. `license-included` | `bring-your-own-license`. + */ + licenseModel?: string; + /** + * Whether storage is encrypted. Immutable — forces replacement. + */ + storageEncrypted?: boolean; + /** + * KMS key for storage encryption. Immutable — forces replacement. + */ + kmsKeyId?: string; + /** + * CA certificate identifier. In-place modify. + */ + caCertificateIdentifier?: string; + /** + * Enable IAM database authentication. In-place modify. + */ + enableIAMDatabaseAuthentication?: boolean; + /** + * Enable Performance Insights. In-place modify. + */ + enablePerformanceInsights?: boolean; + /** + * KMS key for Performance Insights. In-place modify. + */ + performanceInsightsKMSKeyId?: string; + /** + * Performance Insights retention in days (7, 731, or month multiples). + */ + performanceInsightsRetentionPeriod?: number; + /** + * Enhanced-monitoring granularity in seconds (0, 1, 5, 10, 15, 30, 60). + */ + monitoringInterval?: number; + /** + * IAM role ARN for enhanced monitoring. In-place modify. + */ + monitoringRoleArn?: string; + /** + * Log types to export to CloudWatch Logs. Diffed against observed state and + * applied via the delta-shaped `CloudwatchLogsExportConfiguration` on modify. + */ + enableCloudwatchLogsExports?: string[]; + /** + * Block accidental deletion. In-place modify. + */ + deletionProtection?: boolean; + /** + * Network type: `IPV4` | `DUAL`. In-place modify. + */ + networkType?: string; + /** + * Allow a major engine-version upgrade during a modify. Modify-only flag. + */ + allowMajorVersionUpgrade?: boolean; + /** + * Whether the instance is publicly reachable. In-place modify. */ publiclyAccessible?: boolean; /** @@ -81,6 +216,33 @@ export interface DBInstance extends Resource< publiclyAccessible: boolean | undefined; dbSubnetGroupName: string | undefined; dbParameterGroupNames: string[]; + allocatedStorage: number | undefined; + maxAllocatedStorage: number | undefined; + storageType: string | undefined; + iops: number | undefined; + storageThroughput: number | undefined; + multiAZ: boolean | undefined; + availabilityZone: string | undefined; + secondaryAvailabilityZone: string | undefined; + backupRetentionPeriod: number | undefined; + preferredBackupWindow: string | undefined; + preferredMaintenanceWindow: string | undefined; + kmsKeyId: string | undefined; + storageEncrypted: boolean | undefined; + caCertificateIdentifier: string | undefined; + iamDatabaseAuthenticationEnabled: boolean | undefined; + performanceInsightsEnabled: boolean | undefined; + monitoringInterval: number | undefined; + enhancedMonitoringResourceArn: string | undefined; + enabledCloudwatchLogsExports: string[]; + deletionProtection: boolean | undefined; + dbiResourceId: string | undefined; + masterUsername: string | undefined; + masterUserSecretArn: string | undefined; + optionGroupMemberships: string[]; + licenseModel: string | undefined; + dbInstancePort: number | undefined; + networkType: string | undefined; tags: Record; }, never, @@ -88,7 +250,54 @@ export interface DBInstance extends Resource< > {} /** - * An Aurora cluster instance. + * An RDS database instance — either a standalone (non-Aurora) database or a + * member of an Aurora `DBCluster`. + * + * Exposes the full storage, backup, monitoring, performance-insights, + * encryption, networking, and log-export surface of `createDBInstance` / + * `modifyDBInstance`. Mutable fields are reconciled in place against the + * observed cloud state; immutable fields (`engine`, `dbName`, + * `masterUsername`, `availabilityZone`, `storageEncrypted`, `kmsKeyId`, + * `dbSubnetGroupName`) force a replacement. + * + * @section Standalone Instance + * @example A gp3 MySQL instance + * ```typescript + * const db = yield* DBInstance("Db", { + * engine: "mysql", + * dbInstanceClass: "db.t3.micro", + * allocatedStorage: 20, + * storageType: "gp3", + * masterUsername: "admin", + * masterUserPassword: Redacted.make("supersecret"), + * backupRetentionPeriod: 7, + * deletionProtection: false, + * }); + * ``` + * + * @section Cluster Member + * @example An Aurora writer instance + * ```typescript + * const writer = yield* DBInstance("Writer", { + * dbClusterIdentifier: cluster.dbClusterIdentifier, + * dbInstanceClass: "db.serverless", + * engine: "aurora-postgresql", + * }); + * ``` + * + * @section Monitoring & Logs + * @example Enhanced monitoring + log export + * ```typescript + * const db = yield* DBInstance("Db", { + * engine: "postgres", + * dbInstanceClass: "db.t3.micro", + * allocatedStorage: 20, + * monitoringInterval: 60, + * monitoringRoleArn: monitoringRole.roleArn, + * enablePerformanceInsights: true, + * enableCloudwatchLogsExports: ["postgresql", "upgrade"], + * }); + * ``` */ export const DBInstance = Resource("AWS.RDS.DBInstance"); @@ -126,9 +335,62 @@ const toAttrs = ({ dbParameterGroupNames: (instance.DBParameterGroups ?? []).flatMap((group) => group.DBParameterGroupName ? [group.DBParameterGroupName] : [], ), + allocatedStorage: instance.AllocatedStorage, + maxAllocatedStorage: instance.MaxAllocatedStorage, + storageType: instance.StorageType, + iops: instance.Iops, + storageThroughput: instance.StorageThroughput, + multiAZ: instance.MultiAZ, + availabilityZone: instance.AvailabilityZone, + secondaryAvailabilityZone: instance.SecondaryAvailabilityZone, + backupRetentionPeriod: instance.BackupRetentionPeriod, + preferredBackupWindow: instance.PreferredBackupWindow, + preferredMaintenanceWindow: instance.PreferredMaintenanceWindow, + kmsKeyId: instance.KmsKeyId, + storageEncrypted: instance.StorageEncrypted, + caCertificateIdentifier: instance.CACertificateIdentifier, + iamDatabaseAuthenticationEnabled: instance.IAMDatabaseAuthenticationEnabled, + performanceInsightsEnabled: instance.PerformanceInsightsEnabled, + monitoringInterval: instance.MonitoringInterval, + enhancedMonitoringResourceArn: instance.EnhancedMonitoringResourceArn, + enabledCloudwatchLogsExports: instance.EnabledCloudwatchLogsExports ?? [], + deletionProtection: instance.DeletionProtection, + dbiResourceId: instance.DbiResourceId, + masterUsername: instance.MasterUsername, + masterUserSecretArn: instance.MasterUserSecret?.SecretArn, + optionGroupMemberships: (instance.OptionGroupMemberships ?? []).flatMap( + (membership) => + membership.OptionGroupName ? [membership.OptionGroupName] : [], + ), + licenseModel: instance.LicenseModel, + dbInstancePort: instance.DbInstancePort, + networkType: instance.NetworkType, tags, }); +/** + * Compute the CloudWatch Logs export delta. The modify API is delta-shaped + * (`EnableLogTypes`/`DisableLogTypes`), so it must NOT carry the full set. + * Returns `undefined` when there is no change. + */ +const logExportDelta = ( + observed: string[] | undefined, + desired: string[] | undefined, +): rds.CloudwatchLogsExportConfiguration | undefined => { + if (desired === undefined) return undefined; + const have = new Set(observed ?? []); + const want = new Set(desired); + const EnableLogTypes = [...want].filter((t) => !have.has(t)); + const DisableLogTypes = [...have].filter((t) => !want.has(t)); + if (EnableLogTypes.length === 0 && DisableLogTypes.length === 0) { + return undefined; + } + return { + ...(EnableLogTypes.length > 0 ? { EnableLogTypes } : {}), + ...(DisableLogTypes.length > 0 ? { DisableLogTypes } : {}), + }; +}; + export const DBInstanceProvider = () => Provider.effect( DBInstance, @@ -151,16 +413,41 @@ export const DBInstanceProvider = () => return response?.DBInstances?.[0]; }); - const waitForInstance = Effect.fn(function* (instanceId: string) { - const readinessPolicy = Schedule.fixed("2 seconds").pipe( - Schedule.both(Schedule.recurs(30)), + // Bounded readiness wait. Gate on `DBInstanceStatus === "available"` so a + // follow-on `modifyDBInstance` doesn't hit `InvalidDBInstanceStateFault`. + // `waitForAvailable` budgets ~10 min (60 * 10s) for slow provisioning; + // `requireAvailable: false` only waits for the ARN to appear. + const waitForInstance = Effect.fn(function* ( + instanceId: string, + { requireAvailable = true }: { requireAvailable?: boolean } = {}, + ) { + const readinessPolicy = Schedule.fixed("10 seconds").pipe( + Schedule.both(Schedule.recurs(60)), ); return yield* readInstance(instanceId).pipe( - Effect.flatMap((instance) => - instance?.DBInstanceArn - ? Effect.succeed(instance) - : Effect.fail(new Error(`DB instance '${instanceId}' not ready`)), - ), + Effect.flatMap((instance) => { + if (!instance?.DBInstanceArn) { + return Effect.fail( + new Error(`DB instance '${instanceId}' not found`), + ); + } + // Statuses that will never settle on their own — surface instead of + // spinning until the bound is hit. + const status = instance.DBInstanceStatus; + if ( + requireAvailable && + status !== "available" && + status !== "incompatible-parameters" && + status !== "incompatible-restore" + ) { + return Effect.fail( + new Error( + `DB instance '${instanceId}' not available (status: ${status})`, + ), + ); + } + return Effect.succeed(instance); + }), Effect.retry({ schedule: readinessPolicy }), ); }); @@ -206,6 +493,19 @@ export const DBInstanceProvider = () => ) { return { action: "replace" } as const; } + // Immutable props — any change forces a fresh instance. + if ( + olds !== undefined && + (olds.engine !== news.engine || + olds.dbName !== news.dbName || + olds.masterUsername !== news.masterUsername || + olds.availabilityZone !== news.availabilityZone || + olds.storageEncrypted !== news.storageEncrypted || + olds.kmsKeyId !== news.kmsKeyId || + olds.dbSubnetGroupName !== news.dbSubnetGroupName) + ) { + return { action: "replace" } as const; + } }), read: Effect.fn(function* ({ id, olds, output }) { const identifier = @@ -239,8 +539,40 @@ export const DBInstanceProvider = () => DBInstanceClass: news.dbInstanceClass, Engine: news.engine, EngineVersion: news.engineVersion, + DBName: news.dbName, + AllocatedStorage: news.allocatedStorage, + MaxAllocatedStorage: news.maxAllocatedStorage, + StorageType: news.storageType, + Iops: news.iops, + StorageThroughput: news.storageThroughput, + MasterUsername: news.masterUsername, + MasterUserPassword: news.masterUserPassword, + ManageMasterUserPassword: news.manageMasterUserPassword, + MasterUserSecretKmsKeyId: news.masterUserSecretKmsKeyId, + Port: news.port, + MultiAZ: news.multiAZ, + AvailabilityZone: news.availabilityZone, + BackupRetentionPeriod: news.backupRetentionPeriod, + PreferredBackupWindow: news.preferredBackupWindow, + PreferredMaintenanceWindow: news.preferredMaintenanceWindow, DBSubnetGroupName: news.dbSubnetGroupName, DBParameterGroupName: news.dbParameterGroupName, + OptionGroupName: news.optionGroupName, + LicenseModel: news.licenseModel, + StorageEncrypted: news.storageEncrypted, + KmsKeyId: news.kmsKeyId, + CACertificateIdentifier: news.caCertificateIdentifier, + EnableIAMDatabaseAuthentication: + news.enableIAMDatabaseAuthentication, + EnablePerformanceInsights: news.enablePerformanceInsights, + PerformanceInsightsKMSKeyId: news.performanceInsightsKMSKeyId, + PerformanceInsightsRetentionPeriod: + news.performanceInsightsRetentionPeriod, + MonitoringInterval: news.monitoringInterval, + MonitoringRoleArn: news.monitoringRoleArn, + EnableCloudwatchLogsExports: news.enableCloudwatchLogsExports, + DeletionProtection: news.deletionProtection, + NetworkType: news.networkType, VpcSecurityGroupIds: news.vpcSecurityGroupIds, PubliclyAccessible: news.publiclyAccessible, PromotionTier: news.promotionTier, @@ -260,22 +592,94 @@ export const DBInstanceProvider = () => observed = yield* waitForInstance(identifier); } else { - // Sync mutable instance config — push desired shape via - // `modifyDBInstance`. Many fields land in - // `PendingModifiedValues`; `ApplyImmediately` shortens the wait. - yield* rds.modifyDBInstance({ + // Wait for the instance to settle before any modify so the call + // doesn't hit `InvalidDBInstanceStateFault`. + observed = yield* waitForInstance(identifier); + + // syncCoreSettings — single `modifyDBInstance` carrying scalar + // in-place fields. Only emit a field when the desired value differs + // from the observed cloud state, to avoid spurious + // `PendingModifiedValues`. `Port` maps to `DBPortNumber` on modify. + const core: rds.ModifyDBInstanceMessage = { DBInstanceIdentifier: identifier, - DBInstanceClass: news.dbInstanceClass, - EngineVersion: news.engineVersion, - DBParameterGroupName: news.dbParameterGroupName, - VpcSecurityGroupIds: news.vpcSecurityGroupIds, - PubliclyAccessible: news.publiclyAccessible, - PromotionTier: news.promotionTier, - AutoMinorVersionUpgrade: news.autoMinorVersionUpgrade, - CopyTagsToSnapshot: news.copyTagsToSnapshot, ApplyImmediately: true, - }); - observed = yield* waitForInstance(identifier); + }; + let coreDirty = false; + const setIf = ( + key: K, + desired: rds.ModifyDBInstanceMessage[K] | undefined, + observedValue: unknown, + ) => { + if (desired !== undefined && desired !== observedValue) { + core[key] = desired; + coreDirty = true; + } + }; + setIf("DBInstanceClass", news.dbInstanceClass, observed.DBInstanceClass); // prettier-ignore + setIf("EngineVersion", news.engineVersion, observed.EngineVersion); + setIf("AllocatedStorage", news.allocatedStorage, observed.AllocatedStorage); // prettier-ignore + setIf("MaxAllocatedStorage", news.maxAllocatedStorage, observed.MaxAllocatedStorage); // prettier-ignore + setIf("StorageType", news.storageType, observed.StorageType); + setIf("Iops", news.iops, observed.Iops); + setIf("StorageThroughput", news.storageThroughput, observed.StorageThroughput); // prettier-ignore + setIf("MultiAZ", news.multiAZ, observed.MultiAZ); + setIf("BackupRetentionPeriod", news.backupRetentionPeriod, observed.BackupRetentionPeriod); // prettier-ignore + setIf("PreferredBackupWindow", news.preferredBackupWindow, observed.PreferredBackupWindow); // prettier-ignore + setIf("PreferredMaintenanceWindow", news.preferredMaintenanceWindow, observed.PreferredMaintenanceWindow); // prettier-ignore + setIf("DBPortNumber", news.port, observed.DbInstancePort); + setIf("OptionGroupName", news.optionGroupName, undefined); + setIf("LicenseModel", news.licenseModel, observed.LicenseModel); + setIf("CACertificateIdentifier", news.caCertificateIdentifier, observed.CACertificateIdentifier); // prettier-ignore + setIf("EnableIAMDatabaseAuthentication", news.enableIAMDatabaseAuthentication, observed.IAMDatabaseAuthenticationEnabled); // prettier-ignore + setIf("EnablePerformanceInsights", news.enablePerformanceInsights, observed.PerformanceInsightsEnabled); // prettier-ignore + setIf("PerformanceInsightsKMSKeyId", news.performanceInsightsKMSKeyId, observed.PerformanceInsightsKMSKeyId); // prettier-ignore + setIf("PerformanceInsightsRetentionPeriod", news.performanceInsightsRetentionPeriod, observed.PerformanceInsightsRetentionPeriod); // prettier-ignore + setIf("MonitoringInterval", news.monitoringInterval, observed.MonitoringInterval); // prettier-ignore + setIf("MonitoringRoleArn", news.monitoringRoleArn, observed.MonitoringRoleArn); // prettier-ignore + setIf("DeletionProtection", news.deletionProtection, observed.DeletionProtection); // prettier-ignore + setIf("NetworkType", news.networkType, observed.NetworkType); + setIf("DBParameterGroupName", news.dbParameterGroupName, undefined); + setIf("PubliclyAccessible", news.publiclyAccessible, observed.PubliclyAccessible); // prettier-ignore + setIf("PromotionTier", news.promotionTier, observed.PromotionTier); + setIf("AutoMinorVersionUpgrade", news.autoMinorVersionUpgrade, observed.AutoMinorVersionUpgrade); // prettier-ignore + setIf("CopyTagsToSnapshot", news.copyTagsToSnapshot, observed.CopyTagsToSnapshot); // prettier-ignore + if (news.vpcSecurityGroupIds !== undefined) { + core.VpcSecurityGroupIds = news.vpcSecurityGroupIds; + coreDirty = true; + } + if (news.allowMajorVersionUpgrade) { + core.AllowMajorVersionUpgrade = true; + } + // syncMasterPassword — rotation or explicit password update. + if ( + news.manageMasterUserPassword && + news.rotateMasterUserPassword + ) { + core.RotateMasterUserPassword = true; + coreDirty = true; + } else if (news.masterUserPassword !== undefined) { + core.MasterUserPassword = news.masterUserPassword; + coreDirty = true; + } + if (coreDirty) { + yield* rds.modifyDBInstance(core); + observed = yield* waitForInstance(identifier); + } + + // syncCloudwatchLogsExports — delta-shaped; separate call so it + // never mixes the full-set fields above. + const logDelta = logExportDelta( + observed.EnabledCloudwatchLogsExports, + news.enableCloudwatchLogsExports, + ); + if (logDelta) { + yield* rds.modifyDBInstance({ + DBInstanceIdentifier: identifier, + CloudwatchLogsExportConfiguration: logDelta, + ApplyImmediately: true, + }); + observed = yield* waitForInstance(identifier); + } } const dbInstanceArn = observed.DBInstanceArn ?? ""; @@ -308,6 +712,28 @@ export const DBInstanceProvider = () => .pipe( Effect.catchTag("DBInstanceNotFoundFault", () => Effect.void), ); + // Block until the instance is fully gone. RDS deletion is async; if we + // return while it is still `deleting`, a dependent (e.g. a + // DBSubnetGroup or VPC) is torn down next and AWS rejects it with + // `InvalidDBSubnetGroupStateFault: ... still using it`. + yield* Effect.repeat( + rds + .describeDBInstances({ + DBInstanceIdentifier: output.dbInstanceIdentifier, + }) + .pipe( + Effect.as(true), + Effect.catchTag("DBInstanceNotFoundFault", () => + Effect.succeed(false), + ), + ), + { + schedule: Schedule.fixed("15 seconds").pipe( + Schedule.both(Schedule.recurs(40)), + ), + until: (exists) => exists === false, + }, + ).pipe(Effect.catch(() => Effect.void)); }), }; }), diff --git a/packages/alchemy/src/AWS/Route53/HealthCheck.ts b/packages/alchemy/src/AWS/Route53/HealthCheck.ts new file mode 100644 index 0000000000..5c5afbd223 --- /dev/null +++ b/packages/alchemy/src/AWS/Route53/HealthCheck.ts @@ -0,0 +1,349 @@ +import * as route53 from "@distilled.cloud/aws/route-53"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import * as Stream from "effect/Stream"; +import { isResolved } from "../../Diff.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import { createInternalTags, diffTags } from "../../Tags.ts"; +import type { Providers } from "../Providers.ts"; + +export interface HealthCheckProps { + /** + * Health check protocol/type (e.g. `"HTTP"`, `"HTTPS"`, `"TCP"`, + * `"CALCULATED"`, `"CLOUDWATCH_METRIC"`). Immutable — changing it forces + * replacement. + */ + type: route53.HealthCheckType; + /** + * IP address of the endpoint to check. + */ + ipAddress?: string; + /** + * Port of the endpoint to check. + */ + port?: number; + /** + * Path requested for HTTP/HTTPS checks (e.g. `"/health"`). + */ + resourcePath?: string; + /** + * Fully qualified domain name of the endpoint. + */ + fullyQualifiedDomainName?: string; + /** + * String the response body must contain for the check to pass. + */ + searchString?: string; + /** + * Seconds between checks (10 or 30). Immutable — changing it forces + * replacement. + * @default 30 + */ + requestInterval?: number; + /** + * Number of consecutive failures before the endpoint is considered unhealthy. + * @default 3 + */ + failureThreshold?: number; + /** + * Whether Route 53 measures latency. Immutable — changing it forces + * replacement. + */ + measureLatency?: boolean; + /** + * Invert the health check result. + */ + inverted?: boolean; + /** + * Disable the health check (treated as healthy). + */ + disabled?: boolean; + /** + * For CALCULATED checks, the number of child checks that must be healthy. + */ + healthThreshold?: number; + /** + * For CALCULATED checks, the child health check IDs. + */ + childHealthChecks?: string[]; + /** + * Send SNI to the endpoint for HTTPS checks. + */ + enableSNI?: boolean; + /** + * Regions from which Route 53 checks the endpoint. + */ + regions?: route53.HealthCheckRegion[]; + /** + * Tags applied to the health check. + */ + tags?: Record; +} + +export interface HealthCheck extends Resource< + "AWS.Route53.HealthCheck", + HealthCheckProps, + { + /** + * Health check ID. + */ + id: string; + /** + * Alias of `id`. + */ + healthCheckId: string; + /** + * Health check type. + */ + type: route53.HealthCheckType; + }, + never, + Providers +> {} + +/** + * A Route 53 health check. + * + * `HealthCheck` monitors the health of an endpoint and can gate failover and + * other routing policies on a `Record` via `record.healthCheckId`. + * + * @section Creating a Health Check + * @example HTTP Health Check + * ```typescript + * const check = yield* HealthCheck("ApiHealth", { + * type: "HTTP", + * fullyQualifiedDomainName: "api.example.com", + * resourcePath: "/health", + * port: 80, + * requestInterval: 30, + * failureThreshold: 3, + * }); + * ``` + */ +export const HealthCheck = Resource("AWS.Route53.HealthCheck"); + +const toConfig = (props: HealthCheckProps): route53.HealthCheckConfig => ({ + Type: props.type, + IPAddress: props.ipAddress, + Port: props.port, + ResourcePath: props.resourcePath, + FullyQualifiedDomainName: props.fullyQualifiedDomainName, + SearchString: props.searchString, + RequestInterval: props.requestInterval, + FailureThreshold: props.failureThreshold, + MeasureLatency: props.measureLatency, + Inverted: props.inverted, + Disabled: props.disabled, + HealthThreshold: props.healthThreshold, + ChildHealthChecks: props.childHealthChecks, + EnableSNI: props.enableSNI, + Regions: props.regions, +}); + +// Fields settable via UpdateHealthCheck (i.e. everything except the immutable +// Type / RequestInterval / MeasureLatency). +const mutableFields = (props: HealthCheckProps) => ({ + IPAddress: props.ipAddress, + Port: props.port, + ResourcePath: props.resourcePath, + FullyQualifiedDomainName: props.fullyQualifiedDomainName, + SearchString: props.searchString, + FailureThreshold: props.failureThreshold, + Inverted: props.inverted, + Disabled: props.disabled, + HealthThreshold: props.healthThreshold, + ChildHealthChecks: props.childHealthChecks, + EnableSNI: props.enableSNI, + Regions: props.regions, +}); + +const mutableDiffers = ( + observed: route53.HealthCheckConfig, + desired: HealthCheckProps, +): boolean => { + const d = mutableFields(desired); + return ( + observed.IPAddress !== d.IPAddress || + observed.Port !== d.Port || + observed.ResourcePath !== d.ResourcePath || + observed.FullyQualifiedDomainName !== d.FullyQualifiedDomainName || + observed.SearchString !== d.SearchString || + (observed.FailureThreshold ?? undefined) !== d.FailureThreshold || + (observed.Inverted ?? undefined) !== d.Inverted || + (observed.Disabled ?? undefined) !== d.Disabled || + (observed.HealthThreshold ?? undefined) !== d.HealthThreshold || + (observed.EnableSNI ?? undefined) !== d.EnableSNI + ); +}; + +export const HealthCheckProvider = () => + Provider.effect( + HealthCheck, + Effect.gen(function* () { + const observe = Effect.fn(function* (id: string) { + return yield* route53.getHealthCheck({ HealthCheckId: id }).pipe( + Effect.map((r) => r.HealthCheck), + Effect.catchTag("NoSuchHealthCheck", () => Effect.succeed(undefined)), + ); + }); + + const observedTags = Effect.fn(function* (id: string) { + const response = yield* route53.listTagsForResource({ + ResourceType: "healthcheck", + ResourceId: id, + }); + const record: Record = {}; + for (const tag of response.ResourceTagSet.Tags ?? []) { + if (tag.Key !== undefined && tag.Value !== undefined) { + record[tag.Key] = tag.Value; + } + } + return record; + }); + + const syncTags = Effect.fn(function* ( + id: string, + logicalId: string, + userTags: Record | undefined, + ) { + const internalTags = yield* createInternalTags(logicalId); + const newTags = { ...userTags, ...internalTags }; + const oldTags = yield* observedTags(id); + const { upsert, removed } = diffTags(oldTags, newTags); + if (upsert.length === 0 && removed.length === 0) { + return; + } + yield* route53.changeTagsForResource({ + ResourceType: "healthcheck", + ResourceId: id, + AddTags: upsert.length > 0 ? upsert : undefined, + RemoveTagKeys: removed.length > 0 ? removed : undefined, + }); + }); + + return { + stables: ["id", "healthCheckId"], + list: () => + route53.listHealthChecks.pages({}).pipe( + Stream.runCollect, + Effect.map((chunk) => + Array.from(chunk).flatMap((page) => + (page.HealthChecks ?? []).map((check) => ({ + id: check.Id, + healthCheckId: check.Id, + type: check.HealthCheckConfig.Type, + })), + ), + ), + ), + diff: Effect.fn(function* ({ olds, news }) { + if (!isResolved(news)) return undefined; + if ( + olds.type !== news.type || + (olds.requestInterval ?? 30) !== (news.requestInterval ?? 30) || + (olds.measureLatency ?? false) !== (news.measureLatency ?? false) + ) { + return { action: "replace" } as const; + } + }), + read: Effect.fn(function* ({ output }) { + if (!output?.id) { + return undefined; + } + const check = yield* observe(output.id); + if (!check) { + return undefined; + } + return { + id: check.Id, + healthCheckId: check.Id, + type: check.HealthCheckConfig.Type, + }; + }), + reconcile: Effect.fn(function* ({ id, instanceId, news, output }) { + // Observe. + let check = output?.id ? yield* observe(output.id) : undefined; + + // Ensure — CallerReference makes create idempotent. + if (!check) { + check = yield* route53 + .createHealthCheck({ + CallerReference: instanceId, + HealthCheckConfig: toConfig(news), + }) + .pipe( + Effect.map((r) => r.HealthCheck), + Effect.catchTag("HealthCheckAlreadyExists", () => + // Same CallerReference already created it; the engine stores + // output, so on a true re-run output.id observes above. A bare + // race here means we must re-read — but the API gives us no id, + // so fall back to the stored output if present. + output?.id + ? observe(output.id).pipe( + Effect.flatMap((existing) => + existing + ? Effect.succeed(existing) + : Effect.die( + new Error( + "health check exists but could not be observed", + ), + ), + ), + ) + : Effect.die( + new Error( + "health check already exists for caller reference", + ), + ), + ), + ); + } + + // Sync config — diff observed mutable fields against desired. + if (mutableDiffers(check.HealthCheckConfig, news)) { + const updated = yield* route53 + .updateHealthCheck({ + HealthCheckId: check.Id, + HealthCheckVersion: check.HealthCheckVersion, + ...mutableFields(news), + }) + .pipe( + Effect.map((r) => r.HealthCheck), + // Optimistic-lock retry: re-read for the latest version. + Effect.retry({ + while: (e) => e._tag === "HealthCheckVersionMismatch", + schedule: Schedule.fixed("1 second").pipe( + Schedule.both(Schedule.recurs(5)), + ), + }), + ); + check = updated; + } + + // Sync tags. + yield* syncTags(check.Id, id, news.tags); + + return { + id: check.Id, + healthCheckId: check.Id, + type: check.HealthCheckConfig.Type, + }; + }), + delete: Effect.fn(function* ({ output }) { + yield* route53.deleteHealthCheck({ HealthCheckId: output.id }).pipe( + Effect.asVoid, + Effect.catchTag("NoSuchHealthCheck", () => Effect.void), + // Still referenced by a record whose delete is propagating. + Effect.retry({ + while: (e) => e._tag === "HealthCheckInUse", + schedule: Schedule.fixed("3 seconds").pipe( + Schedule.both(Schedule.recurs(10)), + ), + }), + Effect.catchTag("HealthCheckInUse", () => Effect.void), + ); + }), + }; + }), + ); diff --git a/packages/alchemy/src/AWS/Route53/HostedZone.ts b/packages/alchemy/src/AWS/Route53/HostedZone.ts new file mode 100644 index 0000000000..771151b9de --- /dev/null +++ b/packages/alchemy/src/AWS/Route53/HostedZone.ts @@ -0,0 +1,378 @@ +import * as route53 from "@distilled.cloud/aws/route-53"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import * as Stream from "effect/Stream"; +import { isResolved } from "../../Diff.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import { createInternalTags, diffTags } from "../../Tags.ts"; +import type { Providers } from "../Providers.ts"; + +export interface HostedZoneProps { + /** + * Fully qualified domain name for the zone (e.g. `"example.com"`). A trailing + * dot is added automatically. Changing the name forces replacement. + */ + name: string; + /** + * Optional comment describing the zone. Updated in place. + */ + comment?: string; + /** + * Whether this is a private hosted zone. Requires `vpc`. Changing this forces + * replacement. + * @default false + */ + privateZone?: boolean; + /** + * VPC to associate with a private hosted zone at create time. Changing the + * initial VPC forces replacement. + */ + vpc?: { + /** VPC ID. */ + vpcId: string; + /** Region the VPC lives in. */ + vpcRegion: string; + }; + /** + * ID of a reusable delegation set to associate with the zone. Changing this + * forces replacement. + */ + delegationSetId?: string; + /** + * Whether to delete all non-SOA/NS records before deleting the zone. + * @default false + */ + forceDestroy?: boolean; + /** + * Tags applied to the hosted zone. + */ + tags?: Record; +} + +export interface HostedZone extends Resource< + "AWS.Route53.HostedZone", + HostedZoneProps, + { + /** + * Hosted zone ID (without the `/hostedzone/` prefix). + */ + id: string; + /** + * Fully qualified zone name (with trailing dot). + */ + name: string; + /** + * Authoritative name servers for the zone. + */ + nameServers: string[]; + /** + * Current zone comment. + */ + comment: string | undefined; + }, + never, + Providers +> {} + +/** + * A Route 53 hosted zone. + * + * `HostedZone` manages the lifecycle of a public or private hosted zone, + * including its comment and tags. For public zones, the four authoritative + * name servers are exposed as `nameServers`. + * + * @section Creating a Hosted Zone + * @example Public Hosted Zone + * ```typescript + * const zone = yield* HostedZone("MyZone", { + * name: "example.com", + * comment: "Primary zone", + * }); + * // zone.nameServers -> the 4 NS records to set at your registrar + * ``` + * + * @example Force Destroy + * ```typescript + * const zone = yield* HostedZone("MyZone", { + * name: "example.com", + * forceDestroy: true, // delete leftover records on destroy + * }); + * ``` + */ +export const HostedZone = Resource("AWS.Route53.HostedZone"); + +const normalizeId = (id: string) => id.replace(/^\/hostedzone\//, ""); + +const normalizeName = (name: string) => + name.endsWith(".") ? name : `${name}.`; + +export const HostedZoneProvider = () => + Provider.effect( + HostedZone, + Effect.gen(function* () { + // Poll `getChange` until the change reaches INSYNC. `getChange` is + // eventually consistent and can briefly return `NoSuchChange` right after + // submit, so coalesce that to a non-INSYNC status and keep polling. + const waitForChange = Effect.fn(function* (changeId: string) { + return yield* route53.getChange({ Id: changeId }).pipe( + Effect.map((r) => r.ChangeInfo.Status), + Effect.catchTag("NoSuchChange", () => Effect.succeed("PENDING")), + Effect.repeat({ + schedule: Schedule.fixed("2 seconds").pipe( + Schedule.both(Schedule.recurs(60)), + ), + until: (status) => status === "INSYNC", + }), + ); + }); + + const findByName = Effect.fn(function* (name: string) { + const response = yield* route53.listHostedZonesByName({ + DNSName: normalizeName(name), + MaxItems: 1, + }); + return (response.HostedZones ?? []).find( + (zone) => zone.Name === normalizeName(name), + ); + }); + + const observe = Effect.fn(function* (id: string) { + return yield* route53 + .getHostedZone({ Id: normalizeId(id) }) + .pipe( + Effect.catchTag("NoSuchHostedZone", () => + Effect.succeed(undefined), + ), + ); + }); + + const observedTags = Effect.fn(function* (id: string) { + const response = yield* route53.listTagsForResource({ + ResourceType: "hostedzone", + ResourceId: normalizeId(id), + }); + const record: Record = {}; + for (const tag of response.ResourceTagSet.Tags ?? []) { + if (tag.Key !== undefined && tag.Value !== undefined) { + record[tag.Key] = tag.Value; + } + } + return record; + }); + + const syncTags = Effect.fn(function* ( + id: string, + logicalId: string, + userTags: Record | undefined, + ) { + const internalTags = yield* createInternalTags(logicalId); + const newTags = { ...userTags, ...internalTags }; + const oldTags = yield* observedTags(id); + const { upsert, removed } = diffTags(oldTags, newTags); + if (upsert.length === 0 && removed.length === 0) { + return; + } + yield* route53.changeTagsForResource({ + ResourceType: "hostedzone", + ResourceId: normalizeId(id), + AddTags: upsert.length > 0 ? upsert : undefined, + RemoveTagKeys: removed.length > 0 ? removed : undefined, + }); + }); + + // Delete every non-SOA/NS record set so the zone can be deleted. + const purgeRecords = Effect.fn(function* (id: string) { + const sets: route53.ResourceRecordSet[] = []; + let request: route53.ListResourceRecordSetsRequest = { + HostedZoneId: normalizeId(id), + MaxItems: 300, + }; + while (true) { + const response = yield* route53.listResourceRecordSets(request); + sets.push(...(response.ResourceRecordSets ?? [])); + if (!response.IsTruncated || response.NextRecordName === undefined) { + break; + } + request = { + HostedZoneId: normalizeId(id), + StartRecordName: response.NextRecordName, + StartRecordType: response.NextRecordType, + StartRecordIdentifier: response.NextRecordIdentifier, + MaxItems: 300, + }; + } + const deletable = sets.filter( + (set) => set.Type !== "SOA" && set.Type !== "NS", + ); + if (deletable.length === 0) { + return; + } + yield* route53 + .changeResourceRecordSets({ + HostedZoneId: normalizeId(id), + ChangeBatch: { + Comment: "Alchemy HostedZone forceDestroy", + Changes: deletable.map((set) => ({ + Action: "DELETE" as const, + ResourceRecordSet: set, + })), + }, + }) + .pipe( + Effect.flatMap((response) => waitForChange(response.ChangeInfo.Id)), + ); + }); + + return { + stables: ["id", "name"], + list: () => + route53.listHostedZones.pages({}).pipe( + Stream.runCollect, + Effect.map((chunk) => + Array.from(chunk).flatMap((page) => + (page.HostedZones ?? []).map((zone) => ({ + id: normalizeId(zone.Id), + name: zone.Name, + nameServers: [] as string[], + comment: zone.Config?.Comment, + })), + ), + ), + ), + diff: Effect.fn(function* ({ olds, news }) { + if (!isResolved(news)) return undefined; + if ( + normalizeName(olds.name) !== normalizeName(news.name) || + (olds.privateZone ?? false) !== (news.privateZone ?? false) || + olds.delegationSetId !== news.delegationSetId || + olds.vpc?.vpcId !== news.vpc?.vpcId + ) { + return { action: "replace" } as const; + } + }), + read: Effect.fn(function* ({ olds, output }) { + // Resolve the zone id: prefer the stored output, else look up by name + // (adoption / state-loss recovery). + const zoneId = output?.id ?? (yield* findByName(olds!.name))?.Id; + if (zoneId === undefined) { + return undefined; + } + const detail = yield* observe(zoneId); + if (!detail) { + return undefined; + } + return { + id: normalizeId(detail.HostedZone.Id), + name: detail.HostedZone.Name, + nameServers: detail.DelegationSet?.NameServers ?? [], + comment: detail.HostedZone.Config?.Comment, + }; + }), + reconcile: Effect.fn(function* ({ id, instanceId, news, output }) { + // Observe. + let zone = output?.id ? yield* observe(output.id) : undefined; + + // Ensure. + if (!zone) { + const created = yield* route53 + .createHostedZone({ + Name: normalizeName(news.name), + CallerReference: instanceId, + HostedZoneConfig: + news.comment !== undefined || news.privateZone + ? { + Comment: news.comment, + PrivateZone: news.privateZone, + } + : undefined, + VPC: news.vpc + ? { VPCId: news.vpc.vpcId, VPCRegion: news.vpc.vpcRegion } + : undefined, + DelegationSetId: news.delegationSetId, + }) + .pipe( + Effect.map((response) => ({ + HostedZone: response.HostedZone, + DelegationSet: response.DelegationSet, + })), + // Re-running with the same CallerReference races; re-observe. + Effect.catchTag("HostedZoneAlreadyExists", () => + Effect.gen(function* () { + const existing = yield* findByName(news.name); + if (!existing) { + return yield* Effect.die( + new Error( + "hosted zone not found after HostedZoneAlreadyExists", + ), + ); + } + return yield* observe(existing.Id).pipe( + Effect.map((z) => + z + ? { + HostedZone: z.HostedZone, + DelegationSet: z.DelegationSet, + } + : undefined, + ), + ); + }), + ), + ); + if (!created) { + return yield* Effect.die( + new Error("hosted zone could not be observed after create"), + ); + } + zone = { + HostedZone: created.HostedZone, + DelegationSet: created.DelegationSet, + VPCs: undefined, + }; + } + + const zoneId = zone.HostedZone.Id; + + // Sync comment. + if ((zone.HostedZone.Config?.Comment ?? undefined) !== news.comment) { + yield* route53.updateHostedZoneComment({ + Id: normalizeId(zoneId), + Comment: news.comment ?? "", + }); + } + + // Sync tags. + yield* syncTags(zoneId, id, news.tags); + + // Re-read for fresh name servers + comment. + const detail = yield* observe(zoneId); + return { + id: normalizeId(zoneId), + name: detail?.HostedZone.Name ?? normalizeName(news.name), + nameServers: detail?.DelegationSet?.NameServers ?? [], + comment: detail?.HostedZone.Config?.Comment ?? news.comment, + }; + }), + delete: Effect.fn(function* ({ olds, output }) { + if (olds.forceDestroy) { + yield* purgeRecords(output.id).pipe( + Effect.catchTag("NoSuchHostedZone", () => Effect.void), + ); + } + yield* route53.deleteHostedZone({ Id: normalizeId(output.id) }).pipe( + Effect.asVoid, + Effect.catchTag("NoSuchHostedZone", () => Effect.void), + // A still-non-empty zone (without forceDestroy) is retried briefly + // in case a referencing record's delete is still propagating. + Effect.retry({ + while: (e) => e._tag === "PriorRequestNotComplete", + schedule: Schedule.fixed("2 seconds").pipe( + Schedule.both(Schedule.recurs(10)), + ), + }), + ); + }), + }; + }), + ); diff --git a/packages/alchemy/src/AWS/Route53/Record.ts b/packages/alchemy/src/AWS/Route53/Record.ts index 73dcad927e..0082fab678 100644 --- a/packages/alchemy/src/AWS/Route53/Record.ts +++ b/packages/alchemy/src/AWS/Route53/Record.ts @@ -30,6 +30,47 @@ export interface ResolvedRecordAliasTarget { evaluateTargetHealth?: boolean; } +export interface RecordGeoLocation { + /** + * Two-letter continent code (e.g. `"NA"`, `"EU"`). Mutually exclusive with + * `countryCode`. + */ + continentCode?: string; + /** + * Two-letter country code, or `"*"` for the default (catch-all) record. + */ + countryCode?: string; + /** + * Subdivision code (e.g. a US state). Requires `countryCode`. + */ + subdivisionCode?: string; +} + +export interface RecordGeoProximityCoordinates { + /** Latitude as a string (e.g. `"49.22"`). */ + latitude: string; + /** Longitude as a string (e.g. `"-122.41"`). */ + longitude: string; +} + +export interface RecordGeoProximityLocation { + /** AWS Region for the endpoint. Mutually exclusive with `coordinates`. */ + awsRegion?: string; + /** Local Zone Group for the endpoint. */ + localZoneGroup?: string; + /** Explicit latitude/longitude of the endpoint. */ + coordinates?: RecordGeoProximityCoordinates; + /** Bias (-99 to 99) that expands or shrinks the geographic region. */ + bias?: number; +} + +export interface RecordCidrRoutingConfig { + /** ID of the CIDR collection. */ + collectionId: string; + /** Name of the CIDR location within the collection. */ + locationName: string; +} + export interface RecordProps { /** * Hosted zone that owns the record. @@ -60,6 +101,40 @@ export interface RecordProps { * policies that require unique record identities. */ setIdentifier?: string; + /** + * Weight (0-255) for weighted routing. Requires `setIdentifier`. + */ + weight?: number; + /** + * AWS Region for latency-based routing. Requires `setIdentifier`. + */ + region?: route53.ResourceRecordSetRegion; + /** + * Failover role for failover routing. Requires `setIdentifier`. + */ + failover?: "PRIMARY" | "SECONDARY"; + /** + * Geolocation routing rule. Requires `setIdentifier`. + */ + geoLocation?: RecordGeoLocation; + /** + * Geoproximity routing rule. Requires `setIdentifier`. + */ + geoProximityLocation?: RecordGeoProximityLocation; + /** + * Whether this record participates in multivalue answer routing. Requires + * `setIdentifier`. + */ + multiValueAnswer?: boolean; + /** + * IP-based (CIDR) routing rule. Requires `setIdentifier`. + */ + cidrRoutingConfig?: RecordCidrRoutingConfig; + /** + * Health check that gates whether Route 53 returns this record. Typically a + * `HealthCheck.id`. + */ + healthCheckId?: string; } export interface Record extends Resource< @@ -94,6 +169,38 @@ export interface Record extends Resource< * Optional routing set identifier. */ setIdentifier: string | undefined; + /** + * Weight for weighted routing. + */ + weight: number | undefined; + /** + * AWS Region for latency routing. + */ + region: route53.ResourceRecordSetRegion | undefined; + /** + * Failover role for failover routing. + */ + failover: "PRIMARY" | "SECONDARY" | undefined; + /** + * Geolocation routing rule. + */ + geoLocation: RecordGeoLocation | undefined; + /** + * Geoproximity routing rule. + */ + geoProximityLocation: RecordGeoProximityLocation | undefined; + /** + * Whether this record participates in multivalue answer routing. + */ + multiValueAnswer: boolean | undefined; + /** + * IP-based (CIDR) routing rule. + */ + cidrRoutingConfig: RecordCidrRoutingConfig | undefined; + /** + * Health check that gates this record. + */ + healthCheckId: string | undefined; }, never, Providers @@ -130,6 +237,78 @@ export interface Record extends Resource< * records: ["\"value\""], * }); * ``` + * + * @section Routing Policies + * @example Weighted Routing + * ```typescript + * const blue = yield* Record("Blue", { + * hostedZoneId: zone.id, + * name: "api.example.com", + * type: "A", + * ttl: 60, + * records: ["1.2.3.4"], + * setIdentifier: "blue", + * weight: 90, + * }); + * const green = yield* Record("Green", { + * hostedZoneId: zone.id, + * name: "api.example.com", + * type: "A", + * ttl: 60, + * records: ["5.6.7.8"], + * setIdentifier: "green", + * weight: 10, + * }); + * ``` + * + * @example Failover Routing With Health Check + * ```typescript + * const primary = yield* Record("Primary", { + * hostedZoneId: zone.id, + * name: "app.example.com", + * type: "A", + * ttl: 60, + * records: ["1.2.3.4"], + * setIdentifier: "primary", + * failover: "PRIMARY", + * healthCheckId: healthCheck.id, + * }); + * const secondary = yield* Record("Secondary", { + * hostedZoneId: zone.id, + * name: "app.example.com", + * type: "A", + * ttl: 60, + * records: ["5.6.7.8"], + * setIdentifier: "secondary", + * failover: "SECONDARY", + * }); + * ``` + * + * @example Latency Routing + * ```typescript + * const record = yield* Record("UsEast", { + * hostedZoneId: zone.id, + * name: "api.example.com", + * type: "A", + * ttl: 60, + * records: ["1.2.3.4"], + * setIdentifier: "us-east-1", + * region: "us-east-1", + * }); + * ``` + * + * @example Geolocation Routing + * ```typescript + * const record = yield* Record("Default", { + * hostedZoneId: zone.id, + * name: "www.example.com", + * type: "A", + * ttl: 60, + * records: ["1.2.3.4"], + * setIdentifier: "default", + * geoLocation: { countryCode: "*" }, + * }); + * ``` */ export const Record = Resource("AWS.Route53.Record"); @@ -150,10 +329,111 @@ const toAliasTarget = ( } : undefined; -const toRecordSet = (props: RecordProps): route53.ResourceRecordSet => ({ +const toGeoLocation = ( + geo: RecordGeoLocation | undefined, +): route53.GeoLocation | undefined => + geo + ? { + ContinentCode: geo.continentCode, + CountryCode: geo.countryCode, + SubdivisionCode: geo.subdivisionCode, + } + : undefined; + +const fromGeoLocation = ( + geo: route53.GeoLocation | undefined, +): RecordGeoLocation | undefined => + geo + ? { + continentCode: geo.ContinentCode, + countryCode: geo.CountryCode, + subdivisionCode: geo.SubdivisionCode, + } + : undefined; + +const toGeoProximity = ( + geo: RecordGeoProximityLocation | undefined, +): route53.GeoProximityLocation | undefined => + geo + ? { + AWSRegion: geo.awsRegion, + LocalZoneGroup: geo.localZoneGroup, + Coordinates: geo.coordinates + ? { + Latitude: geo.coordinates.latitude, + Longitude: geo.coordinates.longitude, + } + : undefined, + Bias: geo.bias, + } + : undefined; + +const fromGeoProximity = ( + geo: route53.GeoProximityLocation | undefined, +): RecordGeoProximityLocation | undefined => + geo + ? { + awsRegion: geo.AWSRegion, + localZoneGroup: geo.LocalZoneGroup, + coordinates: geo.Coordinates + ? { + latitude: geo.Coordinates.Latitude, + longitude: geo.Coordinates.Longitude, + } + : undefined, + bias: geo.Bias, + } + : undefined; + +const toCidrRouting = ( + cidr: RecordCidrRoutingConfig | undefined, +): route53.CidrRoutingConfig | undefined => + cidr + ? { CollectionId: cidr.collectionId, LocationName: cidr.locationName } + : undefined; + +const fromCidrRouting = ( + cidr: route53.CidrRoutingConfig | undefined, +): RecordCidrRoutingConfig | undefined => + cidr + ? { collectionId: cidr.CollectionId, locationName: cidr.LocationName } + : undefined; + +/** + * Build the full `ResourceRecordSet` wire shape from props. Used for both the + * UPSERT change batch and the DELETE change batch — DELETE requires an exact + * match of every policy field, so this must round-trip the entire surface. + */ +const toRecordSet = ( + props: Pick< + RecordProps, + | "name" + | "type" + | "ttl" + | "records" + | "aliasTarget" + | "setIdentifier" + | "weight" + | "region" + | "failover" + | "geoLocation" + | "geoProximityLocation" + | "multiValueAnswer" + | "cidrRoutingConfig" + | "healthCheckId" + >, +): route53.ResourceRecordSet => ({ Name: normalizeName(props.name), Type: props.type, SetIdentifier: props.setIdentifier, + Weight: props.weight, + Region: props.region, + Failover: props.failover, + GeoLocation: toGeoLocation(props.geoLocation), + GeoProximityLocation: toGeoProximity(props.geoProximityLocation), + MultiValueAnswer: props.multiValueAnswer, + CidrRoutingConfig: toCidrRouting(props.cidrRoutingConfig), + HealthCheckId: props.healthCheckId, TTL: props.aliasTarget ? undefined : props.ttl, ResourceRecords: props.aliasTarget ? undefined @@ -180,27 +460,32 @@ const toAttrs = ( records: recordSet.ResourceRecords?.map((record) => record.Value), aliasTarget: toAliasTarget(recordSet.AliasTarget), setIdentifier: recordSet.SetIdentifier, + weight: recordSet.Weight, + region: recordSet.Region, + failover: recordSet.Failover as "PRIMARY" | "SECONDARY" | undefined, + geoLocation: fromGeoLocation(recordSet.GeoLocation), + geoProximityLocation: fromGeoProximity(recordSet.GeoProximityLocation), + multiValueAnswer: recordSet.MultiValueAnswer, + cidrRoutingConfig: fromCidrRouting(recordSet.CidrRoutingConfig), + healthCheckId: recordSet.HealthCheckId, }); export const RecordProvider = () => Provider.effect( Record, Effect.gen(function* () { + // Poll `getChange` until the change reaches INSYNC. `getChange` is + // eventually consistent and can briefly return `NoSuchChange` right after + // submit, so coalesce that to a non-INSYNC status and keep polling. const waitForChange = Effect.fn(function* (changeId: string) { return yield* route53.getChange({ Id: changeId }).pipe( - Effect.map((response) => response.ChangeInfo), - Effect.flatMap((changeInfo) => - changeInfo.Status === "INSYNC" - ? Effect.succeed(changeInfo) - : Effect.die(new Error("Route53ChangePending")), - ), - Effect.retry({ - while: (error) => - error instanceof Error && - error.message === "Route53ChangePending", + Effect.map((response) => response.ChangeInfo.Status), + Effect.catchTag("NoSuchChange", () => Effect.succeed("PENDING")), + Effect.repeat({ schedule: Schedule.fixed("2 seconds").pipe( Schedule.both(Schedule.recurs(60)), ), + until: (status) => status === "INSYNC", }), ); }); @@ -222,7 +507,7 @@ export const RecordProvider = () => ), ); - return response?.ResourceRecordSets.find( + return (response?.ResourceRecordSets ?? []).find( (recordSet) => recordSet.Name === normalizeName(props.name) && recordSet.Type === props.type && @@ -258,7 +543,7 @@ export const RecordProvider = () => }; while (true) { const response = yield* route53.listResourceRecordSets(request); - all.push(...response.ResourceRecordSets); + all.push(...(response.ResourceRecordSets ?? [])); if (!response.IsTruncated || response.NextRecordName === undefined) { break; } @@ -360,25 +645,25 @@ export const RecordProvider = () => Changes: [ { Action: "DELETE", - ResourceRecordSet: { - Name: output.name, - Type: output.type, - SetIdentifier: output.setIdentifier, - TTL: output.aliasTarget ? undefined : output.ttl, - ResourceRecords: output.records?.map((Value) => ({ - Value, - })), - AliasTarget: output.aliasTarget - ? { - HostedZoneId: normalizeHostedZoneId( - output.aliasTarget.hostedZoneId as string, - ), - DNSName: output.aliasTarget.dnsName as string, - EvaluateTargetHealth: - output.aliasTarget.evaluateTargetHealth ?? false, - } - : undefined, - }, + // Serialize the full record set — DELETE requires an exact + // match including routing-policy fields, so reuse the same + // builder as UPSERT against the stored attributes. + ResourceRecordSet: toRecordSet({ + name: output.name, + type: output.type, + ttl: output.ttl, + records: output.records, + aliasTarget: output.aliasTarget, + setIdentifier: output.setIdentifier, + weight: output.weight, + region: output.region, + failover: output.failover, + geoLocation: output.geoLocation, + geoProximityLocation: output.geoProximityLocation, + multiValueAnswer: output.multiValueAnswer, + cidrRoutingConfig: output.cidrRoutingConfig, + healthCheckId: output.healthCheckId, + }), }, ], }, diff --git a/packages/alchemy/src/AWS/Route53/index.ts b/packages/alchemy/src/AWS/Route53/index.ts index 34413e17a8..68bfd55966 100644 --- a/packages/alchemy/src/AWS/Route53/index.ts +++ b/packages/alchemy/src/AWS/Route53/index.ts @@ -1 +1,3 @@ +export { HealthCheck, HealthCheckProvider } from "./HealthCheck.ts"; +export { HostedZone, HostedZoneProvider } from "./HostedZone.ts"; export { Record, RecordProvider } from "./Record.ts"; diff --git a/packages/alchemy/src/AWS/S3/Bucket.ts b/packages/alchemy/src/AWS/S3/Bucket.ts index c273ca7639..a9a8d5fe26 100644 --- a/packages/alchemy/src/AWS/S3/Bucket.ts +++ b/packages/alchemy/src/AWS/S3/Bucket.ts @@ -22,6 +22,94 @@ import type { RegionID } from "../Region.ts"; export type BucketName = string; export type BucketArn = `arn:aws:s3:::${BucketName}`; +/** + * Server-side encryption configuration for a bucket. + */ +export interface BucketEncryption { + /** + * Server-side encryption algorithm to use for the default encryption. + */ + sseAlgorithm: "AES256" | "aws:kms" | "aws:kms:dsse"; + /** + * KMS key id (or ARN) to use when `sseAlgorithm` is `aws:kms` or + * `aws:kms:dsse`. Ignored for `AES256`. + */ + kmsMasterKeyId?: string; + /** + * Whether to use an S3 Bucket Key for SSE-KMS to reduce KMS request costs. + * @default false + */ + bucketKeyEnabled?: boolean; +} + +/** + * Public access block settings for a bucket. Each flag defaults to `false` + * (i.e. the corresponding public access is allowed) when omitted. + */ +export interface BucketPublicAccessBlock { + /** Block new public ACLs and uploading public objects. */ + blockPublicAcls?: boolean; + /** Ignore all public ACLs on the bucket and its objects. */ + ignorePublicAcls?: boolean; + /** Block new bucket policies that grant public access. */ + blockPublicPolicy?: boolean; + /** Restrict access granted by public bucket policies to AWS principals. */ + restrictPublicBuckets?: boolean; +} + +/** + * Access-logging configuration for a bucket. + */ +export interface BucketLogging { + /** Bucket that receives the access logs. */ + targetBucket: string; + /** Key prefix applied to log object names. */ + targetPrefix: string; + /** Optional grants giving accounts access to the log objects. */ + targetGrants?: s3.TargetGrant[]; + /** Optional log object key format (simple or partitioned prefix). */ + targetObjectKeyFormat?: s3.TargetObjectKeyFormat; +} + +/** + * Static-website hosting configuration for a bucket. + */ +export interface BucketWebsite { + /** Index document served for directory-style requests. */ + indexDocument?: { suffix: string }; + /** Document served for 4XX errors. */ + errorDocument?: { key: string }; + /** Redirect every request to another host instead of serving objects. */ + redirectAllRequestsTo?: { hostName: string; protocol?: "http" | "https" }; + /** Routing rules for conditional redirects. */ + routingRules?: s3.RoutingRule[]; +} + +/** + * Cross-region (or same-region) replication configuration for a bucket. + * Requires `versioning: "Enabled"` on the source bucket and an IAM role + * that S3 can assume to perform the replication. + */ +export interface BucketReplication { + /** ARN of the IAM role S3 assumes to replicate objects. */ + role: string; + /** Replication rules describing what to replicate and where. */ + rules: s3.ReplicationRule[]; +} + +/** + * Default object-lock retention applied to objects placed in a bucket that + * was created with `objectLockEnabled: true`. + */ +export interface BucketObjectLockConfiguration { + /** Retention mode. */ + mode: "GOVERNANCE" | "COMPLIANCE"; + /** Retention period in days (mutually exclusive with `years`). */ + days?: number; + /** Retention period in years (mutually exclusive with `days`). */ + years?: number; +} + export interface BucketProps { /** * Name of the bucket. If omitted, a unique name will be generated. @@ -42,6 +130,80 @@ export interface BucketProps { * Tags to apply to the bucket. */ tags?: Record; + /** + * Object versioning status. `"Enabled"` keeps every version of an object; + * `"Suspended"` stops accruing new versions (existing versions are kept). + */ + versioning?: "Enabled" | "Suspended"; + /** + * MFA-delete status. Rarely used — enabling it requires an MFA serial and + * the root account, so it cannot be toggled through normal credentials. + */ + mfaDelete?: "Enabled" | "Disabled"; + /** + * Default server-side encryption for objects written to the bucket. + */ + encryption?: BucketEncryption; + /** + * Block-public-access settings. Applied before any ACL or policy that + * grants public access. + */ + publicAccessBlock?: BucketPublicAccessBlock; + /** + * Cross-origin resource sharing (CORS) rules. + */ + cors?: s3.CORSRule[]; + /** + * Object lifecycle rules (expiration, transition, abort-incomplete-MPU…). + */ + lifecycleRules?: s3.LifecycleRule[]; + /** + * Object ownership control. `"BucketOwnerEnforced"` disables ACLs entirely. + */ + objectOwnership?: + | "BucketOwnerPreferred" + | "ObjectWriter" + | "BucketOwnerEnforced"; + /** + * Canned ACL to apply. Only valid when object ownership is not + * `BucketOwnerEnforced`. + */ + acl?: s3.BucketCannedACL; + /** + * Access-logging configuration. + */ + logging?: BucketLogging; + /** + * S3 Transfer Acceleration status. + */ + transferAcceleration?: "Enabled" | "Suspended"; + /** + * Who pays for requests and data transfer. `"Requester"` enables + * requester-pays. + */ + requestPayer?: "BucketOwner" | "Requester"; + /** + * Static-website hosting configuration. + */ + website?: BucketWebsite; + /** + * Replication configuration. Requires `versioning: "Enabled"` and an + * IAM role. + */ + replication?: BucketReplication; + /** + * S3 Intelligent-Tiering configurations (id-keyed). + */ + intelligentTiering?: s3.IntelligentTieringConfiguration[]; + /** + * Default object-lock retention. Requires `objectLockEnabled: true`. + */ + objectLockConfiguration?: BucketObjectLockConfiguration; + /** + * Explicit bucket policy as policy statements. Merged with any + * policy statements contributed via bindings. + */ + policy?: PolicyStatement[]; } export interface Bucket extends Resource< @@ -115,6 +277,60 @@ export interface Bucket extends Resource< * }); * ``` * + * @section Configuring a Bucket + * @example Versioning and encryption + * ```typescript + * const bucket = yield* S3.Bucket("my-bucket", { + * versioning: "Enabled", + * encryption: { sseAlgorithm: "AES256" }, + * }); + * ``` + * + * @example Block all public access + * ```typescript + * const bucket = yield* S3.Bucket("my-bucket", { + * publicAccessBlock: { + * blockPublicAcls: true, + * ignorePublicAcls: true, + * blockPublicPolicy: true, + * restrictPublicBuckets: true, + * }, + * }); + * ``` + * + * @example CORS and lifecycle rules + * ```typescript + * const bucket = yield* S3.Bucket("my-bucket", { + * cors: [ + * { + * AllowedMethods: ["GET"], + * AllowedOrigins: ["*"], + * AllowedHeaders: ["*"], + * MaxAgeSeconds: 3000, + * }, + * ], + * lifecycleRules: [ + * { + * ID: "expire-old", + * Status: "Enabled", + * Filter: { Prefix: "logs/" }, + * Expiration: { Days: 30 }, + * }, + * ], + * }); + * ``` + * + * @example Static website hosting + * ```typescript + * const bucket = yield* S3.Bucket("my-bucket", { + * objectOwnership: "BucketOwnerPreferred", + * website: { + * indexDocument: { suffix: "index.html" }, + * errorDocument: { key: "error.html" }, + * }, + * }); + * ``` + * * @section Runtime Operations * Bind S3 operations in the init phase and use them in runtime * handlers. Bindings inject the bucket name and grant scoped IAM @@ -421,17 +637,20 @@ export const BucketProvider = () => const syncBucketPolicy = Effect.fnUntraced(function* ({ bucketName, bindings, + explicitStatements, session, operation, }: { bucketName: string; session: ScopedPlanStatusSession; bindings: ResourceBinding[]; + explicitStatements?: PolicyStatement[]; operation: "create" | "update"; }) { - const policyStatements = bindings.flatMap( - (binding) => binding.data.policyStatements ?? [], - ); + const policyStatements = [ + ...(explicitStatements ?? []), + ...bindings.flatMap((binding) => binding.data.policyStatements ?? []), + ]; const desiredPolicy = policyStatements.length > 0 ? JSON.stringify({ @@ -548,6 +767,542 @@ export const BucketProvider = () => yield* session.note(`Updated bucket notifications: ${bucketName}`); }); + // ---- Bucket configuration sync helpers ------------------------------ + // Each helper observes the bucket's live cloud state, computes the + // desired state from `news`, early-returns on a no-op, and applies only + // the delta. The "not configured" read error for each aspect is already + // a typed tag in distilled (see processes/AWS/catalog/S3.md), so we + // `Effect.catchTag` it rather than inspecting status codes. + + const syncBucketVersioning = Effect.fnUntraced(function* ({ + bucketName, + versioning, + mfaDelete, + session, + }: { + bucketName: string; + versioning?: "Enabled" | "Suspended"; + mfaDelete?: "Enabled" | "Disabled"; + session: ScopedPlanStatusSession; + }) { + if (versioning === undefined && mfaDelete === undefined) return; + const current = yield* s3.getBucketVersioning({ Bucket: bucketName }); + const desiredStatus = versioning; + const desiredMfa = mfaDelete; + if ( + (desiredStatus === undefined || current.Status === desiredStatus) && + (desiredMfa === undefined || current.MFADelete === desiredMfa) + ) { + return; + } + yield* s3.putBucketVersioning({ + Bucket: bucketName, + VersioningConfiguration: { + Status: desiredStatus, + MFADelete: desiredMfa, + }, + }); + yield* session.note(`Updated bucket versioning: ${bucketName}`); + }); + + const syncBucketEncryption = Effect.fnUntraced(function* ({ + bucketName, + encryption, + session, + }: { + bucketName: string; + encryption?: BucketEncryption; + session: ScopedPlanStatusSession; + }) { + if (encryption === undefined) return; + const desiredRule: s3.ServerSideEncryptionRule = { + ApplyServerSideEncryptionByDefault: { + SSEAlgorithm: encryption.sseAlgorithm, + KMSMasterKeyID: encryption.kmsMasterKeyId, + }, + BucketKeyEnabled: encryption.bucketKeyEnabled ?? false, + }; + const current = yield* s3 + .getBucketEncryption({ Bucket: bucketName }) + .pipe( + Effect.map((r) => r.ServerSideEncryptionConfiguration?.Rules?.[0]), + // Some partitions return 404 with no default config; treat any + // not-configured read as "no rule" so we converge by writing. + Effect.catch(() => + Effect.succeed( + undefined, + ), + ), + ); + const canon = (r: s3.ServerSideEncryptionRule | undefined) => + JSON.stringify({ + alg: r?.ApplyServerSideEncryptionByDefault?.SSEAlgorithm ?? null, + key: r?.ApplyServerSideEncryptionByDefault?.KMSMasterKeyID ?? null, + bucketKey: r?.BucketKeyEnabled ?? false, + }); + if (canon(current) === canon(desiredRule)) return; + yield* s3.putBucketEncryption({ + Bucket: bucketName, + ServerSideEncryptionConfiguration: { Rules: [desiredRule] }, + }); + yield* session.note(`Updated bucket encryption: ${bucketName}`); + }); + + const syncPublicAccessBlock = Effect.fnUntraced(function* ({ + bucketName, + publicAccessBlock, + session, + }: { + bucketName: string; + publicAccessBlock?: BucketPublicAccessBlock; + session: ScopedPlanStatusSession; + }) { + if (publicAccessBlock === undefined) return; + const desired: s3.PublicAccessBlockConfiguration = { + BlockPublicAcls: publicAccessBlock.blockPublicAcls ?? false, + IgnorePublicAcls: publicAccessBlock.ignorePublicAcls ?? false, + BlockPublicPolicy: publicAccessBlock.blockPublicPolicy ?? false, + RestrictPublicBuckets: + publicAccessBlock.restrictPublicBuckets ?? false, + }; + const current = yield* s3 + .getPublicAccessBlock({ Bucket: bucketName }) + .pipe( + Effect.map((r) => r.PublicAccessBlockConfiguration), + Effect.catchTag("NoSuchPublicAccessBlockConfiguration", () => + Effect.succeed( + undefined, + ), + ), + ); + const canon = (c: s3.PublicAccessBlockConfiguration | undefined) => + JSON.stringify({ + a: c?.BlockPublicAcls ?? false, + i: c?.IgnorePublicAcls ?? false, + p: c?.BlockPublicPolicy ?? false, + r: c?.RestrictPublicBuckets ?? false, + }); + if (canon(current) === canon(desired)) return; + yield* s3.putPublicAccessBlock({ + Bucket: bucketName, + PublicAccessBlockConfiguration: desired, + }); + yield* session.note(`Updated public access block: ${bucketName}`); + }); + + const canonCors = (rules: readonly s3.CORSRule[]) => + JSON.stringify( + Arr.map(rules, (r) => ({ + // Drop S3-assigned `ID`s and sort member arrays so re-ordering + // doesn't read as drift. + headers: Arr.sort(r.AllowedHeaders ?? [], Order.String), + methods: Arr.sort(r.AllowedMethods ?? [], Order.String), + origins: Arr.sort(r.AllowedOrigins ?? [], Order.String), + expose: Arr.sort(r.ExposeHeaders ?? [], Order.String), + maxAge: r.MaxAgeSeconds ?? null, + })), + ); + + const syncBucketCors = Effect.fnUntraced(function* ({ + bucketName, + cors, + session, + }: { + bucketName: string; + cors?: s3.CORSRule[]; + session: ScopedPlanStatusSession; + }) { + if (cors === undefined) return; + const current = yield* s3.getBucketCors({ Bucket: bucketName }).pipe( + Effect.map((r) => r.CORSRules ?? []), + Effect.catchTag("NoSuchCORSConfiguration", () => + Effect.succeed([]), + ), + ); + if (cors.length === 0) { + if (current.length === 0) return; + yield* s3.deleteBucketCors({ Bucket: bucketName }); + yield* session.note(`Removed bucket CORS: ${bucketName}`); + return; + } + if (canonCors(current) === canonCors(cors)) return; + yield* s3.putBucketCors({ + Bucket: bucketName, + CORSConfiguration: { CORSRules: cors }, + }); + yield* session.note(`Updated bucket CORS: ${bucketName}`); + }); + + const canonLifecycle = (rules: readonly s3.LifecycleRule[]) => + JSON.stringify( + Arr.sort( + Arr.map(rules, (r) => ({ ...r })), + Order.mapInput(Order.String, (r: s3.LifecycleRule) => r.ID ?? ""), + ), + ); + + const syncBucketLifecycle = Effect.fnUntraced(function* ({ + bucketName, + lifecycleRules, + session, + }: { + bucketName: string; + lifecycleRules?: s3.LifecycleRule[]; + session: ScopedPlanStatusSession; + }) { + if (lifecycleRules === undefined) return; + const current = yield* s3 + .getBucketLifecycleConfiguration({ Bucket: bucketName }) + .pipe( + Effect.map((r) => r.Rules ?? []), + Effect.catchTag("NoSuchLifecycleConfiguration", () => + Effect.succeed([]), + ), + ); + if (lifecycleRules.length === 0) { + if (current.length === 0) return; + yield* s3.deleteBucketLifecycle({ Bucket: bucketName }); + yield* session.note(`Removed bucket lifecycle: ${bucketName}`); + return; + } + if (canonLifecycle(current) === canonLifecycle(lifecycleRules)) return; + yield* s3.putBucketLifecycleConfiguration({ + Bucket: bucketName, + LifecycleConfiguration: { Rules: lifecycleRules }, + }); + yield* session.note(`Updated bucket lifecycle: ${bucketName}`); + }); + + const syncBucketOwnershipControls = Effect.fnUntraced(function* ({ + bucketName, + objectOwnership, + session, + }: { + bucketName: string; + objectOwnership?: + | "BucketOwnerPreferred" + | "ObjectWriter" + | "BucketOwnerEnforced"; + session: ScopedPlanStatusSession; + }) { + if (objectOwnership === undefined) return; + const current = yield* s3 + .getBucketOwnershipControls({ Bucket: bucketName }) + .pipe( + Effect.map((r) => r.OwnershipControls?.Rules?.[0]?.ObjectOwnership), + Effect.catchTag("OwnershipControlsNotFoundError", () => + Effect.succeed(undefined), + ), + ); + if (current === objectOwnership) return; + yield* s3.putBucketOwnershipControls({ + Bucket: bucketName, + OwnershipControls: { + Rules: [{ ObjectOwnership: objectOwnership }], + }, + }); + yield* session.note(`Updated object ownership: ${bucketName}`); + }); + + const syncBucketAcl = Effect.fnUntraced(function* ({ + bucketName, + acl, + session, + }: { + bucketName: string; + acl?: s3.BucketCannedACL; + session: ScopedPlanStatusSession; + }) { + // S3 has no canned-ACL read, so we cannot diff; only apply when set. + // putBucketAcl is idempotent for canned ACLs. + if (acl === undefined) return; + yield* s3.putBucketAcl({ Bucket: bucketName, ACL: acl }); + yield* session.note(`Updated bucket ACL: ${bucketName}`); + }); + + const syncBucketLogging = Effect.fnUntraced(function* ({ + bucketName, + logging, + session, + }: { + bucketName: string; + logging?: BucketLogging; + session: ScopedPlanStatusSession; + }) { + if (logging === undefined) return; + const current = yield* s3 + .getBucketLogging({ Bucket: bucketName }) + .pipe(Effect.map((r) => r.LoggingEnabled)); + const desired: s3.LoggingEnabled = { + TargetBucket: logging.targetBucket, + TargetPrefix: logging.targetPrefix, + TargetGrants: logging.targetGrants, + TargetObjectKeyFormat: logging.targetObjectKeyFormat, + }; + if ( + current?.TargetBucket === desired.TargetBucket && + current?.TargetPrefix === desired.TargetPrefix && + JSON.stringify(current?.TargetGrants ?? null) === + JSON.stringify(desired.TargetGrants ?? null) + ) { + return; + } + yield* s3.putBucketLogging({ + Bucket: bucketName, + BucketLoggingStatus: { LoggingEnabled: desired }, + }); + yield* session.note(`Updated bucket logging: ${bucketName}`); + }); + + const syncTransferAcceleration = Effect.fnUntraced(function* ({ + bucketName, + transferAcceleration, + session, + }: { + bucketName: string; + transferAcceleration?: "Enabled" | "Suspended"; + session: ScopedPlanStatusSession; + }) { + if (transferAcceleration === undefined) return; + const current = yield* s3 + .getBucketAccelerateConfiguration({ Bucket: bucketName }) + .pipe(Effect.map((r) => r.Status)); + if (current === transferAcceleration) return; + yield* s3.putBucketAccelerateConfiguration({ + Bucket: bucketName, + AccelerateConfiguration: { Status: transferAcceleration }, + }); + yield* session.note(`Updated transfer acceleration: ${bucketName}`); + }); + + const syncRequestPayment = Effect.fnUntraced(function* ({ + bucketName, + requestPayer, + session, + }: { + bucketName: string; + requestPayer?: "BucketOwner" | "Requester"; + session: ScopedPlanStatusSession; + }) { + if (requestPayer === undefined) return; + const current = yield* s3 + .getBucketRequestPayment({ Bucket: bucketName }) + .pipe(Effect.map((r) => r.Payer)); + if (current === requestPayer) return; + yield* s3.putBucketRequestPayment({ + Bucket: bucketName, + RequestPaymentConfiguration: { Payer: requestPayer }, + }); + yield* session.note(`Updated request payment: ${bucketName}`); + }); + + const syncBucketWebsite = Effect.fnUntraced(function* ({ + bucketName, + website, + session, + }: { + bucketName: string; + website?: BucketWebsite; + session: ScopedPlanStatusSession; + }) { + if (website === undefined) return; + const current = yield* s3.getBucketWebsite({ Bucket: bucketName }).pipe( + Effect.map((r) => r as s3.GetBucketWebsiteOutput | undefined), + Effect.catchTag("NoSuchWebsiteConfiguration", () => + Effect.succeed(undefined), + ), + ); + const desired: s3.WebsiteConfiguration = { + IndexDocument: website.indexDocument + ? { Suffix: website.indexDocument.suffix } + : undefined, + ErrorDocument: website.errorDocument + ? { Key: website.errorDocument.key } + : undefined, + RedirectAllRequestsTo: website.redirectAllRequestsTo + ? { + HostName: website.redirectAllRequestsTo.hostName, + Protocol: website.redirectAllRequestsTo.protocol, + } + : undefined, + RoutingRules: website.routingRules, + }; + const canon = ( + w: s3.GetBucketWebsiteOutput | s3.WebsiteConfiguration | undefined, + ) => + JSON.stringify({ + index: w?.IndexDocument ?? null, + error: w?.ErrorDocument ?? null, + redirect: w?.RedirectAllRequestsTo ?? null, + routing: w?.RoutingRules ?? null, + }); + if (canon(current) === canon(desired)) return; + yield* s3.putBucketWebsite({ + Bucket: bucketName, + WebsiteConfiguration: desired, + }); + yield* session.note(`Updated bucket website: ${bucketName}`); + }); + + const canonReplication = ( + cfg: + | { Role?: string; Rules?: readonly s3.ReplicationRule[] } + | undefined, + ) => + JSON.stringify({ + role: cfg?.Role ?? null, + rules: Arr.sort( + Arr.map(cfg?.Rules ?? [], (r) => ({ ...r })), + Order.mapInput(Order.String, (r: s3.ReplicationRule) => r.ID ?? ""), + ), + }); + + const syncBucketReplication = Effect.fnUntraced(function* ({ + bucketName, + replication, + session, + }: { + bucketName: string; + replication?: BucketReplication; + session: ScopedPlanStatusSession; + }) { + if (replication === undefined) return; + const current = yield* s3 + .getBucketReplication({ Bucket: bucketName }) + .pipe( + Effect.map((r) => r.ReplicationConfiguration), + Effect.catchTag("ReplicationConfigurationNotFoundError", () => + Effect.succeed( + undefined, + ), + ), + ); + const desired: s3.ReplicationConfiguration = { + Role: replication.role, + Rules: replication.rules, + }; + if (canonReplication(current) === canonReplication(desired)) return; + yield* s3.putBucketReplication({ + Bucket: bucketName, + ReplicationConfiguration: desired, + }); + yield* session.note(`Updated bucket replication: ${bucketName}`); + }); + + const syncIntelligentTiering = Effect.fnUntraced(function* ({ + bucketName, + intelligentTiering, + oldIntelligentTiering, + session, + }: { + bucketName: string; + intelligentTiering?: s3.IntelligentTieringConfiguration[]; + oldIntelligentTiering?: s3.IntelligentTieringConfiguration[]; + session: ScopedPlanStatusSession; + }) { + if (intelligentTiering === undefined) return; + const desiredById = new Map( + Arr.map(intelligentTiering, (c) => [c.Id, c] as const), + ); + // Reconcile each desired id: put when missing or changed (observed + // against the per-id read, which is read-after-write consistent). + for (const [id, desired] of desiredById) { + const current = yield* s3 + .getBucketIntelligentTieringConfiguration({ + Bucket: bucketName, + Id: id, + }) + .pipe( + Effect.map((r) => r.IntelligentTieringConfiguration), + Effect.catchTag("NoSuchConfiguration", () => + Effect.succeed( + undefined, + ), + ), + ); + if (JSON.stringify(current) === JSON.stringify(desired)) continue; + yield* s3.putBucketIntelligentTieringConfiguration({ + Bucket: bucketName, + Id: id, + IntelligentTieringConfiguration: desired, + }); + yield* session.note( + `Updated intelligent-tiering ${id}: ${bucketName}`, + ); + } + // Remove ids that were previously declared but are no longer desired. + // `list` is eventually-consistent, so diff against the prior props + // (when available) and fall back to listing for adoption. Each delete + // tolerates a config that is already gone. + const removedIds = new Set(); + for (const old of oldIntelligentTiering ?? []) { + if (old.Id && !desiredById.has(old.Id)) removedIds.add(old.Id); + } + if (oldIntelligentTiering === undefined) { + const observed = yield* s3 + .listBucketIntelligentTieringConfigurations({ Bucket: bucketName }) + .pipe( + Effect.map((r) => r.IntelligentTieringConfigurationList ?? []), + ); + for (const cfg of observed) { + if (cfg.Id && !desiredById.has(cfg.Id)) removedIds.add(cfg.Id); + } + } + for (const id of removedIds) { + // delete is idempotent server-side (no NoSuchConfiguration in the + // typed error union), so a missing id is simply a no-op. + yield* s3.deleteBucketIntelligentTieringConfiguration({ + Bucket: bucketName, + Id: id, + }); + yield* session.note( + `Removed intelligent-tiering ${id}: ${bucketName}`, + ); + } + }); + + const syncObjectLockRetention = Effect.fnUntraced(function* ({ + bucketName, + objectLockConfiguration, + session, + }: { + bucketName: string; + objectLockConfiguration?: BucketObjectLockConfiguration; + session: ScopedPlanStatusSession; + }) { + if (objectLockConfiguration === undefined) return; + const current = yield* s3 + .getObjectLockConfiguration({ Bucket: bucketName }) + .pipe( + Effect.map( + (r) => r.ObjectLockConfiguration?.Rule?.DefaultRetention, + ), + Effect.catchTag("ObjectLockConfigurationNotFoundError", () => + Effect.succeed(undefined), + ), + ); + const desired: s3.DefaultRetention = { + Mode: objectLockConfiguration.mode, + Days: objectLockConfiguration.days, + Years: objectLockConfiguration.years, + }; + const canon = (r: s3.DefaultRetention | undefined) => + JSON.stringify({ + mode: r?.Mode ?? null, + days: r?.Days ?? null, + years: r?.Years ?? null, + }); + if (canon(current) === canon(desired)) return; + yield* s3.putObjectLockConfiguration({ + Bucket: bucketName, + ObjectLockConfiguration: { + ObjectLockEnabled: "Enabled", + Rule: { DefaultRetention: desired }, + }, + }); + yield* session.note(`Updated object-lock retention: ${bucketName}`); + }); + return { stables: ["bucketName", "bucketArn", "region", "accountId"], // S3 bucket names are globally unique. `headBucket` succeeds only when @@ -626,6 +1381,7 @@ export const BucketProvider = () => reconcile: Effect.fn(function* ({ id, news = {}, + olds, output, session, bindings, @@ -643,9 +1399,100 @@ export const BucketProvider = () => operation, }); + // Ownership + public-access-block must precede any ACL/policy that + // grants public access, else those puts can fail with AccessDenied. + yield* syncBucketOwnershipControls({ + bucketName: resolved.bucketName, + objectOwnership: news.objectOwnership, + session, + }); + + yield* syncPublicAccessBlock({ + bucketName: resolved.bucketName, + publicAccessBlock: news.publicAccessBlock, + session, + }); + + // Versioning before replication (replication requires it enabled). + yield* syncBucketVersioning({ + bucketName: resolved.bucketName, + versioning: news.versioning, + mfaDelete: news.mfaDelete, + session, + }); + + yield* syncBucketEncryption({ + bucketName: resolved.bucketName, + encryption: news.encryption, + session, + }); + + yield* syncBucketCors({ + bucketName: resolved.bucketName, + cors: news.cors, + session, + }); + + yield* syncBucketLifecycle({ + bucketName: resolved.bucketName, + lifecycleRules: news.lifecycleRules, + session, + }); + + yield* syncBucketLogging({ + bucketName: resolved.bucketName, + logging: news.logging, + session, + }); + + yield* syncTransferAcceleration({ + bucketName: resolved.bucketName, + transferAcceleration: news.transferAcceleration, + session, + }); + + yield* syncRequestPayment({ + bucketName: resolved.bucketName, + requestPayer: news.requestPayer, + session, + }); + + yield* syncBucketWebsite({ + bucketName: resolved.bucketName, + website: news.website, + session, + }); + + yield* syncBucketReplication({ + bucketName: resolved.bucketName, + replication: news.replication, + session, + }); + + yield* syncIntelligentTiering({ + bucketName: resolved.bucketName, + intelligentTiering: news.intelligentTiering, + oldIntelligentTiering: olds?.intelligentTiering, + session, + }); + + yield* syncObjectLockRetention({ + bucketName: resolved.bucketName, + objectLockConfiguration: news.objectLockConfiguration, + session, + }); + + // ACL after ownership/public-access-block. + yield* syncBucketAcl({ + bucketName: resolved.bucketName, + acl: news.acl, + session, + }); + yield* syncBucketPolicy({ bucketName: resolved.bucketName, bindings, + explicitStatements: news.policy, session, operation, }); diff --git a/packages/alchemy/src/AWS/SQS/Queue.ts b/packages/alchemy/src/AWS/SQS/Queue.ts index fdc044e3a1..715e0ff23a 100644 --- a/packages/alchemy/src/AWS/SQS/Queue.ts +++ b/packages/alchemy/src/AWS/SQS/Queue.ts @@ -1,4 +1,5 @@ import * as sqs from "@distilled.cloud/aws/sqs"; +import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; @@ -7,7 +8,7 @@ import { isResolved } from "../../Diff.ts"; import { createPhysicalName } from "../../PhysicalName.ts"; import * as Provider from "../../Provider.ts"; import { Resource, type ResourceBinding } from "../../Resource.ts"; -import { createInternalTags, hasAlchemyTags } from "../../Tags.ts"; +import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts"; import { AWSEnvironment, type AccountID } from "../Environment.ts"; import type { PolicyStatement } from "../IAM/Policy.ts"; import type { Providers } from "../Providers.ts"; @@ -48,6 +49,69 @@ export type QueueProps = { * @default 30 */ visibilityTimeout?: number; + /** + * Dead-letter queue redrive policy. Failed messages are moved to the + * dead-letter queue after `maxReceiveCount` receive attempts. The + * dead-letter queue must be the same type (a FIFO source requires a + * FIFO dead-letter queue). + */ + redrivePolicy?: { + /** + * The ARN of the dead-letter queue that failed messages are moved to. + */ + deadLetterTargetArn: string; + /** + * The number of times a message is received before it is moved to the + * dead-letter queue (`1` - `1000`). + */ + maxReceiveCount: number; + }; + /** + * Redrive-allow policy. Set on the **dead-letter queue** to authorize + * which source queues may use it. + */ + redriveAllowPolicy?: { + /** + * Whether all, none, or a specified list of source queues may use this + * queue as a dead-letter queue. + */ + redrivePermission: "allowAll" | "denyAll" | "byQueue"; + /** + * The ARNs of the source queues permitted to use this dead-letter + * queue. Only valid (and required) when `redrivePermission` is + * `byQueue` (up to 10 ARNs). + */ + sourceQueueArns?: string[]; + }; + /** + * An access-control policy document (IAM policy JSON) attached to the + * queue. Provided as a JSON string or a plain object. Merged with any + * policy statements contributed by capability bindings. + */ + policy?: string | Record; + /** + * The ID, alias, or ARN of a KMS key for server-side encryption (SSE-KMS). + * Use `alias/aws/sqs` for the AWS-managed SQS key. Mutually exclusive with + * `sqsManagedSseEnabled`. + */ + kmsMasterKeyId?: string; + /** + * The length of time, in seconds, that SQS reuses a data key before + * calling KMS again (`60` - `86,400`). Only meaningful with + * `kmsMasterKeyId`. + * @default 300 + */ + kmsDataKeyReusePeriodSeconds?: number; + /** + * Enables server-side encryption using SQS-owned keys (SSE-SQS). + * Mutually exclusive with `kmsMasterKeyId`. + * @default false + */ + sqsManagedSseEnabled?: boolean; + /** + * Tags to apply to the queue. Merged with internal Alchemy tags. + */ + tags?: Record; } & ( | { fifo?: false; @@ -121,6 +185,44 @@ export interface Queue extends Resource< * }); * ``` * + * @section Dead-Letter Queues + * @example Route failures to a dead-letter queue + * ```typescript + * const dlq = yield* SQS.Queue("OrdersDLQ"); + * const orders = yield* SQS.Queue("Orders", { + * redrivePolicy: { + * deadLetterTargetArn: dlq.queueArn, + * maxReceiveCount: 3, + * }, + * }); + * ``` + * + * @example Authorize source queues on the dead-letter queue + * ```typescript + * const dlq = yield* SQS.Queue("OrdersDLQ", { + * redriveAllowPolicy: { + * redrivePermission: "byQueue", + * sourceQueueArns: [orders.queueArn], + * }, + * }); + * ``` + * + * @section Encryption + * @example SSE-SQS (SQS-managed keys) + * ```typescript + * const queue = yield* SQS.Queue("SecureQueue", { + * sqsManagedSseEnabled: true, + * }); + * ``` + * + * @example SSE-KMS (AWS-managed key) + * ```typescript + * const queue = yield* SQS.Queue("KmsQueue", { + * kmsMasterKeyId: "alias/aws/sqs", + * kmsDataKeyReusePeriodSeconds: 300, + * }); + * ``` + * * @section Sending Messages * Bind send operations in the init phase and use them in runtime * handlers. @@ -157,6 +259,25 @@ export interface Queue extends Resource< */ export const Queue = Resource("AWS.SQS.Queue"); +/** + * Raised when a `Queue` is configured with both `kmsMasterKeyId` (SSE-KMS) + * and `sqsManagedSseEnabled` (SSE-SQS). The two encryption modes are + * mutually exclusive. + */ +export class SqsEncryptionConflict extends Data.TaggedError( + "SqsEncryptionConflict", +)<{ message: string }> {} + +const validateEncryption = (props: QueueProps) => + props.kmsMasterKeyId !== undefined && props.sqsManagedSseEnabled + ? Effect.fail( + new SqsEncryptionConflict({ + message: + "kmsMasterKeyId (SSE-KMS) and sqsManagedSseEnabled (SSE-SQS) are mutually exclusive — set only one.", + }), + ) + : Effect.void; + export const QueueProvider = () => Provider.effect( Queue, @@ -177,10 +298,56 @@ export const QueueProvider = () => }); return props.fifo ? `${baseName}.fifo` : baseName; }); + const buildPolicy = ( + props: QueueProps, + bindings: ResourceBinding[], + ): string | undefined => { + const bindingStatements = bindings.flatMap( + (p) => p.data.policyStatements, + ); + let userStatements: any[] = []; + if (props.policy !== undefined) { + const doc = + typeof props.policy === "string" + ? JSON.parse(props.policy) + : props.policy; + const stmt = doc?.Statement; + userStatements = Array.isArray(stmt) ? stmt : stmt ? [stmt] : []; + } + const statements = [...userStatements, ...bindingStatements]; + if (statements.length === 0) return undefined; + return JSON.stringify({ + Version: "2012-10-17", + Statement: statements, + }); + }; + // Build the desired attribute map. Keys present here are reconciled + // against observed cloud state. An empty-string value explicitly + // CLEARS an attribute (SQS interprets `""` as "remove"); `undefined` + // means "leave alone" and is filtered out before diffing. const createAttributes = ( props: QueueProps, bindings: ResourceBinding[], ) => { + // Removable attributes always emit a key: the desired value when set, + // or "" to clear when the prop is absent. This lets the delta loop + // converge when a user removes redrive/policy/kms props. + const redrivePolicy = props.redrivePolicy + ? JSON.stringify({ + deadLetterTargetArn: props.redrivePolicy.deadLetterTargetArn, + maxReceiveCount: props.redrivePolicy.maxReceiveCount, + }) + : ""; + const redriveAllowPolicy = props.redriveAllowPolicy + ? JSON.stringify({ + redrivePermission: props.redriveAllowPolicy.redrivePermission, + ...(props.redriveAllowPolicy.sourceQueueArns + ? { sourceQueueArns: props.redriveAllowPolicy.sourceQueueArns } + : {}), + }) + : ""; + const policy = buildPolicy(props, bindings) ?? ""; + const baseAttributes: Record = { DelaySeconds: props.delaySeconds?.toString(), MaximumMessageSize: props.maximumMessageSize?.toString(), @@ -188,13 +355,18 @@ export const QueueProvider = () => ReceiveMessageWaitTimeSeconds: props.receiveMessageWaitTimeSeconds?.toString(), VisibilityTimeout: props.visibilityTimeout?.toString(), - Policy: - bindings.length > 0 - ? JSON.stringify({ - Version: "2012-10-17", - Statement: bindings.flatMap((p) => p.data.policyStatements), - }) - : undefined, + RedrivePolicy: redrivePolicy, + RedriveAllowPolicy: redriveAllowPolicy, + Policy: policy, + KmsMasterKeyId: props.kmsMasterKeyId, + KmsDataKeyReusePeriodSeconds: + props.kmsDataKeyReusePeriodSeconds?.toString(), + SqsManagedSseEnabled: + props.sqsManagedSseEnabled === undefined + ? undefined + : props.sqsManagedSseEnabled + ? "true" + : "false", }; if (props.fifo) { @@ -279,6 +451,7 @@ export const QueueProvider = () => }), diff: Effect.fn(function* ({ id, news = {}, olds = {} }) { if (!isResolved(news)) return undefined; + yield* validateEncryption(news); const oldFifo = olds.fifo ?? false; const newFifo = news.fifo ?? false; if (oldFifo !== newFifo) { @@ -298,6 +471,7 @@ export const QueueProvider = () => session, bindings, }) { + yield* validateEncryption(news); const { accountId, region } = yield* AWSEnvironment.current; const queueName = output?.queueName ?? (yield* createQueueName(id, news)); @@ -325,11 +499,19 @@ export const QueueProvider = () => // params it raises `QueueNameExists`. We pass the desired attrs so // first-create lands fully configured, and tolerate the race where // a peer reconciler created it concurrently. + // SQS rejects empty-string attribute values on create (they're + // only meaningful as a "clear" signal during update), so drop + // any empty-string desired attrs from the initial create. + const createAttrs: Record = {}; + for (const [key, value] of Object.entries(desiredAttributes)) { + if (value === undefined || value === "") continue; + createAttrs[key] = value; + } queueUrl = yield* sqs .createQueue({ QueueName: queueName, - Attributes: desiredAttributes, - tags: internalTags, + Attributes: createAttrs, + tags: { ...internalTags, ...(news.tags ?? {}) }, }) .pipe( Effect.retry({ @@ -375,9 +557,14 @@ export const QueueProvider = () => const attributeDelta: Record = {}; for (const [key, value] of Object.entries(desiredAttributes)) { if (value === undefined) continue; - if ( - currentAttributes[key as keyof typeof currentAttributes] !== value - ) { + const current = + currentAttributes[key as keyof typeof currentAttributes]; + // Desired-to-clear ("") only needs an API call when the attribute + // is actually present; SQS rejects clearing an already-absent attr. + if (value === "" && (current === undefined || current === "")) { + continue; + } + if (current !== value) { attributeDelta[key] = value; } } @@ -412,21 +599,44 @@ export const QueueProvider = () => Effect.map((r) => r.Tags ?? {}), Effect.catch(() => Effect.succeed({} as Record)), ); - const tagDelta: Record = {}; - for (const [key, value] of Object.entries(internalTags)) { - if (currentTags[key] !== value) { - tagDelta[key] = value; - } + // Merge user tags with internal Alchemy tags and diff against the + // OBSERVED cloud tags (not olds) so adoption converges. User tags + // can be removed, so we untag removed keys; internal tags are never + // user-removable so they survive. + const desiredTags: Record = { + ...(news.tags ?? {}), + ...internalTags, + }; + const { upsert, removed } = diffTags( + currentTags as Record, + desiredTags, + ); + if (upsert.length > 0) { + yield* sqs + .tagQueue({ + QueueUrl: queueUrl, + Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])), + }) + .pipe( + Effect.retry({ + while: (e) => e._tag === "QueueDoesNotExist", + schedule: Schedule.fixed(1000).pipe( + Schedule.both(Schedule.recurs(30)), + ), + }), + ); } - if (Object.keys(tagDelta).length > 0) { - yield* sqs.tagQueue({ QueueUrl: queueUrl, Tags: tagDelta }).pipe( - Effect.retry({ - while: (e) => e._tag === "QueueDoesNotExist", - schedule: Schedule.fixed(1000).pipe( - Schedule.both(Schedule.recurs(30)), - ), - }), - ); + if (removed.length > 0) { + yield* sqs + .untagQueue({ QueueUrl: queueUrl, TagKeys: removed }) + .pipe( + Effect.retry({ + while: (e) => e._tag === "QueueDoesNotExist", + schedule: Schedule.fixed(1000).pipe( + Schedule.both(Schedule.recurs(30)), + ), + }), + ); } yield* session.note(queueUrl); diff --git a/packages/alchemy/test/AWS/CloudFront/Distribution.test.ts b/packages/alchemy/test/AWS/CloudFront/Distribution.test.ts index 6092459c86..f4f6c07498 100644 --- a/packages/alchemy/test/AWS/CloudFront/Distribution.test.ts +++ b/packages/alchemy/test/AWS/CloudFront/Distribution.test.ts @@ -178,6 +178,106 @@ describe("AWS.CloudFront.Distribution", () => { }), { timeout: 600_000 }, ); + // Exercises the newly-exposed config gaps: geo restriction + custom error + // responses. Creates with a whitelist + a custom 404, updates the geo + // restriction to `none`, and asserts both round-trip via getDistributionConfig. + test.provider.skipIf(!runLive)( + "geo restriction and custom error responses round-trip", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const deployed = yield* stack.deploy( + Effect.gen(function* () { + const bucket = yield* Bucket("GeoBucket", { forceDestroy: true }); + const oac = yield* OriginAccessControl("GeoOac", { + originType: "s3", + }); + const distribution = yield* Distribution("GeoDistribution", { + origins: [ + { + id: "site", + domainName: bucket.bucketRegionalDomainName, + s3Origin: true, + originAccessControlId: oac.originAccessControlId, + }, + ], + defaultCacheBehavior: { + targetOriginId: "site", + viewerProtocolPolicy: "redirect-to-https", + compress: true, + }, + geoRestriction: { + restrictionType: "whitelist", + locations: ["US", "CA"], + }, + customErrorResponses: [ + { + ErrorCode: 404, + ResponseCode: "404", + ResponsePagePath: "/404.html", + ErrorCachingMinTTL: 10, + }, + ], + }); + return { distribution }; + }), + ); + + const created = yield* cloudfront.getDistributionConfig({ + Id: deployed.distribution.distributionId, + }); + expect( + created.DistributionConfig?.Restrictions?.GeoRestriction + .RestrictionType, + ).toEqual("whitelist"); + expect( + created.DistributionConfig?.Restrictions?.GeoRestriction.Items?.sort(), + ).toEqual(["CA", "US"]); + expect( + created.DistributionConfig?.CustomErrorResponses?.Items?.[0] + .ErrorCode, + ).toEqual(404); + + // Update: drop the geo restriction. + yield* stack.deploy( + Effect.gen(function* () { + const bucket = yield* Bucket("GeoBucket", { forceDestroy: true }); + const oac = yield* OriginAccessControl("GeoOac", { + originType: "s3", + }); + return yield* Distribution("GeoDistribution", { + origins: [ + { + id: "site", + domainName: bucket.bucketRegionalDomainName, + s3Origin: true, + originAccessControlId: oac.originAccessControlId, + }, + ], + defaultCacheBehavior: { + targetOriginId: "site", + viewerProtocolPolicy: "redirect-to-https", + compress: true, + }, + geoRestriction: { restrictionType: "none" }, + }); + }), + ); + + const updated = yield* cloudfront.getDistributionConfig({ + Id: deployed.distribution.distributionId, + }); + expect( + updated.DistributionConfig?.Restrictions?.GeoRestriction + .RestrictionType, + ).toEqual("none"); + + yield* stack.destroy(); + yield* assertDistributionDeleted(deployed.distribution.distributionId); + }), + { timeout: 600_000 }, + ); }); const assertDistributionDeleted = (distributionId: string) => diff --git a/packages/alchemy/test/AWS/CloudFront/VpcOrigin.test.ts b/packages/alchemy/test/AWS/CloudFront/VpcOrigin.test.ts new file mode 100644 index 0000000000..c1c5662ebf --- /dev/null +++ b/packages/alchemy/test/AWS/CloudFront/VpcOrigin.test.ts @@ -0,0 +1,148 @@ +import * as AWS from "@/AWS"; +import { VpcOrigin } from "@/AWS/CloudFront"; +import { Network } from "@/AWS/EC2/Network"; +import { SecurityGroup } from "@/AWS/EC2/SecurityGroup"; +import { LoadBalancer } from "@/AWS/ELBv2/LoadBalancer"; +import * as Provider from "@/Provider"; +import * as Test from "@/Test/Vitest"; +import * as cloudfront from "@distilled.cloud/aws/cloudfront"; +import { describe, expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; + +const { test } = Test.make({ providers: AWS.providers() }); + +// The full lifecycle provisions a real internal ALB and a CloudFront VPC +// origin. CloudFront VPC-origin deploy + delete is very slow (global +// propagation, ~20-25 min for create alone), so the whole create -> Deployed +// -> delete -> gone cycle runs ~35-40 min end to end. It is gated behind an +// env var and given a generous timeout; the probe + list below run cheaply and +// cover the wiring + typed-error surface in CI. +const runLifecycle = process.env.CLOUDFRONT_TEST_VPC_ORIGIN === "1"; + +describe("AWS.CloudFront.VpcOrigin", () => { + // Fast probe (no deploy): creating a VPC origin against a bogus ARN must + // surface a typed `InvalidArgument` (or `EntityNotFound`), proving the error + // typing for the create op without provisioning any infrastructure. + test.provider("createVpcOrigin rejects a bogus ARN with a typed error", () => + Effect.gen(function* () { + const result = yield* cloudfront + .createVpcOrigin({ + VpcOriginEndpointConfig: { + Name: "alchemy-vpc-origin-probe", + Arn: "arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/does-not-exist/0000000000000000", + HTTPPort: 80, + HTTPSPort: 443, + OriginProtocolPolicy: "https-only", + }, + }) + .pipe(Effect.flip); + + expect(["InvalidArgument", "EntityNotFound", "AccessDenied"]).toContain( + result._tag, + ); + }), + ); + + test.provider.skipIf(!runLifecycle)( + "create, update, and delete a VPC origin for an internal ALB", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + // CloudFront VPC origins require the target's VPC to have an internet + // gateway attached. `Network` provisions a production-shaped VPC (VPC + + // attached IGW + public/private subnets across 2 AZs + route tables), so + // the networking + ALB is deployed in a first phase, then the VPC origin + // in a second — the IGW must be attached before `createVpcOrigin` runs. + const network = Effect.gen(function* () { + const net = yield* Network("VpcOriginNet", { + cidrBlock: "10.40.0.0/16", + }); + const sg = yield* SecurityGroup("VpcOriginSg", { + vpcId: net.vpcId, + description: "alchemy vpc origin alb", + ingress: [ + { + ipProtocol: "tcp", + fromPort: 80, + toPort: 80, + cidrIpv4: "0.0.0.0/0", + }, + ], + }); + const alb = yield* LoadBalancer("VpcOriginAlb", { + scheme: "internal", + type: "application", + subnets: net.publicSubnetIds, + securityGroups: [sg.groupId], + }); + return { albArn: alb.loadBalancerArn }; + }); + + // Phase 1: networking (incl. attached IGW) + ALB. + yield* stack.deploy(network); + + // Phase 2: the VPC origin, now that the IGW is attached. + const deployed = yield* stack.deploy( + Effect.gen(function* () { + const { albArn } = yield* network; + const vpcOrigin = yield* VpcOrigin("AppVpcOrigin", { + arn: albArn, + httpPort: 80, + originProtocolPolicy: "http-only", + }); + return { vpcOrigin }; + }), + ); + + // Out-of-band: confirm it deployed. + const got = yield* cloudfront.getVpcOrigin({ + Id: deployed.vpcOrigin.vpcOriginId, + }); + expect(got.VpcOrigin?.Status).toEqual("Deployed"); + expect(got.VpcOrigin?.VpcOriginEndpointConfig.HTTPPort).toEqual(80); + expect( + got.VpcOrigin?.VpcOriginEndpointConfig.OriginProtocolPolicy, + ).toEqual("http-only"); + + yield* stack.destroy(); + yield* assertVpcOriginDeleted(deployed.vpcOrigin.vpcOriginId); + }), + // CloudFront VPC origin deploy + delete each take many minutes (global + // propagation), on top of the ALB/VPC provisioning and teardown — budget + // 45 min for the full create -> Deployed -> delete -> gone cycle. + { timeout: 2_700_000 }, + ); + + test.provider.skipIf(!runLifecycle)( + "list enumerates account VPC origins", + () => + Effect.gen(function* () { + const provider = yield* Provider.findProvider(VpcOrigin); + const all = yield* provider.list(); + expect(Array.isArray(all)).toBe(true); + for (const item of all) { + expect(item.vpcOriginId).toBeDefined(); + expect(item.vpcOriginArn).toBeDefined(); + } + }), + ); +}); + +const assertVpcOriginDeleted = (id: string) => + cloudfront.getVpcOrigin({ Id: id }).pipe( + Effect.flatMap((result) => + result.VpcOrigin + ? Effect.fail(new Error("VpcOriginStillExists")) + : Effect.void, + ), + Effect.catchTag("EntityNotFound", () => Effect.void), + Effect.retry({ + while: (error) => + error instanceof Error && error.message === "VpcOriginStillExists", + schedule: Schedule.fixed("10 seconds").pipe( + Schedule.both(Schedule.recurs(30)), + ), + }), + ); diff --git a/packages/alchemy/test/AWS/ECS/Service.test.ts b/packages/alchemy/test/AWS/ECS/Service.test.ts index 1466a31008..cb0fa3ed2b 100644 --- a/packages/alchemy/test/AWS/ECS/Service.test.ts +++ b/packages/alchemy/test/AWS/ECS/Service.test.ts @@ -6,8 +6,10 @@ import { Service } from "@/AWS/ECS/Service.ts"; import * as Provider from "@/Provider"; import * as Test from "@/Test/Vitest"; import * as ecs from "@distilled.cloud/aws/ecs"; +import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2"; import { expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; const { test } = Test.make({ providers: AWS.providers() }); @@ -49,6 +51,13 @@ test.provider("list enumerates the deployed service", (stack) => new Error("registerTaskDefinition returned no task definition ARN"), ); } + // Safety net: deregister the out-of-band task definition on scope close even + // if the body fails — leaves it INACTIVE rather than orphaned as ACTIVE. + yield* Effect.addFinalizer(() => + ecs + .deregisterTaskDefinition({ taskDefinition: taskDefinitionArn }) + .pipe(Effect.ignore), + ); const service = yield* stack.deploy( Effect.gen(function* () { @@ -91,3 +100,343 @@ test.provider("list enumerates the deployed service", (stack) => .pipe(Effect.catchTag("ClientException", () => Effect.void)); }), ); + +// In-place reconcile coverage: create a service at desiredCount 0 (so +// createService returns immediately without waiting on Fargate placement), +// then update the network configuration (assignPublicIp), desiredCount, the +// deployment circuit breaker, and tags — and assert the service was updated in +// place (SAME serviceArn, no replacement). This exercises the per-aspect +// updateService + tag-sync path and the narrowed `diff` (these fields must NOT +// force a replace). +test.provider( + "service applies network / desiredCount / deployment / tag changes in place", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const registered = yield* ecs.registerTaskDefinition({ + family: "alchemy-test-ecs-service-inplace", + networkMode: "awsvpc", + requiresCompatibilities: ["FARGATE"], + cpu: "256", + memory: "512", + containerDefinitions: [ + { + name: "app", + image: "public.ecr.aws/nginx/nginx:stable", + essential: true, + portMappings: [{ containerPort: 80, protocol: "tcp" }], + }, + ], + }); + const taskDefinitionArn = registered.taskDefinition?.taskDefinitionArn!; + // Safety net: deregister the out-of-band task definition on scope close. + yield* Effect.addFinalizer(() => + ecs + .deregisterTaskDefinition({ taskDefinition: taskDefinitionArn }) + .pipe(Effect.ignore), + ); + + const deployService = (props: { + desiredCount: number; + assignPublicIp: boolean; + tags: Record; + circuitBreaker: boolean; + }) => + stack.deploy( + Effect.gen(function* () { + const vpc = yield* Vpc("InPlaceVpc", { cidrBlock: "10.72.0.0/16" }); + const subnet = yield* Subnet("InPlaceSubnet", { + vpcId: vpc.vpcId, + cidrBlock: "10.72.1.0/24", + }); + const cluster = yield* Cluster("InPlaceCluster", { + clusterName: "alchemy-test-ecs-service-inplace", + }); + return yield* Service("InPlaceService", { + cluster, + task: { taskDefinitionArn, containerName: "app", port: 80 }, + desiredCount: props.desiredCount, + vpcId: vpc.vpcId, + subnets: [subnet.subnetId], + assignPublicIp: props.assignPublicIp, + tags: props.tags, + deploymentConfiguration: { + minimumHealthyPercent: 100, + maximumPercent: 200, + deploymentCircuitBreaker: { + enable: props.circuitBreaker, + rollback: props.circuitBreaker, + }, + }, + }); + }), + ); + + const created = yield* deployService({ + desiredCount: 0, + assignPublicIp: false, + tags: { env: "test", keep: "v1" }, + circuitBreaker: false, + }); + + const updated = yield* deployService({ + desiredCount: 0, + assignPublicIp: true, + tags: { env: "test", added: "new" }, + circuitBreaker: true, + }); + + // No replacement — identity is stable. + expect(updated.serviceArn).toEqual(created.serviceArn); + + // Verify out-of-band that updateService applied the changes. + const described = yield* ecs.describeServices({ + cluster: updated.clusterArn, + services: [updated.serviceName], + include: ["TAGS"], + }); + const svc = described.services?.[0]; + expect(svc?.serviceArn).toEqual(created.serviceArn); + expect( + svc?.deploymentConfiguration?.deploymentCircuitBreaker?.enable, + ).toBe(true); + expect( + svc?.networkConfiguration?.awsvpcConfiguration?.assignPublicIp, + ).toBe("ENABLED"); + + // Tag reconcile: `keep` removed, `added` present, `env` retained. + const tagMap = Object.fromEntries( + (svc?.tags ?? []).map((t) => [t.key, t.value]), + ); + expect(tagMap.added).toBe("new"); + expect(tagMap.env).toBe("test"); + expect(tagMap.keep).toBeUndefined(); + + yield* stack.destroy(); + yield* ecs + .deregisterTaskDefinition({ taskDefinition: taskDefinitionArn }) + .pipe(Effect.catchTag("ClientException", () => Effect.void)); + }), + { timeout: 240_000 }, +); + +// Manual (user-supplied) load balancer: create an ALB + target group OUT OF +// BAND, pass it explicitly via `loadBalancers` with `public: false`, and assert +// (a) no Alchemy-managed ALB was created (no `url`/`loadBalancerArn` on the +// attributes) and (b) the service is wired to the supplied target group. +// +// ECS `createService` rejects a target group that is not associated with a load +// balancer (`InvalidParameterException`), so we provision a real (but minimal) +// ALB + listener + target group out of band across two AZ subnets, wire the +// service to that target group with `public: false`, and assert no +// Alchemy-managed ALB was created. +test.provider( + "service wires a user-supplied target group without creating an ALB", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const registered = yield* ecs.registerTaskDefinition({ + family: "alchemy-test-ecs-service-manuallb", + networkMode: "awsvpc", + requiresCompatibilities: ["FARGATE"], + cpu: "256", + memory: "512", + containerDefinitions: [ + { + name: "app", + image: "public.ecr.aws/nginx/nginx:stable", + essential: true, + portMappings: [{ containerPort: 80, protocol: "tcp" }], + }, + ], + }); + const taskDefinitionArn = registered.taskDefinition?.taskDefinitionArn!; + // Safety net: deregister the out-of-band task definition on scope close. + yield* Effect.addFinalizer(() => + ecs + .deregisterTaskDefinition({ taskDefinition: taskDefinitionArn }) + .pipe(Effect.ignore), + ); + + // Deploy networking first so we can resolve concrete ids for the + // out-of-band ELBv2 resources. + const net = yield* stack.deploy( + Effect.gen(function* () { + const vpc = yield* Vpc("ManualLbVpc", { cidrBlock: "10.73.0.0/16" }); + const subnetA = yield* Subnet("ManualLbSubnetA", { + vpcId: vpc.vpcId, + cidrBlock: "10.73.1.0/24", + availabilityZone: "us-west-2a", + }); + const subnetB = yield* Subnet("ManualLbSubnetB", { + vpcId: vpc.vpcId, + cidrBlock: "10.73.2.0/24", + availabilityZone: "us-west-2b", + }); + const cluster = yield* Cluster("ManualLbCluster", { + clusterName: "alchemy-test-ecs-service-manuallb", + }); + return { + vpcId: vpc.vpcId.as(), + subnetAId: subnetA.subnetId.as(), + subnetBId: subnetB.subnetId.as(), + clusterArn: cluster.clusterArn.as(), + }; + }), + ); + + // Clean up any leftovers from a prior interrupted run so the fresh ALB, + // target group, and listener are consistently wired together (a stale, + // unassociated target group would make `createService` reject with + // `InvalidParameterException`). + const existingLbs = yield* elbv2 + .describeLoadBalancers({ Names: ["alchemy-test-ecs-manuallb"] }) + .pipe(Effect.catch(() => Effect.succeed({ LoadBalancers: [] }))); + for (const lb of existingLbs.LoadBalancers ?? []) { + const ls = yield* elbv2 + .describeListeners({ LoadBalancerArn: lb.LoadBalancerArn! }) + .pipe(Effect.catch(() => Effect.succeed({ Listeners: [] }))); + for (const l of ls.Listeners ?? []) { + yield* elbv2 + .deleteListener({ ListenerArn: l.ListenerArn! }) + .pipe(Effect.catch(() => Effect.void)); + } + yield* elbv2 + .deleteLoadBalancer({ LoadBalancerArn: lb.LoadBalancerArn! }) + .pipe(Effect.catch(() => Effect.void)); + } + if ((existingLbs.LoadBalancers ?? []).length > 0) { + yield* Effect.sleep("8 seconds"); + } + const existingTgs = yield* elbv2 + .describeTargetGroups({ Names: ["alchemy-test-ecs-manuallb"] }) + .pipe(Effect.catch(() => Effect.succeed({ TargetGroups: [] }))); + for (const tg of existingTgs.TargetGroups ?? []) { + yield* elbv2 + .deleteTargetGroup({ TargetGroupArn: tg.TargetGroupArn! }) + .pipe( + Effect.retry({ + while: (e) => e._tag === "ResourceInUseException", + schedule: Schedule.spaced("3 seconds").pipe( + Schedule.both(Schedule.recurs(5)), + ), + }), + Effect.catch(() => Effect.void), + ); + } + + // Create a real (internal) ALB + target group + listener out of band so + // the target group is associated with a load balancer. + const loadBalancer = yield* elbv2.createLoadBalancer({ + Name: "alchemy-test-ecs-manuallb", + Type: "application", + Scheme: "internal", + Subnets: [net.subnetAId, net.subnetBId], + }); + const loadBalancerArn = loadBalancer.LoadBalancers?.[0]?.LoadBalancerArn!; + // Safety-net finalizers (run LIFO on scope close): listener -> TG -> ALB, + // so the out-of-band ELBv2 resources are reclaimed even if the body fails. + yield* Effect.addFinalizer(() => + elbv2 + .deleteLoadBalancer({ LoadBalancerArn: loadBalancerArn }) + .pipe(Effect.ignore), + ); + + const targetGroup = yield* elbv2.createTargetGroup({ + Name: "alchemy-test-ecs-manuallb", + VpcId: net.vpcId, + TargetType: "ip", + Protocol: "HTTP", + Port: 80, + }); + const targetGroupArn = targetGroup.TargetGroups?.[0]?.TargetGroupArn!; + yield* Effect.addFinalizer(() => + elbv2 + .deleteTargetGroup({ TargetGroupArn: targetGroupArn }) + .pipe(Effect.ignore), + ); + + const listener = yield* elbv2.createListener({ + LoadBalancerArn: loadBalancerArn, + Port: 80, + Protocol: "HTTP", + DefaultActions: [{ Type: "forward", TargetGroupArn: targetGroupArn }], + }); + const listenerArn = listener.Listeners?.[0]?.ListenerArn!; + yield* Effect.addFinalizer(() => + elbv2.deleteListener({ ListenerArn: listenerArn }).pipe(Effect.ignore), + ); + + // Re-declare the same networking (idempotent — same logical ids) plus the + // Service wired to the user-supplied target group. + const service = yield* stack.deploy( + Effect.gen(function* () { + const vpc = yield* Vpc("ManualLbVpc", { cidrBlock: "10.73.0.0/16" }); + const subnetA = yield* Subnet("ManualLbSubnetA", { + vpcId: vpc.vpcId, + cidrBlock: "10.73.1.0/24", + availabilityZone: "us-west-2a", + }); + const subnetB = yield* Subnet("ManualLbSubnetB", { + vpcId: vpc.vpcId, + cidrBlock: "10.73.2.0/24", + availabilityZone: "us-west-2b", + }); + const cluster = yield* Cluster("ManualLbCluster", { + clusterName: "alchemy-test-ecs-service-manuallb", + }); + return yield* Service("ManualLbService", { + cluster, + task: { taskDefinitionArn, containerName: "app", port: 80 }, + desiredCount: 0, + public: false, + vpcId: vpc.vpcId, + subnets: [subnetA.subnetId, subnetB.subnetId], + loadBalancers: [ + { targetGroupArn, containerName: "app", containerPort: 80 }, + ], + }); + }), + ); + + // No Alchemy-managed ALB. + expect(service.loadBalancerArn).toBeUndefined(); + expect(service.url).toBeUndefined(); + + const described = yield* ecs.describeServices({ + cluster: service.clusterArn, + services: [service.serviceName], + }); + const lbs = described.services?.[0]?.loadBalancers ?? []; + expect(lbs.length).toBe(1); + expect(lbs[0]?.containerName).toBe("app"); + expect(lbs[0]?.targetGroupArn).toBe(targetGroupArn); + + // Delete the service first (it references the target group), then tear + // down the out-of-band ELBv2 resources, then the rest of the stack. + yield* ecs + .deleteService({ + cluster: service.clusterArn, + service: service.serviceName, + force: true, + }) + .pipe(Effect.catch(() => Effect.void)); + yield* elbv2 + .deleteListener({ ListenerArn: listenerArn }) + .pipe(Effect.catch(() => Effect.void)); + yield* elbv2 + .deleteLoadBalancer({ LoadBalancerArn: loadBalancerArn }) + .pipe(Effect.catch(() => Effect.void)); + yield* elbv2 + .deleteTargetGroup({ TargetGroupArn: targetGroupArn }) + .pipe(Effect.catch(() => Effect.void)); + + yield* stack.destroy(); + yield* ecs + .deregisterTaskDefinition({ taskDefinition: taskDefinitionArn }) + .pipe(Effect.catchTag("ClientException", () => Effect.void)); + }), + { timeout: 240_000 }, +); diff --git a/packages/alchemy/test/AWS/ECS/Task.test.ts b/packages/alchemy/test/AWS/ECS/Task.test.ts index 41df020d27..26116c3c8d 100644 --- a/packages/alchemy/test/AWS/ECS/Task.test.ts +++ b/packages/alchemy/test/AWS/ECS/Task.test.ts @@ -60,6 +60,13 @@ test.provider( }); const arn = registered.taskDefinition?.taskDefinitionArn; expect(arn).toBeDefined(); + // Safety net: deregister the out-of-band task definition on scope close + // even if the assertions below fail. + yield* Effect.addFinalizer(() => + ecs + .deregisterTaskDefinition({ taskDefinition: arn! }) + .pipe(Effect.ignore), + ); const provider = yield* Provider.findProvider(Task); const all = yield* provider.list(); @@ -86,3 +93,100 @@ test.provider( }), { timeout: 240_000 }, ); + +// Multi-container + task-level props round-trip. Registering a task definition +// is cheap (no Docker build), so we exercise the full typed surface +// out-of-band: a 2-container task (app + sidecar with dependsOn/portMappings/ +// logConfiguration), task-level ephemeralStorage, runtimePlatform (ARM64), and +// an EFS-less host volume, then describe it back and assert the shapes +// survived, then deregister. This validates that the distilled +// registerTaskDefinition surface we wire in `Task` is correct. +test.provider( + "multi-container task definition round-trips task-level props", + () => + Effect.gen(function* () { + const family = "alchemy-test-ecs-task-multicontainer"; + + const registered = yield* ecs.registerTaskDefinition({ + family, + networkMode: "awsvpc", + requiresCompatibilities: ["FARGATE"], + cpu: "256", + memory: "512", + runtimePlatform: { + cpuArchitecture: "ARM64", + operatingSystemFamily: "LINUX", + }, + ephemeralStorage: { sizeInGiB: 25 }, + volumes: [{ name: "scratch", host: {} }], + containerDefinitions: [ + { + name: "app", + image: "public.ecr.aws/nginx/nginx:stable", + essential: true, + portMappings: [{ containerPort: 80, protocol: "tcp" }], + mountPoints: [ + { sourceVolume: "scratch", containerPath: "/scratch" }, + ], + dependsOn: [{ containerName: "sidecar", condition: "START" }], + }, + { + name: "sidecar", + image: "public.ecr.aws/docker/library/busybox:latest", + essential: false, + command: ["sh", "-c", "while true; do sleep 30; done"], + }, + ], + }); + const td = registered.taskDefinition; + const arn = td?.taskDefinitionArn; + expect(arn).toBeDefined(); + yield* Effect.addFinalizer(() => + ecs + .deregisterTaskDefinition({ taskDefinition: arn! }) + .pipe(Effect.ignore), + ); + + const described = yield* ecs.describeTaskDefinition({ + taskDefinition: arn!, + }); + const def = described.taskDefinition; + expect(def?.containerDefinitions?.length).toBe(2); + expect(def?.containerDefinitions?.map((c) => c.name)).toEqual([ + "app", + "sidecar", + ]); + expect( + def?.containerDefinitions?.[0]?.dependsOn?.[0]?.containerName, + ).toBe("sidecar"); + expect(def?.runtimePlatform?.cpuArchitecture).toBe("ARM64"); + expect(def?.ephemeralStorage?.sizeInGiB).toBe(25); + expect(def?.volumes?.[0]?.name).toBe("scratch"); + + // Update one container (new image) → new revision number. + const updated = yield* ecs.registerTaskDefinition({ + family, + networkMode: "awsvpc", + requiresCompatibilities: ["FARGATE"], + cpu: "256", + memory: "512", + containerDefinitions: [ + { + name: "app", + image: "public.ecr.aws/nginx/nginx:latest", + essential: true, + portMappings: [{ containerPort: 80, protocol: "tcp" }], + }, + ], + }); + expect(updated.taskDefinition?.revision).toBeGreaterThan(td!.revision!); + yield* Effect.addFinalizer(() => + ecs + .deregisterTaskDefinition({ + taskDefinition: updated.taskDefinition!.taskDefinitionArn!, + }) + .pipe(Effect.ignore), + ); + }), + { timeout: 120_000 }, +); diff --git a/packages/alchemy/test/AWS/ELBv2/ListenerActions.test.ts b/packages/alchemy/test/AWS/ELBv2/ListenerActions.test.ts new file mode 100644 index 0000000000..319ee28e84 --- /dev/null +++ b/packages/alchemy/test/AWS/ELBv2/ListenerActions.test.ts @@ -0,0 +1,280 @@ +import * as AWS from "@/AWS"; +import { Subnet, VpcId } from "@/AWS/EC2"; +import { Listener, LoadBalancer, TargetGroup } from "@/AWS/ELBv2"; +import * as Test from "@/Test/Vitest"; +import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2"; +import * as EC2 from "@distilled.cloud/aws/ec2"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; + +const { test } = Test.make({ providers: AWS.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Exercises the full DefaultActions surface on a single ALB listener: +// forward -> redirect -> fixedResponse -> weighted multi-target-group forward +// with stickiness, all in-place via modifyListener. Reuses an existing VPC and +// carves stack-owned subnets (the testing account has no default VPC and is at +// its VPC limit; subnets don't count against that limit). +test.provider( + "listener default actions: forward -> redirect -> fixedResponse -> weighted", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const azResult = yield* EC2.describeAvailabilityZones({}); + const azs = + azResult.AvailabilityZones?.filter( + (az) => az.State === "available", + ).flatMap((az) => (az.ZoneName ? [az.ZoneName] : [])) ?? []; + const [az1, az2] = azs; + expect(az1).toBeTruthy(); + expect(az2).toBeTruthy(); + + const vpcs = yield* EC2.describeVpcs({}); + const vpc = (vpcs.Vpcs ?? []).find((v) => { + if (v.State !== "available" || !v.VpcId || !v.CidrBlock) return false; + const prefix = Number(v.CidrBlock.split("/")[1]); + return Number.isFinite(prefix) && prefix <= 23; + }); + const vpcId = VpcId(vpc?.VpcId!); + expect(vpcId).toBeTruthy(); + const [a, b] = vpc!.CidrBlock!.split("/")[0].split("."); + + // STAGE 1: simple forward listener (sugar form). + const s1 = yield* stack.deploy( + Effect.gen(function* () { + const subnet1 = yield* Subnet("LSubnet1", { + vpcId, + cidrBlock: `${a}.${b}.224.0/24`, + availabilityZone: az1, + }); + const subnet2 = yield* Subnet("LSubnet2", { + vpcId, + cidrBlock: `${a}.${b}.225.0/24`, + availabilityZone: az2, + }); + const lb = yield* LoadBalancer("LLb", { + subnets: [subnet1.subnetId, subnet2.subnetId], + scheme: "internal", + type: "application", + }); + const tgBlue = yield* TargetGroup("LTgBlue", { + vpcId, + port: 80, + protocol: "HTTP", + targetType: "ip", + }); + const tgGreen = yield* TargetGroup("LTgGreen", { + vpcId, + port: 80, + protocol: "HTTP", + targetType: "ip", + }); + const listener = yield* Listener("LListener", { + loadBalancerArn: lb.loadBalancerArn, + targetGroupArn: tgBlue.targetGroupArn, + port: 80, + protocol: "HTTP", + }); + return { + listenerArn: listener.listenerArn, + blue: tgBlue.targetGroupArn, + green: tgGreen.targetGroupArn, + lb: lb.loadBalancerArn, + s1: subnet1.subnetId, + s2: subnet2.subnetId, + }; + }), + ); + + const listenerArn = s1.listenerArn; + const describe = elbv2 + .describeListeners({ ListenerArns: [listenerArn] }) + .pipe(Effect.map((r) => r.Listeners?.[0])); + + let observed = yield* describe; + expect(observed?.DefaultActions?.[0]?.Type).toBe("forward"); + + // STAGE 2: redirect HTTP -> HTTPS (in place). + yield* stack.deploy( + Effect.gen(function* () { + const subnet1 = yield* Subnet("LSubnet1", { + vpcId, + cidrBlock: `${a}.${b}.224.0/24`, + availabilityZone: az1, + }); + const subnet2 = yield* Subnet("LSubnet2", { + vpcId, + cidrBlock: `${a}.${b}.225.0/24`, + availabilityZone: az2, + }); + const lb = yield* LoadBalancer("LLb", { + subnets: [subnet1.subnetId, subnet2.subnetId], + scheme: "internal", + type: "application", + }); + yield* TargetGroup("LTgBlue", { + vpcId, + port: 80, + protocol: "HTTP", + targetType: "ip", + }); + yield* TargetGroup("LTgGreen", { + vpcId, + port: 80, + protocol: "HTTP", + targetType: "ip", + }); + yield* Listener("LListener", { + loadBalancerArn: lb.loadBalancerArn, + port: 80, + protocol: "HTTP", + defaultActions: [ + { + type: "redirect", + statusCode: "HTTP_301", + protocol: "HTTPS", + port: "443", + }, + ], + }); + }), + ); + + observed = yield* describe; + expect(observed?.DefaultActions?.[0]?.Type).toBe("redirect"); + expect(observed?.DefaultActions?.[0]?.RedirectConfig?.StatusCode).toBe( + "HTTP_301", + ); + + // STAGE 3: fixed-response (in place). + yield* stack.deploy( + Effect.gen(function* () { + const subnet1 = yield* Subnet("LSubnet1", { + vpcId, + cidrBlock: `${a}.${b}.224.0/24`, + availabilityZone: az1, + }); + const subnet2 = yield* Subnet("LSubnet2", { + vpcId, + cidrBlock: `${a}.${b}.225.0/24`, + availabilityZone: az2, + }); + const lb = yield* LoadBalancer("LLb", { + subnets: [subnet1.subnetId, subnet2.subnetId], + scheme: "internal", + type: "application", + }); + yield* TargetGroup("LTgBlue", { + vpcId, + port: 80, + protocol: "HTTP", + targetType: "ip", + }); + yield* TargetGroup("LTgGreen", { + vpcId, + port: 80, + protocol: "HTTP", + targetType: "ip", + }); + yield* Listener("LListener", { + loadBalancerArn: lb.loadBalancerArn, + port: 80, + protocol: "HTTP", + defaultActions: [ + { + type: "fixedResponse", + statusCode: "503", + contentType: "text/plain", + messageBody: "down", + }, + ], + }); + }), + ); + + observed = yield* describe; + expect(observed?.DefaultActions?.[0]?.Type).toBe("fixed-response"); + expect( + observed?.DefaultActions?.[0]?.FixedResponseConfig?.StatusCode, + ).toBe("503"); + + // STAGE 4: weighted multi-target-group forward with stickiness. + yield* stack.deploy( + Effect.gen(function* () { + const subnet1 = yield* Subnet("LSubnet1", { + vpcId, + cidrBlock: `${a}.${b}.224.0/24`, + availabilityZone: az1, + }); + const subnet2 = yield* Subnet("LSubnet2", { + vpcId, + cidrBlock: `${a}.${b}.225.0/24`, + availabilityZone: az2, + }); + const lb = yield* LoadBalancer("LLb", { + subnets: [subnet1.subnetId, subnet2.subnetId], + scheme: "internal", + type: "application", + }); + const tgBlue = yield* TargetGroup("LTgBlue", { + vpcId, + port: 80, + protocol: "HTTP", + targetType: "ip", + }); + const tgGreen = yield* TargetGroup("LTgGreen", { + vpcId, + port: 80, + protocol: "HTTP", + targetType: "ip", + }); + yield* Listener("LListener", { + loadBalancerArn: lb.loadBalancerArn, + port: 80, + protocol: "HTTP", + defaultActions: [ + { + type: "forward", + targetGroups: [ + { targetGroupArn: tgBlue.targetGroupArn, weight: 90 }, + { targetGroupArn: tgGreen.targetGroupArn, weight: 10 }, + ], + stickiness: { enabled: true, durationSeconds: 3600 }, + }, + ], + }); + }), + ); + + observed = yield* describe; + const forward = observed?.DefaultActions?.find( + (x) => x.Type === "forward", + ); + expect(forward?.ForwardConfig?.TargetGroups?.length).toBe(2); + const weights = (forward?.ForwardConfig?.TargetGroups ?? []) + .map((t) => t.Weight) + .sort(); + expect(weights).toEqual([10, 90]); + expect(forward?.ForwardConfig?.TargetGroupStickinessConfig?.Enabled).toBe( + true, + ); + + yield* stack.destroy(); + + // Verify the listener is gone. + const after = yield* elbv2 + .describeListeners({ ListenerArns: [listenerArn] }) + .pipe( + Effect.map((r) => r.Listeners?.length ?? 0), + Effect.catchTag("ListenerNotFoundException", () => Effect.succeed(0)), + ); + expect(after).toBe(0); + }).pipe(logLevel), + { timeout: 600_000 }, +); diff --git a/packages/alchemy/test/AWS/ELBv2/ListenerRule.test.ts b/packages/alchemy/test/AWS/ELBv2/ListenerRule.test.ts new file mode 100644 index 0000000000..4d5df077e3 --- /dev/null +++ b/packages/alchemy/test/AWS/ELBv2/ListenerRule.test.ts @@ -0,0 +1,157 @@ +import * as AWS from "@/AWS"; +import { Subnet, VpcId } from "@/AWS/EC2"; +import { Listener, ListenerRule, LoadBalancer, TargetGroup } from "@/AWS/ELBv2"; +import * as Test from "@/Test/Vitest"; +import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2"; +import * as EC2 from "@distilled.cloud/aws/ec2"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; + +const { test } = Test.make({ providers: AWS.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// Create a path-pattern rule and a host-header rule on a listener, update the +// path rule's condition + action in place, change its priority via +// setRulePriorities, then destroy. Reuses an existing VPC + carved subnets. +test.provider( + "listener rules: path + host conditions, in-place update, priority change", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const azResult = yield* EC2.describeAvailabilityZones({}); + const azs = + azResult.AvailabilityZones?.filter( + (az) => az.State === "available", + ).flatMap((az) => (az.ZoneName ? [az.ZoneName] : [])) ?? []; + const [az1, az2] = azs; + expect(az1).toBeTruthy(); + expect(az2).toBeTruthy(); + + const vpcs = yield* EC2.describeVpcs({}); + const vpc = (vpcs.Vpcs ?? []).find((v) => { + if (v.State !== "available" || !v.VpcId || !v.CidrBlock) return false; + const prefix = Number(v.CidrBlock.split("/")[1]); + return Number.isFinite(prefix) && prefix <= 23; + }); + const vpcId = VpcId(vpc?.VpcId!); + expect(vpcId).toBeTruthy(); + const [a, b] = vpc!.CidrBlock!.split("/")[0].split("."); + + const stage = ( + pathPriority: number, + pathValue: string, + pathTargetFixed: boolean, + ) => + stack.deploy( + Effect.gen(function* () { + const subnet1 = yield* Subnet("RSubnet1", { + vpcId, + cidrBlock: `${a}.${b}.226.0/24`, + availabilityZone: az1, + }); + const subnet2 = yield* Subnet("RSubnet2", { + vpcId, + cidrBlock: `${a}.${b}.227.0/24`, + availabilityZone: az2, + }); + const lb = yield* LoadBalancer("RLb", { + subnets: [subnet1.subnetId, subnet2.subnetId], + scheme: "internal", + type: "application", + }); + const tg = yield* TargetGroup("RTg", { + vpcId, + port: 80, + protocol: "HTTP", + targetType: "ip", + }); + const listener = yield* Listener("RListener", { + loadBalancerArn: lb.loadBalancerArn, + targetGroupArn: tg.targetGroupArn, + port: 80, + protocol: "HTTP", + }); + const pathRule = yield* ListenerRule("RPathRule", { + listenerArn: listener.listenerArn, + priority: pathPriority, + conditions: [{ pathPattern: { values: [pathValue] } }], + actions: pathTargetFixed + ? [ + { + type: "fixedResponse", + statusCode: "200", + messageBody: "ok", + }, + ] + : [ + { + type: "forward", + targetGroups: [{ targetGroupArn: tg.targetGroupArn }], + }, + ], + }); + const hostRule = yield* ListenerRule("RHostRule", { + listenerArn: listener.listenerArn, + priority: 20, + conditions: [{ hostHeader: { values: ["admin.example.com"] } }], + actions: [ + { + type: "forward", + targetGroups: [{ targetGroupArn: tg.targetGroupArn }], + }, + ], + }); + return { + pathRuleArn: pathRule.ruleArn, + hostRuleArn: hostRule.ruleArn, + }; + }), + ); + + // STAGE 1: path rule priority 10 forwarding "/api/*", host rule priority 20. + const s1 = yield* stage(10, "/api/*", false); + const pathRuleArn = s1.pathRuleArn; + + const describePath = elbv2 + .describeRules({ RuleArns: [pathRuleArn] }) + .pipe(Effect.map((r) => r.Rules?.[0])); + + let rule = yield* describePath; + expect(rule?.Priority).toBe("10"); + expect(rule?.Conditions?.[0]?.Field).toBe("path-pattern"); + expect(rule?.Conditions?.[0]?.PathPatternConfig?.Values).toEqual([ + "/api/*", + ]); + expect(rule?.Actions?.some((x) => x.Type === "forward")).toBe(true); + + // STAGE 2: change the path value + action (forward -> fixedResponse) in + // place, and bump priority 10 -> 15 via setRulePriorities. + yield* stage(15, "/v2/*", true); + + rule = yield* describePath; + expect(rule?.Priority).toBe("15"); + expect(rule?.Conditions?.[0]?.PathPatternConfig?.Values).toEqual([ + "/v2/*", + ]); + expect(rule?.Actions?.some((x) => x.Type === "fixed-response")).toBe( + true, + ); + + yield* stack.destroy(); + + const after = yield* elbv2 + .describeRules({ RuleArns: [pathRuleArn] }) + .pipe( + Effect.map((r) => r.Rules?.length ?? 0), + Effect.catchTag("RuleNotFoundException", () => Effect.succeed(0)), + ); + expect(after).toBe(0); + }).pipe(logLevel), + { timeout: 600_000 }, +); diff --git a/packages/alchemy/test/AWS/ELBv2/TrustStore.test.ts b/packages/alchemy/test/AWS/ELBv2/TrustStore.test.ts new file mode 100644 index 0000000000..f417450825 --- /dev/null +++ b/packages/alchemy/test/AWS/ELBv2/TrustStore.test.ts @@ -0,0 +1,135 @@ +import * as AWS from "@/AWS"; +import { Bucket } from "@/AWS/S3"; +import { TrustStore } from "@/AWS/ELBv2"; +import * as Test from "@/Test/Vitest"; +import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2"; +import * as s3 from "@distilled.cloud/aws/s3"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; + +const { test } = Test.make({ providers: AWS.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +// A self-signed CA certificate generated once and checked in (never created at +// test time, per the fixture convention). X.509 v3 with basicConstraints +// CA:TRUE + keyCertSign — ELBv2 trust stores reject v1 certs +// ("The certificate version is not supported"). +const CA_BUNDLE_PEM = `-----BEGIN CERTIFICATE----- +MIIC2jCCAcKgAwIBAgIJAJyM/Dvd55qtMA0GCSqGSIb3DQEBCwUAMBoxGDAWBgNV +BAMMD2FsY2hlbXktdGVzdC1jYTAeFw0yNjA2MTcwNjA5MDlaFw0zNjA2MTQwNjA5 +MDlaMBoxGDAWBgNVBAMMD2FsY2hlbXktdGVzdC1jYTCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBANgo7XPCQMpyecXg2SCj6Tn6R1snlmhSA1vKGQnHoQBS +QA11DMpv+iFRT9s1d3izaGA4GEcxfrXOsmUBkzYIHJIYakCWdr6qcUXs6lS2uhnZ +qcyR0CamDtHTqAxRKEK+QPaISoxyD3BIwQqE0I8yNzV3/6osIE513e+7tp9E+J04 +dBhyG5goSwR3ueqs53gQioYVp/fgLKo4MqFcsA3p7anEE9hyeq1Q/lGAXxQwZmXT +3kQli/JjMoF8OfccpA3aBx9Y2aDTCU8HXTscVYmPSHbnTGkTARGBwnag+Jwq5Uni +YvM2OeDUPwvszgpi3JgiblZQhZQAy4/MeNhmE8qgIa8CAwEAAaMjMCEwDwYDVR0T +AQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQELBQADggEBAJnp +el0xBbL/eQY87evhy0o+ZTHMVCdI9Uc+kDK0XPMi4hc5OfjWNIy8u5/s33kPkNYS +Y5Jhm5KtGtMb9kXioCWjSi0aREA8zijGrXn1jC+0rksMQmJka63bKsJ4TjFaHMcc +m/xt25xX1Ssp/gWr9YX3MzbPhcn57Uu9OTtzf13F6CMv1XtRS1RKFYtkLZrhvzBR +WPdos3xvn3D0Fjd5H5AgVKTkeb2YPhINfN4jyzn3J09teKZpNN/qHTAQewIh2FnO +MeplcuT3eQVUZNTBelvUE7VKHe11AUc8TkvVMS/XOFeN6OHAJtq08EegbTcjwz9Z +lyGGetkNMmdhGRV6AlY= +-----END CERTIFICATE----- +`; + +// Fast unconditional probe: createTrustStore against a non-existent bundle +// must surface a typed error (not an untyped catch-all). Proves both the +// resource wiring and the distilled typed-error path. +test.provider( + "trust store create with missing bundle returns a typed error", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const result = yield* elbv2 + .createTrustStore({ + Name: `alchemy-mtls-probe-${stack.name.replace(/[^a-zA-Z0-9]/g, "")}`.slice( + 0, + 32, + ), + CaCertificatesBundleS3Bucket: "alchemy-no-such-bucket-elbv2-probe", + CaCertificatesBundleS3Key: "missing.pem", + }) + .pipe(Effect.flip); + + // AWS rejects a missing/inaccessible bundle with one of these typed tags. + expect( + [ + "CaCertificatesBundleNotFoundException", + "InvalidCaCertificatesBundleException", + ].includes(result._tag), + ).toBe(true); + + yield* stack.destroy(); + }).pipe(logLevel), + { timeout: 120_000 }, +); + +// Full mTLS-verify lifecycle: upload a CA bundle to a stack-owned bucket, create +// an ACTIVE trust store, then destroy. Gated — requires an account that can +// create trust stores and an S3 bucket. +test.provider.skipIf(!process.env.ELBV2_TEST_MTLS)( + "trust store full lifecycle from an uploaded CA bundle", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const deployed = yield* stack.deploy( + Effect.gen(function* () { + const bucket = yield* Bucket("TsBucket", { forceDestroy: true }); + return { bucketName: bucket.bucketName }; + }), + ); + + const key = "ca-bundle.pem"; + yield* s3.putObject({ + Bucket: deployed.bucketName, + Key: key, + Body: CA_BUNDLE_PEM, + ContentType: "application/x-pem-file", + }); + + const ts = yield* stack.deploy( + Effect.gen(function* () { + const bucket = yield* Bucket("TsBucket", { forceDestroy: true }); + const trustStore = yield* TrustStore("TsStore", { + caCertificatesBundleS3Bucket: bucket.bucketName, + caCertificatesBundleS3Key: key, + }); + return { trustStore }; + }), + ); + + expect(ts.trustStore.status).toBe("ACTIVE"); + expect(ts.trustStore.numberOfCaCertificates).toBeGreaterThanOrEqual(1); + + const observed = yield* elbv2 + .describeTrustStores({ + TrustStoreArns: [ts.trustStore.trustStoreArn], + }) + .pipe(Effect.map((r) => r.TrustStores?.[0])); + expect(observed?.Status).toBe("ACTIVE"); + + yield* stack.destroy(); + + const after = yield* elbv2 + .describeTrustStores({ + TrustStoreArns: [ts.trustStore.trustStoreArn], + }) + .pipe( + Effect.map((r) => r.TrustStores?.length ?? 0), + Effect.catchTag("TrustStoreNotFoundException", () => + Effect.succeed(0), + ), + ); + expect(after).toBe(0); + }).pipe(logLevel), + { timeout: 600_000 }, +); diff --git a/packages/alchemy/test/AWS/RDS/DBCluster.test.ts b/packages/alchemy/test/AWS/RDS/DBCluster.test.ts index a032c6d06a..cae937cea0 100644 --- a/packages/alchemy/test/AWS/RDS/DBCluster.test.ts +++ b/packages/alchemy/test/AWS/RDS/DBCluster.test.ts @@ -1,5 +1,8 @@ import * as AWS from "@/AWS"; +import { Network } from "@/AWS/EC2/Network"; import { DBCluster } from "@/AWS/RDS/DBCluster.ts"; +import type { DBClusterProps } from "@/AWS/RDS/DBCluster.ts"; +import { DBSubnetGroup } from "@/AWS/RDS/DBSubnetGroup.ts"; import * as Provider from "@/Provider"; import * as Test from "@/Test/Vitest"; import { expect } from "@effect/vitest"; @@ -7,6 +10,66 @@ import * as Effect from "effect/Effect"; const { test } = Test.make({ providers: AWS.providers() }); +// Fast, unconditional `diff` checks for the replacement-set logic. No deploy. +const callDiff = (olds: DBClusterProps, news: DBClusterProps) => + Effect.gen(function* () { + const provider = yield* Provider.findProvider(DBCluster); + return yield* provider.diff!({ + id: "TestCluster", + instanceId: "test-cluster", + olds, + news, + oldBindings: undefined as never, + newBindings: undefined as never, + output: undefined, + }); + }); + +const base: DBClusterProps = { + dbClusterIdentifier: "alchemy-rds-cluster-diff", + engine: "aurora-postgresql", +}; + +test.provider("diff: backup retention is an in-place update", () => + Effect.gen(function* () { + const result = yield* callDiff( + { ...base, backupRetentionPeriod: 1 }, + { ...base, backupRetentionPeriod: 7 }, + ); + expect(result).toBeUndefined(); + }), +); + +test.provider("diff: changing databaseName forces replacement", () => + Effect.gen(function* () { + const result = yield* callDiff( + { ...base, databaseName: "app" }, + { ...base, databaseName: "other" }, + ); + expect(result).toEqual({ action: "replace" }); + }), +); + +test.provider("diff: changing kmsKeyId forces replacement", () => + Effect.gen(function* () { + const result = yield* callDiff( + { ...base, kmsKeyId: "key-a" }, + { ...base, kmsKeyId: "key-b" }, + ); + expect(result).toEqual({ action: "replace" }); + }), +); + +test.provider("diff: changing engineMode forces replacement", () => + Effect.gen(function* () { + const result = yield* callDiff( + { ...base, engineMode: "provisioned" }, + { ...base, engineMode: "serverless" }, + ); + expect(result).toEqual({ action: "replace" }); + }), +); + // Read-only `list()` test (no deploy). An Aurora DB cluster takes MANY minutes // to create *and* delete — far beyond the 240s test budget — so we exercise the // enumeration path without provisioning. We resolve the provider via the typed @@ -67,3 +130,106 @@ test.provider.skipIf(!process.env.AWS_TEST_RDS_DBCLUSTER)( }), { timeout: 1_800_000 }, ); + +// Full cluster lifecycle gated behind RDS_TEST_LIFECYCLE=1. Creates a +// serverless-v2 Aurora cluster with backup/log/deletion-protection knobs, then +// does an in-place modify (backup retention, toggle deletionProtection — a +// regression for the previously-missing `modifyDBCluster` deletion-protection +// sync — and scaling min/max), asserting no replacement (same ARN). +test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)( + "cluster: create with knobs, then in-place modify (deletionProtection toggle)", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + // No default VPC/subnets in the testing account — provision a + // production-shaped network (VPC + subnets across 2 AZs) + a DB subnet + // group for the cluster. + const network = Effect.gen(function* () { + const net = yield* Network("ClusterNet", { cidrBlock: "10.42.0.0/16" }); + // No fixed name — let the engine generate a unique physical name so a + // leftover group from an interrupted run can't force a cross-VPC + // ModifyDBSubnetGroup ("new Subnets are not in the same Vpc"). + const subnetGroup = yield* DBSubnetGroup("ClusterSubnetGroup", { + description: "alchemy cluster lifecycle", + subnetIds: net.privateSubnetIds, + }); + return { dbSubnetGroupName: subnetGroup.dbSubnetGroupName }; + }); + + const created = yield* stack.deploy( + Effect.gen(function* () { + const { dbSubnetGroupName } = yield* network; + return yield* DBCluster("LifecycleCluster", { + dbClusterIdentifier: "alchemy-rds-lifecycle", + engine: "aurora-postgresql", + engineMode: "provisioned", + dbSubnetGroupName, + serverlessV2ScalingConfiguration: { + MinCapacity: 0.5, + MaxCapacity: 1, + }, + manageMasterUserPassword: true, + masterUsername: "alchemy", + backupRetentionPeriod: 1, + enableCloudwatchLogsExports: ["postgresql"], + deletionProtection: false, + }); + }), + ); + + expect(created.backupRetentionPeriod).toBe(1); + expect(created.enabledCloudwatchLogsExports).toContain("postgresql"); + expect(created.deletionProtection).toBe(false); + + const updated = yield* stack.deploy( + Effect.gen(function* () { + const { dbSubnetGroupName } = yield* network; + return yield* DBCluster("LifecycleCluster", { + dbClusterIdentifier: "alchemy-rds-lifecycle", + engine: "aurora-postgresql", + engineMode: "provisioned", + dbSubnetGroupName, + serverlessV2ScalingConfiguration: { + MinCapacity: 1, + MaxCapacity: 2, + }, + manageMasterUserPassword: true, + masterUsername: "alchemy", + backupRetentionPeriod: 3, + enableCloudwatchLogsExports: ["postgresql"], + deletionProtection: true, + }); + }), + ); + + expect(updated.dbClusterArn).toBe(created.dbClusterArn); + expect(updated.backupRetentionPeriod).toBe(3); + expect(updated.deletionProtection).toBe(true); + + // Re-disable protection so the trailing destroy can delete the cluster. + yield* stack.deploy( + Effect.gen(function* () { + const { dbSubnetGroupName } = yield* network; + return yield* DBCluster("LifecycleCluster", { + dbClusterIdentifier: "alchemy-rds-lifecycle", + engine: "aurora-postgresql", + engineMode: "provisioned", + dbSubnetGroupName, + serverlessV2ScalingConfiguration: { + MinCapacity: 1, + MaxCapacity: 2, + }, + manageMasterUserPassword: true, + masterUsername: "alchemy", + backupRetentionPeriod: 3, + enableCloudwatchLogsExports: ["postgresql"], + deletionProtection: false, + }); + }), + ); + + yield* stack.destroy(); + }), + { timeout: 2_400_000 }, +); diff --git a/packages/alchemy/test/AWS/RDS/DBInstance.test.ts b/packages/alchemy/test/AWS/RDS/DBInstance.test.ts index b33a0cf669..1aba5a3a5c 100644 --- a/packages/alchemy/test/AWS/RDS/DBInstance.test.ts +++ b/packages/alchemy/test/AWS/RDS/DBInstance.test.ts @@ -1,5 +1,8 @@ import * as AWS from "@/AWS"; +import { Network } from "@/AWS/EC2/Network"; import { DBCluster, DBInstance } from "@/AWS/RDS"; +import type { DBInstanceProps } from "@/AWS/RDS/DBInstance.ts"; +import { DBSubnetGroup } from "@/AWS/RDS/DBSubnetGroup.ts"; import * as Provider from "@/Provider"; import * as Test from "@/Test/Vitest"; import { expect } from "@effect/vitest"; @@ -7,6 +10,67 @@ import * as Effect from "effect/Effect"; const { test } = Test.make({ providers: AWS.providers() }); +// Fast, unconditional `diff` checks. These exercise the replacement-set logic +// without provisioning anything (the real lifecycle is multi-minute, gated +// below). `diff` is called with `id`, `olds`, and `news` — the engine wraps +// `news` in `Input` but plain objects resolve fine. +const callDiff = (olds: DBInstanceProps, news: DBInstanceProps) => + Effect.gen(function* () { + const provider = yield* Provider.findProvider(DBInstance); + return yield* provider.diff!({ + id: "TestInstance", + instanceId: "test-instance", + olds, + news, + oldBindings: undefined as never, + newBindings: undefined as never, + output: undefined, + }); + }); + +const base: DBInstanceProps = { + dbInstanceIdentifier: "alchemy-rds-instance-diff", + dbInstanceClass: "db.t3.micro", + engine: "postgres", +}; + +test.provider("diff: storage scale is an in-place update", () => + Effect.gen(function* () { + const result = yield* callDiff( + { ...base, allocatedStorage: 20 }, + { ...base, allocatedStorage: 50 }, + ); + expect(result).toBeUndefined(); + }), +); + +test.provider("diff: changing engine forces replacement", () => + Effect.gen(function* () { + const result = yield* callDiff(base, { ...base, engine: "mysql" }); + expect(result).toEqual({ action: "replace" }); + }), +); + +test.provider("diff: changing storageEncrypted forces replacement", () => + Effect.gen(function* () { + const result = yield* callDiff( + { ...base, storageEncrypted: false }, + { ...base, storageEncrypted: true }, + ); + expect(result).toEqual({ action: "replace" }); + }), +); + +test.provider("diff: changing masterUsername forces replacement", () => + Effect.gen(function* () { + const result = yield* callDiff( + { ...base, masterUsername: "admin" }, + { ...base, masterUsername: "root" }, + ); + expect(result).toEqual({ action: "replace" }); + }), +); + // Default (read-only) path: an RDS instance takes many minutes to create and // delete — far beyond the 240s test budget — so the canonical `list()` test // here does NOT deploy. It resolves the provider via the typed @@ -76,3 +140,82 @@ test.provider.skipIf(!process.env.AWS_TEST_RDS_DBINSTANCE)( }), { timeout: 1_800_000 }, ); + +// Full standalone-instance lifecycle, gated behind RDS_TEST_LIFECYCLE=1. +// Provisioning + modifying + deleting a real `db.t3.micro` takes ~10-15 min, +// far beyond the default budget. It creates a gp3 Postgres instance with +// explicit storage/backup knobs, asserts they round-trip, then does an +// in-place modify (allocatedStorage up, backup retention, perf insights) and +// re-reads to assert no replacement occurred (same ARN, same identifier). +test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)( + "standalone instance: create with storage knobs, then in-place modify", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + // The testing account has no default VPC/subnets, so provision a + // production-shaped network (VPC + subnets across 2 AZs) and a DB subnet + // group for the instance to live in. + const network = Effect.gen(function* () { + const net = yield* Network("RdsNet", { cidrBlock: "10.41.0.0/16" }); + // No fixed name — let the engine generate a unique physical name so a + // leftover group from an interrupted run can't force a cross-VPC + // ModifyDBSubnetGroup ("new Subnets are not in the same Vpc"). + const subnetGroup = yield* DBSubnetGroup("RdsSubnetGroup", { + description: "alchemy standalone instance lifecycle", + subnetIds: net.privateSubnetIds, + }); + return { dbSubnetGroupName: subnetGroup.dbSubnetGroupName }; + }); + + const created = yield* stack.deploy( + Effect.gen(function* () { + const { dbSubnetGroupName } = yield* network; + return yield* DBInstance("StandaloneInstance", { + dbInstanceIdentifier: "alchemy-rds-standalone", + engine: "postgres", + dbInstanceClass: "db.t3.micro", + allocatedStorage: 20, + storageType: "gp3", + masterUsername: "alchemy", + manageMasterUserPassword: true, + backupRetentionPeriod: 1, + deletionProtection: false, + dbSubnetGroupName, + publiclyAccessible: false, + }); + }), + ); + + expect(created.allocatedStorage).toBe(20); + expect(created.storageType).toBe("gp3"); + expect(created.backupRetentionPeriod).toBe(1); + + const updated = yield* stack.deploy( + Effect.gen(function* () { + const { dbSubnetGroupName } = yield* network; + return yield* DBInstance("StandaloneInstance", { + dbInstanceIdentifier: "alchemy-rds-standalone", + engine: "postgres", + dbInstanceClass: "db.t3.micro", + allocatedStorage: 25, + storageType: "gp3", + masterUsername: "alchemy", + manageMasterUserPassword: true, + backupRetentionPeriod: 3, + enablePerformanceInsights: true, + deletionProtection: false, + dbSubnetGroupName, + publiclyAccessible: false, + }); + }), + ); + + // In-place modify — identity is preserved (no replacement). + expect(updated.dbInstanceArn).toBe(created.dbInstanceArn); + expect(updated.backupRetentionPeriod).toBe(3); + + yield* stack.destroy(); + }), + { timeout: 2_400_000 }, +); diff --git a/packages/alchemy/test/AWS/Route53/HealthCheck.test.ts b/packages/alchemy/test/AWS/Route53/HealthCheck.test.ts new file mode 100644 index 0000000000..e8d5834792 --- /dev/null +++ b/packages/alchemy/test/AWS/Route53/HealthCheck.test.ts @@ -0,0 +1,144 @@ +import * as AWS from "@/AWS"; +import { HealthCheck } from "@/AWS/Route53"; +import * as Test from "@/Test/Vitest"; +import * as route53 from "@distilled.cloud/aws/route-53"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; + +const { test } = Test.make({ providers: AWS.providers() }); + +const assertCheckGone = (id: string) => + route53.getHealthCheck({ HealthCheckId: id }).pipe( + Effect.flatMap(() => Effect.fail(new Error("health check still exists"))), + Effect.catchTag("NoSuchHealthCheck", () => Effect.void), + Effect.retry({ + while: (e) => e instanceof Error, + schedule: Schedule.fixed("2 seconds").pipe( + Schedule.both(Schedule.recurs(10)), + ), + }), + ); + +test.provider( + "create, update in place, tag, and delete health check", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + // Create. + const check = yield* stack.deploy( + Effect.gen(function* () { + return yield* HealthCheck("Check", { + type: "HTTP", + fullyQualifiedDomainName: "example.com", + resourcePath: "/", + port: 80, + requestInterval: 30, + failureThreshold: 3, + tags: { env: "test" }, + }); + }), + ); + + expect(check.id).toBeDefined(); + expect(check.healthCheckId).toBe(check.id); + expect(check.type).toBe("HTTP"); + + const observed = yield* route53.getHealthCheck({ + HealthCheckId: check.id, + }); + expect(observed.HealthCheck.HealthCheckConfig.FailureThreshold).toBe(3); + expect(observed.HealthCheck.HealthCheckConfig.ResourcePath).toBe("/"); + + const tags = yield* route53.listTagsForResource({ + ResourceType: "healthcheck", + ResourceId: check.id, + }); + const tagMap = Object.fromEntries( + (tags.ResourceTagSet.Tags ?? []).map((t) => [t.Key, t.Value]), + ); + expect(tagMap.env).toBe("test"); + expect(tagMap["alchemy::id"]).toBeDefined(); + + // Update mutable fields in place (version-locked). + const updated = yield* stack.deploy( + Effect.gen(function* () { + return yield* HealthCheck("Check", { + type: "HTTP", + fullyQualifiedDomainName: "example.com", + resourcePath: "/health", + port: 80, + requestInterval: 30, + failureThreshold: 5, + tags: { env: "prod" }, + }); + }), + ); + // In-place update keeps the same id. + expect(updated.id).toBe(check.id); + + const observed2 = yield* route53.getHealthCheck({ + HealthCheckId: check.id, + }); + expect(observed2.HealthCheck.HealthCheckConfig.FailureThreshold).toBe(5); + expect(observed2.HealthCheck.HealthCheckConfig.ResourcePath).toBe( + "/health", + ); + + const tags2 = yield* route53.listTagsForResource({ + ResourceType: "healthcheck", + ResourceId: check.id, + }); + const tagMap2 = Object.fromEntries( + (tags2.ResourceTagSet.Tags ?? []).map((t) => [t.Key, t.Value]), + ); + expect(tagMap2.env).toBe("prod"); + + yield* stack.destroy(); + yield* assertCheckGone(check.id); + }), + { timeout: 180_000 }, +); + +test.provider( + "changing Type replaces the health check", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const check = yield* stack.deploy( + Effect.gen(function* () { + return yield* HealthCheck("ReplaceCheck", { + type: "HTTP", + fullyQualifiedDomainName: "example.com", + port: 80, + requestInterval: 30, + }); + }), + ); + const originalId = check.id; + + // Type is immutable — changing it forces replacement (new id). + const replaced = yield* stack.deploy( + Effect.gen(function* () { + return yield* HealthCheck("ReplaceCheck", { + type: "HTTPS", + fullyQualifiedDomainName: "example.com", + port: 443, + requestInterval: 30, + }); + }), + ); + + expect(replaced.type).toBe("HTTPS"); + expect(replaced.id).not.toBe(originalId); + + // The old health check must have been deleted. + yield* assertCheckGone(originalId); + + yield* stack.destroy(); + yield* assertCheckGone(replaced.id); + }), + { timeout: 180_000 }, +); diff --git a/packages/alchemy/test/AWS/Route53/HostedZone.test.ts b/packages/alchemy/test/AWS/Route53/HostedZone.test.ts new file mode 100644 index 0000000000..02de8f3a99 --- /dev/null +++ b/packages/alchemy/test/AWS/Route53/HostedZone.test.ts @@ -0,0 +1,158 @@ +import * as AWS from "@/AWS"; +import { HostedZone } from "@/AWS/Route53"; +import * as Test from "@/Test/Vitest"; +import * as route53 from "@distilled.cloud/aws/route-53"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; + +const { test } = Test.make({ providers: AWS.providers() }); + +const normalizeId = (id: string) => id.replace(/^\/hostedzone\//, ""); + +// Deterministic per-test zone names (reserved-domain-safe TLD `.alchemy`). +const zoneName = "alchemy-hostedzone-crud.alchemy."; + +const assertZoneGone = (id: string) => + route53.getHostedZone({ Id: normalizeId(id) }).pipe( + Effect.flatMap(() => Effect.fail(new Error("zone still exists"))), + Effect.catchTag("NoSuchHostedZone", () => Effect.void), + Effect.retry({ + while: (e) => e instanceof Error, + schedule: Schedule.fixed("2 seconds").pipe( + Schedule.both(Schedule.recurs(10)), + ), + }), + ); + +test.provider( + "create, update comment, tag, and delete hosted zone", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + // Create. + const zone = yield* stack.deploy( + Effect.gen(function* () { + return yield* HostedZone("Zone", { + name: zoneName, + comment: "initial comment", + tags: { env: "test" }, + }); + }), + ); + + expect(zone.id).toBeDefined(); + expect(zone.name).toBe(zoneName); + // Public zones get exactly 4 authoritative name servers. + expect(zone.nameServers.length).toBe(4); + expect(zone.comment).toBe("initial comment"); + + // Verify out of band. + const observed = yield* route53.getHostedZone({ + Id: normalizeId(zone.id), + }); + expect(observed.HostedZone.Config?.Comment).toBe("initial comment"); + + const tags = yield* route53.listTagsForResource({ + ResourceType: "hostedzone", + ResourceId: normalizeId(zone.id), + }); + const tagMap = Object.fromEntries( + (tags.ResourceTagSet.Tags ?? []).map((t) => [t.Key, t.Value]), + ); + expect(tagMap.env).toBe("test"); + expect(tagMap["alchemy::id"]).toBeDefined(); + + // Update comment + tags in place. + const updated = yield* stack.deploy( + Effect.gen(function* () { + return yield* HostedZone("Zone", { + name: zoneName, + comment: "updated comment", + tags: { env: "prod" }, + }); + }), + ); + expect(updated.id).toBe(zone.id); + expect(updated.comment).toBe("updated comment"); + + const observed2 = yield* route53.getHostedZone({ + Id: normalizeId(zone.id), + }); + expect(observed2.HostedZone.Config?.Comment).toBe("updated comment"); + + const tags2 = yield* route53.listTagsForResource({ + ResourceType: "hostedzone", + ResourceId: normalizeId(zone.id), + }); + const tagMap2 = Object.fromEntries( + (tags2.ResourceTagSet.Tags ?? []).map((t) => [t.Key, t.Value]), + ); + expect(tagMap2.env).toBe("prod"); + + yield* stack.destroy(); + yield* assertZoneGone(zone.id); + }), + { timeout: 180_000 }, +); + +const forceZoneName = "alchemy-hostedzone-force.alchemy."; + +test.provider( + "forceDestroy deletes leftover records before deleting the zone", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const zone = yield* stack.deploy( + Effect.gen(function* () { + return yield* HostedZone("ForceZone", { + name: forceZoneName, + forceDestroy: true, + }); + }), + ); + + // Seed a leftover record out of band so the zone is non-empty. + yield* route53 + .changeResourceRecordSets({ + HostedZoneId: normalizeId(zone.id), + ChangeBatch: { + Comment: "leftover record", + Changes: [ + { + Action: "UPSERT", + ResourceRecordSet: { + Name: `leftover.${forceZoneName}`, + Type: "TXT", + TTL: 60, + ResourceRecords: [{ Value: '"leftover"' }], + }, + }, + ], + }, + }) + .pipe(Effect.asVoid); + + // destroy() must purge the record then delete the zone. + yield* stack.destroy(); + yield* assertZoneGone(zone.id); + }), + { timeout: 180_000 }, +); + +test.provider( + "idempotent delete tolerates an already-gone zone", + () => + Effect.gen(function* () { + // Deleting a non-existent zone id should be a no-op (NoSuchHostedZone). + yield* route53.deleteHostedZone({ Id: "Z0000000000000NONEXIST" }).pipe( + Effect.asVoid, + Effect.catchTag("NoSuchHostedZone", () => Effect.void), + Effect.catchTag("InvalidInput", () => Effect.void), + ); + expect(true).toBe(true); + }), + { timeout: 60_000 }, +); diff --git a/packages/alchemy/test/AWS/Route53/RoutingPolicy.test.ts b/packages/alchemy/test/AWS/Route53/RoutingPolicy.test.ts new file mode 100644 index 0000000000..336e1fa258 --- /dev/null +++ b/packages/alchemy/test/AWS/Route53/RoutingPolicy.test.ts @@ -0,0 +1,121 @@ +import * as AWS from "@/AWS"; +import { HealthCheck, HostedZone, Record } from "@/AWS/Route53"; +import * as Test from "@/Test/Vitest"; +import * as route53 from "@distilled.cloud/aws/route-53"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +const { test } = Test.make({ providers: AWS.providers() }); + +const normalizeId = (id: string) => id.replace(/^\/hostedzone\//, ""); + +const findSet = ( + sets: route53.ResourceRecordSet[], + name: string, + setId: string, +) => + sets.find( + (s) => s.Name === name && s.SetIdentifier === setId && s.Type === "A", + ); + +const zoneName = "alchemy-route53-routing.alchemy."; + +test.provider( + "weighted, failover, and alias routing records", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const result = yield* stack.deploy( + Effect.gen(function* () { + const zone = yield* HostedZone("RoutingZone", { name: zoneName }); + + // AWS requires a non-alias PRIMARY failover record to have a health + // check. + const check = yield* HealthCheck("PrimaryCheck", { + type: "HTTP", + ipAddress: "1.1.1.1", + port: 80, + resourcePath: "/", + requestInterval: 30, + }); + + // Weighted pair — same name/type, distinct setIdentifier + weight. + yield* Record("Blue", { + hostedZoneId: zone.id, + name: `api.${zoneName}`, + type: "A", + ttl: 60, + records: ["1.2.3.4"], + setIdentifier: "blue", + weight: 90, + }); + yield* Record("Green", { + hostedZoneId: zone.id, + name: `api.${zoneName}`, + type: "A", + ttl: 60, + records: ["5.6.7.8"], + setIdentifier: "green", + weight: 10, + }); + + // Failover pair — PRIMARY gated on the health check. + yield* Record("Primary", { + hostedZoneId: zone.id, + name: `app.${zoneName}`, + type: "A", + ttl: 60, + records: ["1.1.1.1"], + setIdentifier: "primary", + failover: "PRIMARY", + healthCheckId: check.id, + }); + yield* Record("Secondary", { + hostedZoneId: zone.id, + name: `app.${zoneName}`, + type: "A", + ttl: 60, + records: ["2.2.2.2"], + setIdentifier: "secondary", + failover: "SECONDARY", + }); + + return { zoneId: zone.id }; + }), + ); + + const zoneId = normalizeId(result.zoneId); + + // Verify out of band. + const listed = yield* route53.listResourceRecordSets({ + HostedZoneId: zoneId, + MaxItems: 100, + }); + const sets = listed.ResourceRecordSets ?? []; + + const blue = findSet(sets, `api.${zoneName}`, "blue"); + const green = findSet(sets, `api.${zoneName}`, "green"); + expect(blue?.Weight).toBe(90); + expect(green?.Weight).toBe(10); + + const primary = findSet(sets, `app.${zoneName}`, "primary"); + const secondary = findSet(sets, `app.${zoneName}`, "secondary"); + expect(primary?.Failover).toBe("PRIMARY"); + expect(primary?.HealthCheckId).toBeDefined(); + expect(secondary?.Failover).toBe("SECONDARY"); + + // Destroy — this exercises the DELETE builder serializing policy fields + // (Weight, Failover, HealthCheckId). A mismatch would fail with + // InvalidChangeBatch. + yield* stack.destroy(); + + // The zone must be fully cleaned up (records deleted, then zone deleted). + const gone = yield* route53.getHostedZone({ Id: zoneId }).pipe( + Effect.map(() => false), + Effect.catchTag("NoSuchHostedZone", () => Effect.succeed(true)), + ); + expect(gone).toBe(true); + }), + { timeout: 300_000 }, +); diff --git a/packages/alchemy/test/AWS/S3/Bucket.test.ts b/packages/alchemy/test/AWS/S3/Bucket.test.ts index a8a4d3fb4c..bbd89d40da 100644 --- a/packages/alchemy/test/AWS/S3/Bucket.test.ts +++ b/packages/alchemy/test/AWS/S3/Bucket.test.ts @@ -1,4 +1,5 @@ import * as AWS from "@/AWS"; +import { Role } from "@/AWS/IAM"; import { Bucket } from "@/AWS/S3"; import * as Provider from "@/Provider"; import { State } from "@/State"; @@ -401,6 +402,546 @@ test.provider("list enumerates the deployed bucket", (stack) => }), ); +test.provider("versioning enable then suspend", (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const name = "alchemy-test-bucket-versioning"; + const bucket = yield* stack.deploy( + Bucket("VersioningBucket", { + bucketName: name, + versioning: "Enabled", + forceDestroy: true, + }), + ); + + const v1 = yield* S3.getBucketVersioning({ Bucket: bucket.bucketName }); + expect(v1.Status).toEqual("Enabled"); + + yield* stack.deploy( + Bucket("VersioningBucket", { + bucketName: name, + versioning: "Suspended", + forceDestroy: true, + }), + ); + + const v2 = yield* S3.getBucketVersioning({ Bucket: bucket.bucketName }); + expect(v2.Status).toEqual("Suspended"); + + yield* stack.destroy(); + yield* assertBucketDeleted(bucket.bucketName); + }), +); + +test.provider("encryption SSE-S3 set and update bucketKey", (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const name = "alchemy-test-bucket-encryption"; + const bucket = yield* stack.deploy( + Bucket("EncryptionBucket", { + bucketName: name, + encryption: { sseAlgorithm: "AES256" }, + forceDestroy: true, + }), + ); + + const e1 = yield* S3.getBucketEncryption({ Bucket: bucket.bucketName }); + expect( + e1.ServerSideEncryptionConfiguration?.Rules?.[0] + ?.ApplyServerSideEncryptionByDefault?.SSEAlgorithm, + ).toEqual("AES256"); + + yield* stack.deploy( + Bucket("EncryptionBucket", { + bucketName: name, + encryption: { sseAlgorithm: "AES256", bucketKeyEnabled: true }, + forceDestroy: true, + }), + ); + + const e2 = yield* S3.getBucketEncryption({ Bucket: bucket.bucketName }); + expect( + e2.ServerSideEncryptionConfiguration?.Rules?.[0]?.BucketKeyEnabled, + ).toEqual(true); + + yield* stack.destroy(); + yield* assertBucketDeleted(bucket.bucketName); + }), +); + +test.provider("public access block set, update, remove", (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const name = "alchemy-test-bucket-pab"; + const bucket = yield* stack.deploy( + Bucket("PabBucket", { + bucketName: name, + publicAccessBlock: { + blockPublicAcls: true, + ignorePublicAcls: true, + blockPublicPolicy: true, + restrictPublicBuckets: true, + }, + forceDestroy: true, + }), + ); + + const p1 = yield* S3.getPublicAccessBlock({ Bucket: bucket.bucketName }); + expect(p1.PublicAccessBlockConfiguration?.BlockPublicAcls).toEqual(true); + expect(p1.PublicAccessBlockConfiguration?.RestrictPublicBuckets).toEqual( + true, + ); + + yield* stack.deploy( + Bucket("PabBucket", { + bucketName: name, + publicAccessBlock: { + blockPublicAcls: true, + ignorePublicAcls: false, + blockPublicPolicy: true, + restrictPublicBuckets: false, + }, + forceDestroy: true, + }), + ); + + const p2 = yield* S3.getPublicAccessBlock({ Bucket: bucket.bucketName }); + expect(p2.PublicAccessBlockConfiguration?.RestrictPublicBuckets).toEqual( + false, + ); + + yield* stack.destroy(); + yield* assertBucketDeleted(bucket.bucketName); + }), +); + +test.provider("cors add rule then remove all", (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const name = "alchemy-test-bucket-cors"; + const bucket = yield* stack.deploy( + Bucket("CorsBucket", { + bucketName: name, + cors: [ + { + AllowedMethods: ["GET"], + AllowedOrigins: ["https://example.com"], + AllowedHeaders: ["*"], + MaxAgeSeconds: 3000, + }, + ], + forceDestroy: true, + }), + ); + + const c1 = yield* S3.getBucketCors({ Bucket: bucket.bucketName }); + expect(c1.CORSRules).toHaveLength(1); + + yield* stack.deploy( + Bucket("CorsBucket", { + bucketName: name, + cors: [ + { + AllowedMethods: ["GET"], + AllowedOrigins: ["https://example.com"], + AllowedHeaders: ["*"], + MaxAgeSeconds: 3000, + }, + { + AllowedMethods: ["PUT", "POST"], + AllowedOrigins: ["https://app.example.com"], + }, + ], + forceDestroy: true, + }), + ); + + const c2 = yield* S3.getBucketCors({ Bucket: bucket.bucketName }); + expect(c2.CORSRules).toHaveLength(2); + + yield* stack.deploy( + Bucket("CorsBucket", { + bucketName: name, + cors: [], + forceDestroy: true, + }), + ); + + const removed = yield* S3.getBucketCors({ Bucket: bucket.bucketName }).pipe( + Effect.map(() => "has-cors" as const), + Effect.catchTag("NoSuchCORSConfiguration", () => + Effect.succeed("no-cors" as const), + ), + ); + expect(removed).toEqual("no-cors"); + + yield* stack.destroy(); + yield* assertBucketDeleted(bucket.bucketName); + }), +); + +test.provider("lifecycle add rule then remove", (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const name = "alchemy-test-bucket-lifecycle"; + const bucket = yield* stack.deploy( + Bucket("LifecycleBucket", { + bucketName: name, + lifecycleRules: [ + { + ID: "expire-logs", + Status: "Enabled", + Filter: { Prefix: "logs/" }, + Expiration: { Days: 30 }, + AbortIncompleteMultipartUpload: { DaysAfterInitiation: 7 }, + }, + ], + forceDestroy: true, + }), + ); + + const l1 = yield* S3.getBucketLifecycleConfiguration({ + Bucket: bucket.bucketName, + }); + expect(l1.Rules).toHaveLength(1); + expect(l1.Rules?.[0]?.Expiration?.Days).toEqual(30); + + yield* stack.deploy( + Bucket("LifecycleBucket", { + bucketName: name, + lifecycleRules: [], + forceDestroy: true, + }), + ); + + // Lifecycle config is eventually consistent — the rule can linger on reads + // for a few seconds after deleteBucketLifecycle. Retry until it clears. + const removed = yield* S3.getBucketLifecycleConfiguration({ + Bucket: bucket.bucketName, + }).pipe( + Effect.map(() => "has-lifecycle" as const), + Effect.catchTag("NoSuchLifecycleConfiguration", () => + Effect.succeed("no-lifecycle" as const), + ), + Effect.repeat({ + schedule: Schedule.spaced("3 seconds"), + until: (r) => r === "no-lifecycle", + times: 10, + }), + ); + expect(removed).toEqual("no-lifecycle"); + + yield* stack.destroy(); + yield* assertBucketDeleted(bucket.bucketName); + }), +); + +test.provider("ownership controls and website hosting", (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const name = "alchemy-test-bucket-website"; + const bucket = yield* stack.deploy( + Bucket("WebsiteBucket", { + bucketName: name, + objectOwnership: "BucketOwnerPreferred", + website: { + indexDocument: { suffix: "index.html" }, + errorDocument: { key: "error.html" }, + }, + forceDestroy: true, + }), + ); + + const own = yield* S3.getBucketOwnershipControls({ + Bucket: bucket.bucketName, + }); + expect(own.OwnershipControls?.Rules?.[0]?.ObjectOwnership).toEqual( + "BucketOwnerPreferred", + ); + + const web = yield* S3.getBucketWebsite({ Bucket: bucket.bucketName }); + expect(web.IndexDocument?.Suffix).toEqual("index.html"); + expect(web.ErrorDocument?.Key).toEqual("error.html"); + + // In-place update of the index document; website config is in-place + // updatable. (Omitting the prop leaves the config untouched — to clear it + // a user removes the resource; we never silently drop unmanaged config.) + yield* stack.deploy( + Bucket("WebsiteBucket", { + bucketName: name, + objectOwnership: "BucketOwnerPreferred", + website: { + indexDocument: { suffix: "home.html" }, + errorDocument: { key: "error.html" }, + }, + forceDestroy: true, + }), + ); + + const web2 = yield* S3.getBucketWebsite({ Bucket: bucket.bucketName }); + expect(web2.IndexDocument?.Suffix).toEqual("home.html"); + + yield* stack.destroy(); + yield* assertBucketDeleted(bucket.bucketName); + }), +); + +test.provider("transfer acceleration and request payment", (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const name = "alchemy-test-bucket-accel-pay"; + const bucket = yield* stack.deploy( + Bucket("AccelPayBucket", { + bucketName: name, + transferAcceleration: "Enabled", + requestPayer: "Requester", + forceDestroy: true, + }), + ); + + const a1 = yield* S3.getBucketAccelerateConfiguration({ + Bucket: bucket.bucketName, + }); + expect(a1.Status).toEqual("Enabled"); + + const r1 = yield* S3.getBucketRequestPayment({ Bucket: bucket.bucketName }); + expect(r1.Payer).toEqual("Requester"); + + yield* stack.deploy( + Bucket("AccelPayBucket", { + bucketName: name, + transferAcceleration: "Suspended", + requestPayer: "BucketOwner", + forceDestroy: true, + }), + ); + + const a2 = yield* S3.getBucketAccelerateConfiguration({ + Bucket: bucket.bucketName, + }); + expect(a2.Status).toEqual("Suspended"); + + const r2 = yield* S3.getBucketRequestPayment({ Bucket: bucket.bucketName }); + expect(r2.Payer).toEqual("BucketOwner"); + + yield* stack.destroy(); + yield* assertBucketDeleted(bucket.bucketName); + }), +); + +test.provider("object lock default retention", (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const name = "alchemy-test-bucket-objlock-retention"; + const bucket = yield* stack.deploy( + Bucket("ObjLockRetentionBucket", { + bucketName: name, + objectLockEnabled: true, + objectLockConfiguration: { mode: "GOVERNANCE", days: 1 }, + forceDestroy: true, + }), + ); + + const cfg = yield* S3.getObjectLockConfiguration({ + Bucket: bucket.bucketName, + }); + expect(cfg.ObjectLockConfiguration?.Rule?.DefaultRetention?.Mode).toEqual( + "GOVERNANCE", + ); + expect(cfg.ObjectLockConfiguration?.Rule?.DefaultRetention?.Days).toEqual( + 1, + ); + + yield* stack.destroy(); + yield* assertBucketDeleted(bucket.bucketName); + }), +); + +test.provider("intelligent tiering add and remove id", (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const name = "alchemy-test-bucket-int-tiering"; + const bucket = yield* stack.deploy( + Bucket("IntTieringBucket", { + bucketName: name, + intelligentTiering: [ + { + Id: "archive", + Status: "Enabled", + Tierings: [{ Days: 90, AccessTier: "ARCHIVE_ACCESS" }], + }, + ], + forceDestroy: true, + }), + ); + + const t1 = yield* S3.getBucketIntelligentTieringConfiguration({ + Bucket: bucket.bucketName, + Id: "archive", + }); + expect(t1.IntelligentTieringConfiguration?.Status).toEqual("Enabled"); + + yield* stack.deploy( + Bucket("IntTieringBucket", { + bucketName: name, + intelligentTiering: [], + forceDestroy: true, + }), + ); + + const removed = yield* S3.getBucketIntelligentTieringConfiguration({ + Bucket: bucket.bucketName, + Id: "archive", + }).pipe( + Effect.map(() => "has-config" as const), + Effect.catchTag("NoSuchConfiguration", () => + Effect.succeed("no-config" as const), + ), + ); + expect(removed).toEqual("no-config"); + + yield* stack.destroy(); + yield* assertBucketDeleted(bucket.bucketName); + }), +); + +test.provider("explicit bucket policy prop", (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const name = "alchemy-test-bucket-policy-prop"; + const bucketArn = `arn:aws:s3:::${name}`; + const bucket = yield* stack.deploy( + Bucket("PolicyPropBucket", { + bucketName: name, + policy: [ + { + Sid: "AllowCloudFront", + Effect: "Allow", + Principal: { Service: "cloudfront.amazonaws.com" }, + Action: ["s3:GetObject"], + Resource: [`${bucketArn}/*`], + }, + ], + forceDestroy: true, + }), + ); + + const policy = yield* S3.getBucketPolicy({ + Bucket: bucket.bucketName, + }).pipe(Effect.map((r) => JSON.parse(r.Policy!))); + expect(policy.Statement[0].Sid).toEqual("AllowCloudFront"); + + yield* stack.destroy(); + yield* assertBucketDeleted(bucket.bucketName); + }), +); + +// Replication needs an IAM role S3 can assume + a versioned destination bucket. +// The test provisions all of it (role + dest bucket) so it is self-contained. +test.provider( + "replication configuration", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const dest = "alchemy-test-bucket-replication-dest"; + const src = "alchemy-test-bucket-replication-src"; + + const buckets = yield* stack.deploy( + Effect.gen(function* () { + const destBucket = yield* Bucket("ReplDestBucket", { + bucketName: dest, + versioning: "Enabled", + forceDestroy: true, + }); + const replRole = yield* Role("ReplRole", { + roleName: "alchemy-test-s3-replication-role", + assumeRolePolicyDocument: { + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { Service: "s3.amazonaws.com" }, + Action: ["sts:AssumeRole"], + }, + ], + }, + inlinePolicies: { + Replication: { + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: ["s3:GetReplicationConfiguration", "s3:ListBucket"], + Resource: [`arn:aws:s3:::${src}`], + }, + { + Effect: "Allow", + Action: [ + "s3:GetObjectVersionForReplication", + "s3:GetObjectVersionAcl", + "s3:GetObjectVersionTagging", + ], + Resource: [`arn:aws:s3:::${src}/*`], + }, + { + Effect: "Allow", + Action: [ + "s3:ReplicateObject", + "s3:ReplicateDelete", + "s3:ReplicateTags", + ], + Resource: [`arn:aws:s3:::${dest}/*`], + }, + ], + }, + }, + }); + const srcBucket = yield* Bucket("ReplSrcBucket", { + bucketName: src, + versioning: "Enabled", + replication: { + role: replRole.roleArn, + rules: [ + { + ID: "replicate-all", + Status: "Enabled", + Priority: 1, + Filter: {}, + DeleteMarkerReplication: { Status: "Disabled" }, + Destination: { Bucket: `arn:aws:s3:::${dest}` }, + }, + ], + }, + forceDestroy: true, + }); + return { srcBucket, destBucket, roleArn: replRole.roleArn }; + }), + ); + + const repl = yield* S3.getBucketReplication({ + Bucket: buckets.srcBucket.bucketName, + }); + expect(repl.ReplicationConfiguration?.Role).toEqual(buckets.roleArn); + expect(repl.ReplicationConfiguration?.Rules).toHaveLength(1); + + yield* stack.destroy(); + yield* assertBucketDeleted(buckets.srcBucket.bucketName); + yield* assertBucketDeleted(buckets.destBucket.bucketName); + }), + { timeout: 180_000 }, +); + class BucketStillExists extends Data.TaggedError("BucketStillExists") {} const assertBucketDeleted = Effect.fn(function* (bucketName: string) { diff --git a/packages/alchemy/test/AWS/SQS/Queue.test.ts b/packages/alchemy/test/AWS/SQS/Queue.test.ts index aa1081ad41..f24f208106 100644 --- a/packages/alchemy/test/AWS/SQS/Queue.test.ts +++ b/packages/alchemy/test/AWS/SQS/Queue.test.ts @@ -325,6 +325,218 @@ test.provider( { timeout: 120_000 }, ); +test.provider( + "DLQ redrive policy round-trips and can be removed", + (stack) => + Effect.gen(function* () { + // Create the DLQ and source together, keeping both deployed across + // steps to avoid the engine replace+remove-dependency deadlock. + const deployBoth = (withRedrive: boolean) => + stack.deploy( + Effect.gen(function* () { + const dlq = yield* Queue("RedriveDLQ"); + const source = yield* Queue("RedriveSource", { + ...(withRedrive + ? { + redrivePolicy: { + deadLetterTargetArn: dlq.queueArn, + maxReceiveCount: 3, + }, + } + : {}), + }); + return { dlq, source }; + }), + ); + + const { source } = yield* deployBoth(true); + + yield* waitForQueueAttributePredicate(source.queueUrl, (attrs) => { + if (!attrs.RedrivePolicy) return false; + const parsed = JSON.parse(attrs.RedrivePolicy); + return parsed.maxReceiveCount === 3; + }); + + // Remove the redrive policy on update; it must be cleared. + const { source: updated } = yield* deployBoth(false); + yield* waitForQueueAttributePredicate( + updated.queueUrl, + (attrs) => !attrs.RedrivePolicy, + ); + + yield* stack.destroy(); + yield* assertQueueDeleted(source.queueUrl); + }), + { timeout: 120_000 }, +); + +test.provider( + "redriveAllowPolicy is set on the dead-letter queue", + (stack) => + Effect.gen(function* () { + const { dlq } = yield* stack.deploy( + Effect.gen(function* () { + const source = yield* Queue("AllowSource"); + const dlq = yield* Queue("AllowDLQ", { + redriveAllowPolicy: { + redrivePermission: "byQueue", + sourceQueueArns: [source.queueArn], + }, + }); + return { source, dlq }; + }), + ); + + yield* waitForQueueAttributePredicate(dlq.queueUrl, (attrs) => { + if (!attrs.RedriveAllowPolicy) return false; + const parsed = JSON.parse(attrs.RedriveAllowPolicy); + return parsed.redrivePermission === "byQueue"; + }); + + yield* stack.destroy(); + yield* assertQueueDeleted(dlq.queueUrl); + }), + { timeout: 120_000 }, +); + +test.provider("SSE-SQS encryption enables sqs-managed key", (stack) => + Effect.gen(function* () { + const queue = yield* stack.deploy( + Effect.gen(function* () { + return yield* Queue("SseSqsQueue", { sqsManagedSseEnabled: true }); + }), + ); + + yield* waitForQueueAttributeMatch(queue.queueUrl, { + SqsManagedSseEnabled: "true", + }); + + yield* stack.destroy(); + yield* assertQueueDeleted(queue.queueUrl); + }), +); + +test.provider("SSE-KMS encryption with AWS-managed key", (stack) => + Effect.gen(function* () { + const queue = yield* stack.deploy( + Effect.gen(function* () { + return yield* Queue("KmsQueue", { + kmsMasterKeyId: "alias/aws/sqs", + kmsDataKeyReusePeriodSeconds: 300, + }); + }), + ); + + yield* waitForQueueAttributeMatch(queue.queueUrl, { + KmsMasterKeyId: "alias/aws/sqs", + KmsDataKeyReusePeriodSeconds: "300", + }); + + yield* stack.destroy(); + yield* assertQueueDeleted(queue.queueUrl); + }), +); + +test.provider( + "kmsMasterKeyId and sqsManagedSseEnabled together fail fast", + (stack) => + Effect.gen(function* () { + const result = yield* stack + .deploy( + Effect.gen(function* () { + return yield* Queue("ConflictQueue", { + kmsMasterKeyId: "alias/aws/sqs", + sqsManagedSseEnabled: true, + }); + }), + ) + .pipe(Effect.flip); + + // The typed validation error surfaces (possibly wrapped by the engine). + expect(JSON.stringify(result)).toContain("SqsEncryptionConflict"); + + yield* stack.destroy(); + }), +); + +test.provider( + "user tags coexist with internal tags and can be removed", + (stack) => + Effect.gen(function* () { + const withTags = yield* stack.deploy( + Effect.gen(function* () { + return yield* Queue("TaggedQueue", { + tags: { team: "payments", env: "test" }, + }); + }), + ); + + const tags1 = yield* SQS.listQueueTags({ QueueUrl: withTags.queueUrl }); + expect(tags1.Tags?.team).toEqual("payments"); + expect(tags1.Tags?.env).toEqual("test"); + expect(tags1.Tags?.["alchemy::id"]).toBeDefined(); + + // Remove one tag, change another. + const updated = yield* stack.deploy( + Effect.gen(function* () { + return yield* Queue("TaggedQueue", { + tags: { team: "platform" }, + }); + }), + ); + + yield* Effect.gen(function* () { + const tags = yield* SQS.listQueueTags({ QueueUrl: updated.queueUrl }); + const t = tags.Tags ?? {}; + if (t.team !== "platform" || t.env !== undefined) { + return yield* Effect.fail(new QueueAttributesNotReady()); + } + // internal tags survive untag. + expect(t["alchemy::id"]).toBeDefined(); + }).pipe( + Effect.retry({ + while: (e) => e._tag === "QueueAttributesNotReady", + schedule: Schedule.fixed("1 second").pipe( + Schedule.both(Schedule.recurs(20)), + ), + }), + ); + + yield* stack.destroy(); + yield* assertQueueDeleted(withTags.queueUrl); + }), + { timeout: 120_000 }, +); + +test.provider( + "FIFO source with FIFO dead-letter queue (no type mismatch)", + (stack) => + Effect.gen(function* () { + const { source } = yield* stack.deploy( + Effect.gen(function* () { + const dlq = yield* Queue("FifoDLQ", { fifo: true }); + const source = yield* Queue("FifoSource", { + fifo: true, + redrivePolicy: { + deadLetterTargetArn: dlq.queueArn, + maxReceiveCount: 5, + }, + }); + return { dlq, source }; + }), + ); + + yield* waitForQueueAttributePredicate(source.queueUrl, (attrs) => { + if (!attrs.RedrivePolicy) return false; + return JSON.parse(attrs.RedrivePolicy).maxReceiveCount === 5; + }); + + yield* stack.destroy(); + yield* assertQueueDeleted(source.queueUrl); + }), + { timeout: 120_000 }, +); + class QueueNotListed extends Data.TaggedError("QueueNotListed") {} class QueueStillExists extends Data.TaggedError("QueueStillExists") {} @@ -386,6 +598,29 @@ const waitForQueueAttributeMatch = Effect.fn(function* ( ); }); +/** Poll until a predicate over the queue's attributes holds. */ +const waitForQueueAttributePredicate = Effect.fn(function* ( + queueUrl: string, + predicate: (attrs: Record) => boolean, +) { + yield* Effect.gen(function* () { + const result = yield* SQS.getQueueAttributes({ + QueueUrl: queueUrl, + AttributeNames: ["All"], + }); + if (!predicate(result.Attributes ?? {})) { + return yield* Effect.fail(new QueueAttributesNotReady()); + } + }).pipe( + Effect.retry({ + while: (e) => e._tag === "QueueAttributesNotReady", + schedule: Schedule.fixed("1 second").pipe( + Schedule.both(Schedule.recurs(40)), + ), + }), + ); +}); + const waitForQueueMessages = Effect.fn(function* ( queueUrl: string, count: number,