From 5de6ac4424fe4312c5efe59b17bc04363d68efb1 Mon Sep 17 00:00:00 2001 From: Soham Kulkarni Date: Wed, 19 Feb 2025 13:06:03 -0800 Subject: [PATCH 1/4] Add L2 Constructs for TableBucket and TableBucketPolicy cr: https://code.amazon.com/reviews/CR-179393336 --- .../aws-s3tables/lib/table-bucket-policy.ts | 70 +++++ .../aws-s3tables/lib/table-bucket.ts | 278 ++++++++++++++++++ packages/aws-cdk-lib/aws-s3tables/lib/util.ts | 93 ++++++ 3 files changed, 441 insertions(+) create mode 100644 packages/aws-cdk-lib/aws-s3tables/lib/table-bucket-policy.ts create mode 100644 packages/aws-cdk-lib/aws-s3tables/lib/table-bucket.ts create mode 100644 packages/aws-cdk-lib/aws-s3tables/lib/util.ts diff --git a/packages/aws-cdk-lib/aws-s3tables/lib/table-bucket-policy.ts b/packages/aws-cdk-lib/aws-s3tables/lib/table-bucket-policy.ts new file mode 100644 index 0000000000000..d8a65d01b9e57 --- /dev/null +++ b/packages/aws-cdk-lib/aws-s3tables/lib/table-bucket-policy.ts @@ -0,0 +1,70 @@ +import { Construct } from 'constructs'; +import { CfnTableBucketPolicy } from './s3tables.generated'; +import { TableBucket } from './table-bucket'; +import * as iam from '../../aws-iam'; +import { PolicyDocument } from '../../aws-iam'; +import { Resource, ValidationError } from '../../core'; + +type TableBucketPolicyPropsWithArn = { + tableBucketArn: string; + tableBucket?: never; + resourcePolicy?: iam.Policy; +}; + +type TableBucketPolicyPropsWithBucket = { + tableBucketArn?: never; + tableBucket: TableBucket; + resourcePolicy?: iam.Policy; +}; + +/** + * Parameters for constructing a TableBucketPolicy + * + * This supports two options: + * 1. tableBucketArn along with the IAM Policy + * 2. TableBucket entity along with the IAM Policy + */ +export type TableBucketPolicyProps = + | TableBucketPolicyPropsWithArn + | TableBucketPolicyPropsWithBucket; + +function isArnProps(props: TableBucketPolicyProps): boolean { + return 'tableBucketArn' in props; +} + +/** + * A Bucket Policy for S3 TableBuckets. + * + * You will almost never need to use this construct directly. + * Instead, TableBucket.addToResourcePolicy can be used to add more policies to your bucket directly + */ +export class TableBucketPolicy extends Resource { + public readonly policy: CfnTableBucketPolicy; + /** + * A policy document containing permissions to add to the specified table bucket. + */ + public readonly document = new PolicyDocument(); + + constructor(scope: Construct, id: string, props: TableBucketPolicyProps) { + super(scope, id); + let tableBucketArn; + if (isArnProps(props)) { + tableBucketArn = props.tableBucketArn; + } else { + tableBucketArn = props.tableBucket?._resource.attrTableBucketArn; + } + + if (!tableBucketArn) { + throw new ValidationError('Expected either tableBucketArn or tableBucket entity to be provided', this); + } + + if (props.resourcePolicy?.document) { + this.document = props.resourcePolicy.document; + } + + this.policy = new CfnTableBucketPolicy(this, id, { + tableBucketArn, + resourcePolicy: this.document, + }); + } +} diff --git a/packages/aws-cdk-lib/aws-s3tables/lib/table-bucket.ts b/packages/aws-cdk-lib/aws-s3tables/lib/table-bucket.ts new file mode 100644 index 0000000000000..77d5cca49bad7 --- /dev/null +++ b/packages/aws-cdk-lib/aws-s3tables/lib/table-bucket.ts @@ -0,0 +1,278 @@ +import { EOL } from 'os'; +import { Construct } from 'constructs'; +import UnreferencedFileRemovalProperty = CfnTableBucket.UnreferencedFileRemovalProperty; +import * as s3tables from './index'; +import { CfnTableBucket } from './s3tables.generated'; +import { TableBucketPolicy } from './table-bucket-policy'; +import { validateTableBucketAttributes, S3_TABLES_SERVICE } from './util'; +import * as iam from '../../aws-iam'; +import { Resource, IResource, Token, UnscopedValidationError, ArnFormat } from '../../core'; + +/** + * Interface definition for S3 Table Buckets + */ +export interface ITableBucket extends IResource { + /** + * The ARN of the bucket. + * @attribute + */ + readonly tableBucketArn: string; + + /** + * The name of the bucket. + * @attribute + */ + readonly tableBucketName: string; +} + +export abstract class TableBucketBase extends Resource implements ITableBucket { + public abstract readonly tableBucketArn: string; + public abstract readonly tableBucketName: string; +} + +/** + * Parameters for constructing a TableBucket + */ +export interface TableBucketProps { + /** + * Name of the S3 TableBucket. + * @link https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-tables-buckets-naming.html#table-buckets-naming-rules + */ + bucketName: string; + /** + * Unreferenced file removal settings for the S3 TableBucket. + * @link https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-s3tables-tablebucket-unreferencedfileremoval.html + */ + unreferencedFileRemoval?: UnreferencedFileRemovalProperty; + /** + * AWS region that the table bucket exists in. + */ + region?: string; + /** + * AWS Account ID of the table bucket owner. + */ + account?: string; +} + +/** + * Everything needed to reference a specific table bucket. + * At a minimum, we need the tableBucketName, region, and account. + */ +export class TableBucketAttributes { + readonly region?: string; + readonly account?: string; + readonly tableBucketName?: string; + readonly tableBucketArn?: string; +} + +/** + * An S3 table bucket with helpers for associated resource policies + * + * This bucket may not yet have all features that exposed by the underlying CfnTableBucket. + * + * @example + * const tableBucket = new TableBucket(scope, 'ExampleTableBucket', { + * bucketName: 'example-bucket', + * // Optional fields: + * unreferencedFileRemoval: { + * noncurrentDays: 123, + * status: 'status', + * unreferencedDays: 123, + * }, + * }); + */ +export class TableBucket extends TableBucketBase { + /** + * Creates a TableBucket construct that represents an external table bucket. + * + * @param scope The parent creating construct (usually `this`). + * @param id The construct's name. + * @param attrs A `TableBucketAttributes` object. Can be manually created. + */ + public static fromTableBucketAttributes( + scope: Construct, + id: string, + attrs: TableBucketAttributes, + ): ITableBucket { + const { tableBucketName, region, account, tableBucketArn } = validateTableBucketAttributes(scope, attrs); + TableBucket.validateBucketName(tableBucketName); + class Import extends TableBucketBase { + public readonly tableBucketName = tableBucketName!; + public readonly tableBucketArn = tableBucketArn; + public policy?: TableBucketPolicy = undefined; + + /** + * Exports this bucket from the stack. + */ + public export() { + return attrs; + } + } + + return new Import(scope, id, { + account: account, + region: region, + physicalName: tableBucketName, + }); + } + + /** + * Thrown an exception if the given table bucket name is not valid. + * + * @param physicalName name of the bucket. + */ + public static validateBucketName( + physicalName: string, + ): void { + const bucketName = physicalName; + if (!bucketName || Token.isUnresolved(bucketName)) { + // the name is a late-bound value, not a defined string, so skip validation + return; + } + + const errors: string[] = []; + + // Length validation + if (bucketName.length < 3 || bucketName.length > 63) { + errors.push( + 'Bucket name must be at least 3 and no more than 63 characters', + ); + } + + // Character set validation + const illegalCharsetRegEx = /[^a-z0-9-]/; + const allowedEdgeCharsetRegEx = /[a-z0-9]/; + + const illegalCharMatch = bucketName.match(illegalCharsetRegEx); + if (illegalCharMatch) { + errors.push( + 'Bucket name must only contain lowercase characters, numbers, and hyphens (-)' + + ` (offset: ${illegalCharMatch.index})`, + ); + } + + // Edge character validation + if (!allowedEdgeCharsetRegEx.test(bucketName.charAt(0))) { + errors.push( + 'Bucket name must start with a lowercase letter or number (offset: 0)', + ); + } + if ( + !allowedEdgeCharsetRegEx.test(bucketName.charAt(bucketName.length - 1)) + ) { + errors.push( + `Bucket name must end with a lowercase letter or number (offset: ${ + bucketName.length - 1 + })`, + ); + } + + // Forbidden prefixes + const forbiddenPrefixes = ['xn--', 'sthree-', 'amzn-s3-demo-']; + for (const prefix of forbiddenPrefixes) { + if (bucketName.toLowerCase().startsWith(prefix)) { + errors.push(`Bucket name must not start with the prefix '${prefix}'`); + } + } + + // Forbidden suffixes + const forbiddenSuffixes = ['-s3alias', '--ol-s3', '--x-s3']; + for (const suffix of forbiddenSuffixes) { + if (bucketName.toLowerCase().endsWith(suffix)) { + errors.push(`Bucket name must not end with the suffix '${suffix}'`); + } + } + + // IP address format check + if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(bucketName)) { + errors.push('Bucket name must not resemble an IP address'); + } + + if (errors.length > 0) { + throw new UnscopedValidationError( + `Invalid S3 bucket name (value: ${bucketName})${EOL}${errors.join(EOL)}`, + ); + } + } + + /** + * The underlying CfnTableBucket L1 resource + */ + public readonly _resource: s3tables.CfnTableBucket; + /** + * The resource policy for this tableBucket. + * See TableBucket#addToResourcePolicy + */ + public resourcePolicy: TableBucketPolicy | undefined; + + /** + * The unique Amazon Resource Name (arn) of this table bucket + */ + public readonly tableBucketArn: string; + /** + * The name of this table bucket + */ + public readonly tableBucketName: string; + + constructor(scope: Construct, id: string, props: TableBucketProps) { + super(scope, id, { + physicalName: props.bucketName, + }); + + const resource = new s3tables.CfnTableBucket(this, id, { + tableBucketName: props.bucketName, + unreferencedFileRemoval: props.unreferencedFileRemoval, + }); + this._resource = resource; + + this.tableBucketName = this.getResourceNameAttribute(resource.ref); + this.tableBucketArn = this.getResourceArnAttribute( + resource.attrTableBucketArn, + { + region: props.region, + account: props.account, + service: S3_TABLES_SERVICE, + resource: 'bucket', + resourceName: this.physicalName, + arnFormat: ArnFormat.SLASH_RESOURCE_NAME, + }, + ); + } + + /** + * Adds a statement to the resource policy for a principal (i.e. + * account/role/service) to perform actions on this table bucket and/or its + * contents. Use `tableBucketArn` and `arnForObjects(keys)` to obtain ARNs for + * this bucket or objects. + * + * Note that the policy statement may or may not be added to the policy. + * For example, when an `IBucket` is created from an existing bucket, + * it's not possible to tell whether the bucket already has a policy + * attached, let alone to re-use that policy to add more statements to it. + * So it's safest to do nothing in these cases. + * + * @param permission the policy statement to be added to the bucket's + * policy. + * @returns metadata about the execution of this method. If the policy + * was not added, the value of `statementAdded` will be `false`. You + * should always check this value to make sure that the operation was + * actually carried out. Otherwise, synthesis and deploy will terminate + * silently, which may be confusing. + */ + public addToResourcePolicy( + permission: iam.PolicyStatement, + ): iam.AddToResourcePolicyResult { + if (!this.resourcePolicy) { + this.resourcePolicy = new TableBucketPolicy(this, 'Policy', { + tableBucket: this, + }); + } + + if (this.resourcePolicy) { + this.resourcePolicy.document.addStatements(permission); + return { statementAdded: true, policyDependable: this.resourcePolicy }; + } + + return { statementAdded: false }; + } +} diff --git a/packages/aws-cdk-lib/aws-s3tables/lib/util.ts b/packages/aws-cdk-lib/aws-s3tables/lib/util.ts new file mode 100644 index 0000000000000..764c910baa0c7 --- /dev/null +++ b/packages/aws-cdk-lib/aws-s3tables/lib/util.ts @@ -0,0 +1,93 @@ +import { IConstruct } from 'constructs'; +import { TableBucketAttributes } from './table-bucket'; +import * as cdk from '../../core'; +import { ArnFormat } from '../../core'; +import { ValidationError } from '../../core/lib/errors'; + +export const S3_TABLES_SERVICE = 's3tables'; + +export function parseTableBucketArn(construct: IConstruct, props: TableBucketAttributes): string { + // if we have an explicit table bucket ARN, use it. + if (props.tableBucketArn) { + return props.tableBucketArn; + } + + if (props.tableBucketName && props.region && props.account) { + return cdk.Stack.of(construct).formatArn({ + region: props.region, + account: props.account, + service: S3_TABLES_SERVICE, + resource: 'bucket', + resourceName: props.tableBucketName, + arnFormat: ArnFormat.SLASH_RESOURCE_NAME, + }); + } + + throw new ValidationError('Cannot determine bucket ARN. At least `tableBucketArn`, `bucketName`, and `account` is needed', construct); +} + +export function parseTableBucketName(construct: IConstruct, props: TableBucketAttributes): string { + // if we have an explicit bucket name, use it. + if (props.tableBucketName) { + return props.tableBucketName; + } + + // extract table bucket name from bucket arn + if (props.tableBucketArn) { + const bucketNameFromArn = cdk.Stack.of(construct).splitArn(props.tableBucketArn, cdk.ArnFormat.SLASH_RESOURCE_NAME).resourceName; + if (bucketNameFromArn) { + return bucketNameFromArn; + } + } + + // no table bucket name is okay since it's optional. + throw new ValidationError('tableBucketName is required and could not be inferred from context', construct); +} + +export function parseTableBucketRegion(construct: IConstruct, props: TableBucketAttributes): string { + // if we have an explicit bucket region, use it. + if (props.region) { + return props.region; + } + + // extract table bucket region from bucket arn + if (props.tableBucketArn) { + const regionFromArn = cdk.Stack.of(construct).splitArn(props.tableBucketArn, cdk.ArnFormat.SLASH_RESOURCE_NAME).region; + if (regionFromArn) { + return regionFromArn; + } + } + + // no table bucket region is okay since it's optional. + throw new ValidationError('Region is required and could not be inferred from context', construct); +} + +export function parseTableBucketAccount(construct: IConstruct, props: TableBucketAttributes): string { + // if we have an explicit bucket account, use it. + if (props.account) { + return props.account; + } + + // extract table bucket account from bucket arn + if (props.tableBucketArn) { + const accountFromArn = cdk.Stack.of(construct).splitArn(props.tableBucketArn, cdk.ArnFormat.SLASH_RESOURCE_NAME).account; + if (accountFromArn) { + return accountFromArn; + } + } + + throw new ValidationError('Account is required and could not be inferred from context', construct); +} + +/** + * @returns populated attributes from given scope and attributes + * @throws ValidationError if any of the required attribures are missing + */ +export function validateTableBucketAttributes(construct: IConstruct, props: TableBucketAttributes) { + return { + tableBucketName: parseTableBucketName(construct, props), + account: parseTableBucketAccount(construct, props), + region: parseTableBucketRegion(construct, props), + tableBucketArn: parseTableBucketArn(construct, props), + }; +} From 5369362e04c94eb429ae80fd52219230fffd3d21 Mon Sep 17 00:00:00 2001 From: Soham Kulkarni Date: Wed, 26 Feb 2025 10:44:07 -0800 Subject: [PATCH 2/4] Create alpha module for Tables L2 constructs cr: https://code.amazon.com/reviews/CR-179525855 --- .../@aws-cdk/aws-s3tables-alpha/.eslintrc.js | 4 + .../@aws-cdk/aws-s3tables-alpha/.gitignore | 23 ++ .../@aws-cdk/aws-s3tables-alpha/.npmignore | 28 +++ packages/@aws-cdk/aws-s3tables-alpha/LICENSE | 201 +++++++++++++++ packages/@aws-cdk/aws-s3tables-alpha/NOTICE | 2 + .../@aws-cdk/aws-s3tables-alpha/README.md | 54 ++++ .../aws-s3tables-alpha/jest.config.js | 2 + .../@aws-cdk/aws-s3tables-alpha/lib/index.ts | 7 + .../lib/table-bucket-policy.ts | 69 ++++++ .../aws-s3tables-alpha}/lib/table-bucket.ts | 230 ++++++++++++------ .../aws-s3tables-alpha}/lib/util.ts | 17 +- .../@aws-cdk/aws-s3tables-alpha/package.json | 105 ++++++++ .../aws-s3tables/lib/table-bucket-policy.ts | 70 ------ 13 files changed, 661 insertions(+), 151 deletions(-) create mode 100644 packages/@aws-cdk/aws-s3tables-alpha/.eslintrc.js create mode 100644 packages/@aws-cdk/aws-s3tables-alpha/.gitignore create mode 100644 packages/@aws-cdk/aws-s3tables-alpha/.npmignore create mode 100644 packages/@aws-cdk/aws-s3tables-alpha/LICENSE create mode 100644 packages/@aws-cdk/aws-s3tables-alpha/NOTICE create mode 100644 packages/@aws-cdk/aws-s3tables-alpha/README.md create mode 100644 packages/@aws-cdk/aws-s3tables-alpha/jest.config.js create mode 100644 packages/@aws-cdk/aws-s3tables-alpha/lib/index.ts create mode 100644 packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket-policy.ts rename packages/{aws-cdk-lib/aws-s3tables => @aws-cdk/aws-s3tables-alpha}/lib/table-bucket.ts (56%) rename packages/{aws-cdk-lib/aws-s3tables => @aws-cdk/aws-s3tables-alpha}/lib/util.ts (77%) create mode 100644 packages/@aws-cdk/aws-s3tables-alpha/package.json delete mode 100644 packages/aws-cdk-lib/aws-s3tables/lib/table-bucket-policy.ts diff --git a/packages/@aws-cdk/aws-s3tables-alpha/.eslintrc.js b/packages/@aws-cdk/aws-s3tables-alpha/.eslintrc.js new file mode 100644 index 0000000000000..73d2505a85a7f --- /dev/null +++ b/packages/@aws-cdk/aws-s3tables-alpha/.eslintrc.js @@ -0,0 +1,4 @@ +const baseConfig = require('@aws-cdk/cdk-build-tools/config/eslintrc'); +baseConfig.parserOptions.project = __dirname + '/tsconfig.json'; +baseConfig.rules['import/order'] = 'off'; +module.exports = baseConfig; diff --git a/packages/@aws-cdk/aws-s3tables-alpha/.gitignore b/packages/@aws-cdk/aws-s3tables-alpha/.gitignore new file mode 100644 index 0000000000000..3e895fc51317c --- /dev/null +++ b/packages/@aws-cdk/aws-s3tables-alpha/.gitignore @@ -0,0 +1,23 @@ +*.js +*.js.map +*.d.ts +tsconfig.json +node_modules +*.generated.ts +dist +.jsii + +.LAST_BUILD +.nyc_output +coverage +nyc.config.js +.LAST_PACKAGE +*.snk +!.eslintrc.js +!jest.config.js + +junit.xml +!**/*.snapshot/**/asset.*/*.js +!**/*.snapshot/**/asset.*/*.d.ts + +!**/*.snapshot/**/asset.*/** diff --git a/packages/@aws-cdk/aws-s3tables-alpha/.npmignore b/packages/@aws-cdk/aws-s3tables-alpha/.npmignore new file mode 100644 index 0000000000000..b94897de6fcce --- /dev/null +++ b/packages/@aws-cdk/aws-s3tables-alpha/.npmignore @@ -0,0 +1,28 @@ +# Don't include original .ts files when doing `npm pack` +*.ts +!*.d.ts +coverage +.nyc_output +*.tgz + +dist +.LAST_PACKAGE +.LAST_BUILD +!*.js + +# Include .jsii +!.jsii + +*.snk + +*.tsbuildinfo + +tsconfig.json +.eslintrc.js +jest.config.js + +# exclude cdk artifacts +**/cdk.out +junit.xml +!*.lit.ts +**/*.snapshot diff --git a/packages/@aws-cdk/aws-s3tables-alpha/LICENSE b/packages/@aws-cdk/aws-s3tables-alpha/LICENSE new file mode 100644 index 0000000000000..5ccf0c6780bab --- /dev/null +++ b/packages/@aws-cdk/aws-s3tables-alpha/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2018-2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/@aws-cdk/aws-s3tables-alpha/NOTICE b/packages/@aws-cdk/aws-s3tables-alpha/NOTICE new file mode 100644 index 0000000000000..cd0946c1cf193 --- /dev/null +++ b/packages/@aws-cdk/aws-s3tables-alpha/NOTICE @@ -0,0 +1,2 @@ +AWS Cloud Development Kit (AWS CDK) +Copyright 2018-2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/packages/@aws-cdk/aws-s3tables-alpha/README.md b/packages/@aws-cdk/aws-s3tables-alpha/README.md new file mode 100644 index 0000000000000..61b68decb27c4 --- /dev/null +++ b/packages/@aws-cdk/aws-s3tables-alpha/README.md @@ -0,0 +1,54 @@ +# Amazon S3 Tables Construct Library + + + +--- + +![cdk-constructs: Experimental](https://img.shields.io/badge/cdk--constructs-experimental-important.svg?style=for-the-badge) + +> The APIs of higher level constructs in this module are experimental and under active development. They are subject to non-backward compatible changes or removal in any future version. These are not subject to the [Semantic Versioning](https://semver.org/) model and breaking changes will be announced in the release notes. This means that while you may use them, you may need to update your source code when upgrading to a newer version of this package. + +--- + + + +## Amazon S3 Tables + +Amazon S3 Tables deliver the first cloud object store with built-in Apache Iceberg support and streamline storing tabular data at scale. + +[Product Page](https://aws.amazon.com/s3/features/tables/) | [User Guide](https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-tables.html) + + +## Usage + +### Define an S3 Table Bucket + +```ts +// Build a Table bucket +const tableBucket = new TableBucket(scope, 'ExampleTableBucket', { + tableBucketName: 'example-bucket-1', + // optional fields: + unreferencedFileRemoval: { + noncurrentDays: 123, + status: 'Enabled', + unreferencedDays: 123, + }, +}); + + // Add resource policy statements +const permissions = new iam.PolicyStatement({ + effect: Effect.ALLOW, + actions: ['s3tables:*'], + principals: [ new iam.ServicePrincipal('example.aws.internal') ], + resources: ['*'] +}); + +tableBucket.addResourcePolicy(permissions); +``` + +## Coming Soon + +L2 Construct support for: + +- Namespaces +- Tables diff --git a/packages/@aws-cdk/aws-s3tables-alpha/jest.config.js b/packages/@aws-cdk/aws-s3tables-alpha/jest.config.js new file mode 100644 index 0000000000000..3a2fd93a1228a --- /dev/null +++ b/packages/@aws-cdk/aws-s3tables-alpha/jest.config.js @@ -0,0 +1,2 @@ +const baseConfig = require('@aws-cdk/cdk-build-tools/config/jest.config'); +module.exports = baseConfig; diff --git a/packages/@aws-cdk/aws-s3tables-alpha/lib/index.ts b/packages/@aws-cdk/aws-s3tables-alpha/lib/index.ts new file mode 100644 index 0000000000000..18747863837b0 --- /dev/null +++ b/packages/@aws-cdk/aws-s3tables-alpha/lib/index.ts @@ -0,0 +1,7 @@ +// The index.ts files contains a list of files we want to +// include as part of the public API of this module. +// In general, all files including L2 classes will be listed here, +// while all files including only utility functions will be omitted from here. + +export * from './table-bucket'; +export * from './table-bucket-policy'; diff --git a/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket-policy.ts b/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket-policy.ts new file mode 100644 index 0000000000000..4b1d51df7f439 --- /dev/null +++ b/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket-policy.ts @@ -0,0 +1,69 @@ +import { Construct } from 'constructs'; +import { CfnTableBucketPolicy } from 'aws-cdk-lib/aws-s3tables'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import { PhysicalName, Resource } from 'aws-cdk-lib/core'; +import { ITableBucket } from './table-bucket'; + +/** + * Parameters for constructing a TableBucketPolicy + */ +export interface TableBucketPolicyProps { + /** + * Name of the table bucket policy + */ + readonly tableBucketPolicyName: string; + /** + * The associated table bucket + */ + readonly tableBucket: ITableBucket; + /** + * The policy document for the bucket's resource policy + * @default undefined An empty iam.PolicyDocument will be initialized + */ + readonly resourcePolicy?: iam.PolicyDocument; +} + +/** + * A Bucket Policy for S3 TableBuckets. + * + * You will almost never need to use this construct directly. + * Instead, TableBucket.addToResourcePolicy can be used to add more policies to your bucket directly + */ +export class TableBucketPolicy extends Resource { + /** + * The IAM PolicyDocument containing permissions represented by this policy. + */ + public readonly document: iam.PolicyDocument; + /** + * @internal The underlying policy resource. + */ + private readonly _resource: CfnTableBucketPolicy; + + constructor(scope: Construct, id: string, props: TableBucketPolicyProps) { + super(scope, id, { + physicalName: PhysicalName.GENERATE_IF_NEEDED, + }); + + // Use default policy if not provided with props + const resourcePolicy = props.resourcePolicy || new iam.PolicyDocument({}); + this.document = resourcePolicy; + + this._resource = new CfnTableBucketPolicy(this, id, { + tableBucketArn: props.tableBucket.tableBucketArn, + resourcePolicy: resourcePolicy.toJSON(), + }); + } + + /** + * Adds a statement to the resource policy for a principal (i.e. + * account/role/service) to perform actions on the associated table bucket and/or its + * contents. + * + * @param statement the policy statement to be added to the bucket's + * policy. + */ + public addToResourcePolicy(statement: iam.PolicyStatement) { + this.document.addStatements(statement); + this._resource.resourcePolicy = this.document.toJSON(); + } +} diff --git a/packages/aws-cdk-lib/aws-s3tables/lib/table-bucket.ts b/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket.ts similarity index 56% rename from packages/aws-cdk-lib/aws-s3tables/lib/table-bucket.ts rename to packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket.ts index 77d5cca49bad7..90decb5c40aec 100644 --- a/packages/aws-cdk-lib/aws-s3tables/lib/table-bucket.ts +++ b/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket.ts @@ -1,12 +1,12 @@ import { EOL } from 'os'; import { Construct } from 'constructs'; -import UnreferencedFileRemovalProperty = CfnTableBucket.UnreferencedFileRemovalProperty; -import * as s3tables from './index'; -import { CfnTableBucket } from './s3tables.generated'; +import * as s3tables from 'aws-cdk-lib/aws-s3tables'; import { TableBucketPolicy } from './table-bucket-policy'; import { validateTableBucketAttributes, S3_TABLES_SERVICE } from './util'; -import * as iam from '../../aws-iam'; -import { Resource, IResource, Token, UnscopedValidationError, ArnFormat } from '../../core'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import { Resource, IResource, Token, UnscopedValidationError, ArnFormat, PhysicalName } from 'aws-cdk-lib/core'; +import UnreferencedFileRemovalProperty = s3tables.CfnTableBucket.UnreferencedFileRemovalProperty; +import { addConstructMetadata } from 'aws-cdk-lib/core/lib/metadata-resource'; /** * Interface definition for S3 Table Buckets @@ -23,11 +23,83 @@ export interface ITableBucket extends IResource { * @attribute */ readonly tableBucketName: string; + + /** + * The resource policy for this tableBucket. + */ + readonly policy?: TableBucketPolicy; + + /** + * Adds a statement to the resource policy for a principal (i.e. + * account/role/service) to perform actions on this table bucket and/or its + * contents. Use `tableBucketArn` and `arnForObjects(keys)` to obtain ARNs for + * this bucket or objects. + * + * Note that the policy statement may or may not be added to the policy. + * For example, when an `ITableBucket` is created from an existing table bucket, + * it's not possible to tell whether the bucket already has a policy + * attached, let alone to re-use that policy to add more statements to it. + * So it's safest to do nothing in these cases. + * + * @param statement the policy statement to be added to the bucket's + * policy. + * @returns metadata about the execution of this method. If the policy + * was not added, the value of `statementAdded` will be `false`. You + * should always check this value to make sure that the operation was + * actually carried out. Otherwise, synthesis and deploy will terminate + * silently, which may be confusing. + */ + addToResourcePolicy(statement: iam.PolicyStatement): iam.AddToResourcePolicyResult; } -export abstract class TableBucketBase extends Resource implements ITableBucket { +abstract class TableBucketBase extends Resource implements ITableBucket { public abstract readonly tableBucketArn: string; public abstract readonly tableBucketName: string; + public abstract policy?: TableBucketPolicy; + + /** + * Indicates if a bucket resource policy should automatically created upon + * the first call to `addToResourcePolicy`. + */ + protected abstract autoCreatePolicy: boolean; + + /** + * Adds a statement to the resource policy for a principal (i.e. + * account/role/service) to perform actions on this table bucket and/or its + * contents. Use `tableBucketArn` and `arnForObjects(keys)` to obtain ARNs for + * this bucket or objects. + * + * Note that the policy statement may or may not be added to the policy. + * For example, when an `ITableBucket` is created from an existing table bucket, + * it's not possible to tell whether the bucket already has a policy + * attached, let alone to re-use that policy to add more statements to it. + * So it's safest to do nothing in these cases. + * + * @param statement the policy statement to be added to the bucket's + * policy. + * @returns metadata about the execution of this method. If the policy + * was not added, the value of `statementAdded` will be `false`. You + * should always check this value to make sure that the operation was + * actually carried out. Otherwise, synthesis and deploy will terminate + * silently, which may be confusing. + */ + public addToResourcePolicy( + statement: iam.PolicyStatement, + ): iam.AddToResourcePolicyResult { + if (!this.policy && this.autoCreatePolicy) { + this.policy = new TableBucketPolicy(this, 'Policy', { + tableBucket: this, + tableBucketPolicyName: `${this.tableBucketName}-policy`, + }); + } + + if (this.policy) { + this.policy.document.addStatements(statement); + return { statementAdded: true, policyDependable: this.policy }; + } + + return { statementAdded: false }; + } } /** @@ -38,30 +110,49 @@ export interface TableBucketProps { * Name of the S3 TableBucket. * @link https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-tables-buckets-naming.html#table-buckets-naming-rules */ - bucketName: string; + readonly tableBucketName: string; /** * Unreferenced file removal settings for the S3 TableBucket. * @link https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-s3tables-tablebucket-unreferencedfileremoval.html + * @default Enabled with default values + * @see https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-table-buckets-maintenance.html */ - unreferencedFileRemoval?: UnreferencedFileRemovalProperty; + readonly unreferencedFileRemoval?: UnreferencedFileRemovalProperty; /** * AWS region that the table bucket exists in. */ - region?: string; + readonly region: string; /** * AWS Account ID of the table bucket owner. */ - account?: string; + readonly account: string; } /** * Everything needed to reference a specific table bucket. - * At a minimum, we need the tableBucketName, region, and account. + * The tableBucketName, region, and account can be provided explicitly + * or will be inferred from the tableBucketArn */ -export class TableBucketAttributes { +export interface TableBucketAttributes { + /** + * AWS region this table bucket exists in + * @default region inferred from scope + */ readonly region?: string; + /** + * The accountId containing this table bucket + * @default account inferred from scope + */ readonly account?: string; + /** + * The table bucket name, unique per region + * @default tableBucketName inferred from arn + */ readonly tableBucketName?: string; + /** + * The table bucket's ARN. + * @default tableBucketArn constructed from region, account and tableBucketName are provided + */ readonly tableBucketArn?: string; } @@ -99,7 +190,8 @@ export class TableBucket extends TableBucketBase { class Import extends TableBucketBase { public readonly tableBucketName = tableBucketName!; public readonly tableBucketArn = tableBucketArn; - public policy?: TableBucketPolicy = undefined; + public readonly policy?: TableBucketPolicy; + protected autoCreatePolicy: boolean = false; /** * Exports this bucket from the stack. @@ -117,7 +209,7 @@ export class TableBucket extends TableBucketBase { } /** - * Thrown an exception if the given table bucket name is not valid. + * Throws an exception if the given table bucket name is not valid. * * @param physicalName name of the bucket. */ @@ -167,60 +259,92 @@ export class TableBucket extends TableBucketBase { ); } - // Forbidden prefixes - const forbiddenPrefixes = ['xn--', 'sthree-', 'amzn-s3-demo-']; - for (const prefix of forbiddenPrefixes) { - if (bucketName.toLowerCase().startsWith(prefix)) { - errors.push(`Bucket name must not start with the prefix '${prefix}'`); - } + if (errors.length > 0) { + throw new UnscopedValidationError( + `Invalid S3 bucket name (value: ${bucketName})${EOL}${errors.join(EOL)}`, + ); } + } - // Forbidden suffixes - const forbiddenSuffixes = ['-s3alias', '--ol-s3', '--x-s3']; - for (const suffix of forbiddenSuffixes) { - if (bucketName.toLowerCase().endsWith(suffix)) { - errors.push(`Bucket name must not end with the suffix '${suffix}'`); - } + /** + * Throws an exception if the given unreferencedFileRemovalProperty is not valid. + * + * @param unreferencedFileRemovalProperty configuration for the table bucket + */ + public static validateUnreferencedFileRemoval( + unreferencedFileRemovalProperty?: UnreferencedFileRemovalProperty, + ): void { + // Skip validation if property is not defined + if (!unreferencedFileRemovalProperty) { + return; } - // IP address format check - if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(bucketName)) { - errors.push('Bucket name must not resemble an IP address'); + const { noncurrentDays, status, unreferencedDays } = unreferencedFileRemovalProperty; + + const errors: string[] = []; + + if (noncurrentDays != undefined && noncurrentDays < 1) { + errors.push( + 'noncurrentDays must be at least 1', + ); + } + + if (unreferencedDays != undefined && unreferencedDays < 1) { + errors.push( + 'unreferencedDays must be at least 1', + ); + } + + const allowedStatus = ['Enabled', 'Disabled']; + if (status != undefined && !allowedStatus.includes(status)) { + errors.push( + 'status must be one of \'Enabled\' or \'Disabled\'', + ); } if (errors.length > 0) { throw new UnscopedValidationError( - `Invalid S3 bucket name (value: ${bucketName})${EOL}${errors.join(EOL)}`, + `Invalid UnreferencedFileRemovalProperty})${EOL}${errors.join(EOL)}`, ); } } /** + * @internal * The underlying CfnTableBucket L1 resource */ public readonly _resource: s3tables.CfnTableBucket; + /** * The resource policy for this tableBucket. - * See TableBucket#addToResourcePolicy */ - public resourcePolicy: TableBucketPolicy | undefined; + public readonly policy?: TableBucketPolicy; /** * The unique Amazon Resource Name (arn) of this table bucket */ public readonly tableBucketArn: string; + /** * The name of this table bucket */ public readonly tableBucketName: string; + protected autoCreatePolicy: boolean = true; + constructor(scope: Construct, id: string, props: TableBucketProps) { super(scope, id, { - physicalName: props.bucketName, + physicalName: PhysicalName.GENERATE_IF_NEEDED, }); + // Enhanced CDK Analytics Telemetry + addConstructMetadata(this, props); + + TableBucket.validateBucketName(props.tableBucketName); + TableBucket.validateUnreferencedFileRemoval(props.unreferencedFileRemoval); + const resource = new s3tables.CfnTableBucket(this, id, { - tableBucketName: props.bucketName, + tableBucketName: props.tableBucketName, unreferencedFileRemoval: props.unreferencedFileRemoval, }); this._resource = resource; @@ -238,41 +362,5 @@ export class TableBucket extends TableBucketBase { }, ); } - - /** - * Adds a statement to the resource policy for a principal (i.e. - * account/role/service) to perform actions on this table bucket and/or its - * contents. Use `tableBucketArn` and `arnForObjects(keys)` to obtain ARNs for - * this bucket or objects. - * - * Note that the policy statement may or may not be added to the policy. - * For example, when an `IBucket` is created from an existing bucket, - * it's not possible to tell whether the bucket already has a policy - * attached, let alone to re-use that policy to add more statements to it. - * So it's safest to do nothing in these cases. - * - * @param permission the policy statement to be added to the bucket's - * policy. - * @returns metadata about the execution of this method. If the policy - * was not added, the value of `statementAdded` will be `false`. You - * should always check this value to make sure that the operation was - * actually carried out. Otherwise, synthesis and deploy will terminate - * silently, which may be confusing. - */ - public addToResourcePolicy( - permission: iam.PolicyStatement, - ): iam.AddToResourcePolicyResult { - if (!this.resourcePolicy) { - this.resourcePolicy = new TableBucketPolicy(this, 'Policy', { - tableBucket: this, - }); - } - - if (this.resourcePolicy) { - this.resourcePolicy.document.addStatements(permission); - return { statementAdded: true, policyDependable: this.resourcePolicy }; - } - - return { statementAdded: false }; - } } + diff --git a/packages/aws-cdk-lib/aws-s3tables/lib/util.ts b/packages/@aws-cdk/aws-s3tables-alpha/lib/util.ts similarity index 77% rename from packages/aws-cdk-lib/aws-s3tables/lib/util.ts rename to packages/@aws-cdk/aws-s3tables-alpha/lib/util.ts index 764c910baa0c7..712403883e53b 100644 --- a/packages/aws-cdk-lib/aws-s3tables/lib/util.ts +++ b/packages/@aws-cdk/aws-s3tables-alpha/lib/util.ts @@ -1,8 +1,7 @@ import { IConstruct } from 'constructs'; import { TableBucketAttributes } from './table-bucket'; -import * as cdk from '../../core'; -import { ArnFormat } from '../../core'; -import { ValidationError } from '../../core/lib/errors'; +import * as cdk from 'aws-cdk-lib/core'; +import * as errors from 'aws-cdk-lib/core/lib/errors'; export const S3_TABLES_SERVICE = 's3tables'; @@ -19,11 +18,11 @@ export function parseTableBucketArn(construct: IConstruct, props: TableBucketAtt service: S3_TABLES_SERVICE, resource: 'bucket', resourceName: props.tableBucketName, - arnFormat: ArnFormat.SLASH_RESOURCE_NAME, + arnFormat: cdk.ArnFormat.SLASH_RESOURCE_NAME, }); } - throw new ValidationError('Cannot determine bucket ARN. At least `tableBucketArn`, `bucketName`, and `account` is needed', construct); + throw new errors.ValidationError('Cannot determine bucket ARN. At least `tableBucketArn`, `bucketName`, and `account` is needed', construct); } export function parseTableBucketName(construct: IConstruct, props: TableBucketAttributes): string { @@ -40,8 +39,7 @@ export function parseTableBucketName(construct: IConstruct, props: TableBucketAt } } - // no table bucket name is okay since it's optional. - throw new ValidationError('tableBucketName is required and could not be inferred from context', construct); + throw new errors.ValidationError('tableBucketName is required and could not be inferred from context', construct); } export function parseTableBucketRegion(construct: IConstruct, props: TableBucketAttributes): string { @@ -58,8 +56,7 @@ export function parseTableBucketRegion(construct: IConstruct, props: TableBucket } } - // no table bucket region is okay since it's optional. - throw new ValidationError('Region is required and could not be inferred from context', construct); + throw new errors.ValidationError('Region is required and could not be inferred from context', construct); } export function parseTableBucketAccount(construct: IConstruct, props: TableBucketAttributes): string { @@ -76,7 +73,7 @@ export function parseTableBucketAccount(construct: IConstruct, props: TableBucke } } - throw new ValidationError('Account is required and could not be inferred from context', construct); + throw new errors.ValidationError('Account is required and could not be inferred from context', construct); } /** diff --git a/packages/@aws-cdk/aws-s3tables-alpha/package.json b/packages/@aws-cdk/aws-s3tables-alpha/package.json new file mode 100644 index 0000000000000..546649022c9db --- /dev/null +++ b/packages/@aws-cdk/aws-s3tables-alpha/package.json @@ -0,0 +1,105 @@ +{ + "name": "@aws-cdk/aws-s3tables-alpha", + "private": true, + "version": "0.0.0", + "description": "CDK Constructs for S3 Tables", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "jsii": { + "outdir": "dist", + "targets": { + "java": { + "package": "software.amazon.awscdk.services.s3tables.alpha", + "maven": { + "groupId": "software.amazon.awscdk", + "artifactId": "s3tables-alpha" + } + }, + "dotnet": { + "namespace": "Amazon.CDK.AWS.S3Tables.Alpha", + "packageId": "Amazon.CDK.AWS.S3Tables.Alpha", + "iconUrl": "https://raw.githubusercontent.com/aws/aws-cdk/main/logo/default-256-dark.png" + }, + "python": { + "distName": "aws-cdk.aws-s3tables-alpha", + "module": "aws_cdk.aws_s3tables_alpha", + "classifiers": [ + "Framework :: AWS CDK", + "Framework :: AWS CDK :: 2" + ] + } + }, + "projectReferences": true, + "metadata": { + "jsii": { + "rosetta": { + "strict": true + } + } + } + }, + "repository": { + "type": "git", + "url": "https://github.com/aws/aws-cdk.git", + "directory": "packages/@aws-cdk/aws-s3tables-alpha" + }, + "scripts": { + "build": "cdk-build", + "watch": "cdk-watch", + "lint": "cdk-lint", + "test": "cdk-test", + "integ": "integ-runner --language javascript", + "pkglint": "pkglint -f", + "package": "cdk-package", + "awslint": "cdk-awslint", + "build+test": "yarn build && yarn test", + "build+lint": "yarn build && cdk-lint", + "build+test+package": "yarn build+test && yarn package", + "compat": "cdk-compat", + "rosetta:extract": "yarn --silent jsii-rosetta extract", + "build+extract": "yarn build && yarn rosetta:extract", + "build+test+extract": "yarn build+test && yarn rosetta:extract" + }, + "keywords": [ + "aws", + "cdk", + "s3", + "tables", + "construct", + "library" + ], + "author": { + "name": "Amazon Web Services", + "url": "https://aws.amazon.com", + "organization": true + }, + "license": "Apache-2.0", + "devDependencies": { + "aws-cdk-lib": "0.0.0", + "@aws-cdk/cdk-build-tools": "0.0.0", + "@aws-cdk/integ-runner": "0.0.0", + "@aws-cdk/pkglint": "0.0.0", + "@types/jest": "^29.5.14", + "constructs": "^10.0.0", + "jest": "^29.7.0" + }, + "homepage": "https://github.com/aws/aws-cdk", + "peerDependencies": { + "aws-cdk-lib": "^0.0.0", + "constructs": "^10.0.0" + }, + "separate-module": false, + "engines": { + "node": ">= 14.15.0" + }, + "stability": "experimental", + "maturity": "experimental", + "awscdkio": { + "announce": false + }, + "cdk-build": { + "env": { + "AWSLINT_BASE_CONSTRUCT": true + } + } +} diff --git a/packages/aws-cdk-lib/aws-s3tables/lib/table-bucket-policy.ts b/packages/aws-cdk-lib/aws-s3tables/lib/table-bucket-policy.ts deleted file mode 100644 index d8a65d01b9e57..0000000000000 --- a/packages/aws-cdk-lib/aws-s3tables/lib/table-bucket-policy.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Construct } from 'constructs'; -import { CfnTableBucketPolicy } from './s3tables.generated'; -import { TableBucket } from './table-bucket'; -import * as iam from '../../aws-iam'; -import { PolicyDocument } from '../../aws-iam'; -import { Resource, ValidationError } from '../../core'; - -type TableBucketPolicyPropsWithArn = { - tableBucketArn: string; - tableBucket?: never; - resourcePolicy?: iam.Policy; -}; - -type TableBucketPolicyPropsWithBucket = { - tableBucketArn?: never; - tableBucket: TableBucket; - resourcePolicy?: iam.Policy; -}; - -/** - * Parameters for constructing a TableBucketPolicy - * - * This supports two options: - * 1. tableBucketArn along with the IAM Policy - * 2. TableBucket entity along with the IAM Policy - */ -export type TableBucketPolicyProps = - | TableBucketPolicyPropsWithArn - | TableBucketPolicyPropsWithBucket; - -function isArnProps(props: TableBucketPolicyProps): boolean { - return 'tableBucketArn' in props; -} - -/** - * A Bucket Policy for S3 TableBuckets. - * - * You will almost never need to use this construct directly. - * Instead, TableBucket.addToResourcePolicy can be used to add more policies to your bucket directly - */ -export class TableBucketPolicy extends Resource { - public readonly policy: CfnTableBucketPolicy; - /** - * A policy document containing permissions to add to the specified table bucket. - */ - public readonly document = new PolicyDocument(); - - constructor(scope: Construct, id: string, props: TableBucketPolicyProps) { - super(scope, id); - let tableBucketArn; - if (isArnProps(props)) { - tableBucketArn = props.tableBucketArn; - } else { - tableBucketArn = props.tableBucket?._resource.attrTableBucketArn; - } - - if (!tableBucketArn) { - throw new ValidationError('Expected either tableBucketArn or tableBucket entity to be provided', this); - } - - if (props.resourcePolicy?.document) { - this.document = props.resourcePolicy.document; - } - - this.policy = new CfnTableBucketPolicy(this, id, { - tableBucketArn, - resourcePolicy: this.document, - }); - } -} From 02e24221689fc87cd57ee141232cbac600358189 Mon Sep 17 00:00:00 2001 From: Soham Kulkarni Date: Fri, 28 Feb 2025 14:31:19 -0800 Subject: [PATCH 3/4] Add unit tests for TableBucket and TableBucketPolicy --- .../lib/table-bucket-policy.ts | 12 +- .../aws-s3tables-alpha/lib/table-bucket.ts | 96 +++-- .../@aws-cdk/aws-s3tables-alpha/lib/util.ts | 17 +- .../test/table-bucket-policy.test.ts | 98 +++++ .../test/table-bucket.test.ts | 371 ++++++++++++++++++ 5 files changed, 550 insertions(+), 44 deletions(-) create mode 100644 packages/@aws-cdk/aws-s3tables-alpha/test/table-bucket-policy.test.ts create mode 100644 packages/@aws-cdk/aws-s3tables-alpha/test/table-bucket.test.ts diff --git a/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket-policy.ts b/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket-policy.ts index 4b1d51df7f439..da11edc5e7cf5 100644 --- a/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket-policy.ts +++ b/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket-policy.ts @@ -10,8 +10,9 @@ import { ITableBucket } from './table-bucket'; export interface TableBucketPolicyProps { /** * Name of the table bucket policy + * @default -- Physical name will be generated if not provided */ - readonly tableBucketPolicyName: string; + readonly tableBucketPolicyName?: string; /** * The associated table bucket */ @@ -41,16 +42,15 @@ export class TableBucketPolicy extends Resource { constructor(scope: Construct, id: string, props: TableBucketPolicyProps) { super(scope, id, { - physicalName: PhysicalName.GENERATE_IF_NEEDED, + physicalName: props.tableBucketPolicyName || PhysicalName.GENERATE_IF_NEEDED, }); // Use default policy if not provided with props - const resourcePolicy = props.resourcePolicy || new iam.PolicyDocument({}); - this.document = resourcePolicy; + this.document = props.resourcePolicy || new iam.PolicyDocument({}); this._resource = new CfnTableBucketPolicy(this, id, { tableBucketArn: props.tableBucket.tableBucketArn, - resourcePolicy: resourcePolicy.toJSON(), + resourcePolicy: this.document, }); } @@ -64,6 +64,6 @@ export class TableBucketPolicy extends Resource { */ public addToResourcePolicy(statement: iam.PolicyStatement) { this.document.addStatements(statement); - this._resource.resourcePolicy = this.document.toJSON(); + this._resource.resourcePolicy = this.document; } } diff --git a/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket.ts b/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket.ts index 90decb5c40aec..7ab880556f512 100644 --- a/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket.ts +++ b/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket.ts @@ -4,7 +4,7 @@ import * as s3tables from 'aws-cdk-lib/aws-s3tables'; import { TableBucketPolicy } from './table-bucket-policy'; import { validateTableBucketAttributes, S3_TABLES_SERVICE } from './util'; import * as iam from 'aws-cdk-lib/aws-iam'; -import { Resource, IResource, Token, UnscopedValidationError, ArnFormat, PhysicalName } from 'aws-cdk-lib/core'; +import { Resource, IResource, UnscopedValidationError, ArnFormat } from 'aws-cdk-lib/core'; import UnreferencedFileRemovalProperty = s3tables.CfnTableBucket.UnreferencedFileRemovalProperty; import { addConstructMetadata } from 'aws-cdk-lib/core/lib/metadata-resource'; @@ -24,10 +24,22 @@ export interface ITableBucket extends IResource { */ readonly tableBucketName: string; + /** + * The accountId containing the table bucket. + * @attribute + */ + readonly account?: string; + + /** + * The region containing the table bucket. + * @attribute + */ + readonly region?: string; + /** * The resource policy for this tableBucket. */ - readonly policy?: TableBucketPolicy; + readonly resourcePolicy?: TableBucketPolicy; /** * Adds a statement to the resource policy for a principal (i.e. @@ -55,7 +67,14 @@ export interface ITableBucket extends IResource { abstract class TableBucketBase extends Resource implements ITableBucket { public abstract readonly tableBucketArn: string; public abstract readonly tableBucketName: string; - public abstract policy?: TableBucketPolicy; + + /** + * The resource policy associated with this table bucket. + * + * If `autoCreatePolicy` is true, a `TableBucketPolicy` will be created upon the + * first call to addToResourcePolicy(s). + */ + public abstract resourcePolicy?: TableBucketPolicy; /** * Indicates if a bucket resource policy should automatically created upon @@ -86,16 +105,15 @@ abstract class TableBucketBase extends Resource implements ITableBucket { public addToResourcePolicy( statement: iam.PolicyStatement, ): iam.AddToResourcePolicyResult { - if (!this.policy && this.autoCreatePolicy) { - this.policy = new TableBucketPolicy(this, 'Policy', { + if (!this.resourcePolicy && this.autoCreatePolicy) { + this.resourcePolicy = new TableBucketPolicy(this, 'DefaultPolicy', { tableBucket: this, - tableBucketPolicyName: `${this.tableBucketName}-policy`, }); } - if (this.policy) { - this.policy.document.addStatements(statement); - return { statementAdded: true, policyDependable: this.policy }; + if (this.resourcePolicy) { + this.resourcePolicy.addToResourcePolicy(statement); + return { statementAdded: true, policyDependable: this.resourcePolicy }; } return { statementAdded: false }; @@ -111,6 +129,7 @@ export interface TableBucketProps { * @link https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-tables-buckets-naming.html#table-buckets-naming-rules */ readonly tableBucketName: string; + /** * Unreferenced file removal settings for the S3 TableBucket. * @link https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-s3tables-tablebucket-unreferencedfileremoval.html @@ -118,14 +137,20 @@ export interface TableBucketProps { * @see https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-table-buckets-maintenance.html */ readonly unreferencedFileRemoval?: UnreferencedFileRemovalProperty; + /** * AWS region that the table bucket exists in. + * + * @default - it's assumed the bucket is in the same region as the scope it's being imported into */ - readonly region: string; + readonly region?: string; + /** * AWS Account ID of the table bucket owner. + * + * @default - it's assumed the bucket belongs to the same account as the scope it's being imported into */ - readonly account: string; + readonly account?: string; } /** @@ -174,7 +199,18 @@ export interface TableBucketAttributes { */ export class TableBucket extends TableBucketBase { /** - * Creates a TableBucket construct that represents an external table bucket. + * Defines a TableBucket construct from an external table bucket ARN. + * + * @param scope The parent creating construct (usually `this`). + * @param id The construct's name. + * @param tableBucketArn Amazon Resource Name (arn) of the table bucket + */ + public static fromTableBucketArn(scope: Construct, id: string, tableBucketArn: string): ITableBucket { + return TableBucket.fromTableBucketAttributes(scope, id, { tableBucketArn }); + } + + /** + * Defines a TableBucket construct that represents an external table bucket. * * @param scope The parent creating construct (usually `this`). * @param id The construct's name. @@ -186,11 +222,13 @@ export class TableBucket extends TableBucketBase { attrs: TableBucketAttributes, ): ITableBucket { const { tableBucketName, region, account, tableBucketArn } = validateTableBucketAttributes(scope, attrs); - TableBucket.validateBucketName(tableBucketName); + TableBucket.validateTableBucketName(tableBucketName); class Import extends TableBucketBase { public readonly tableBucketName = tableBucketName!; public readonly tableBucketArn = tableBucketArn; - public readonly policy?: TableBucketPolicy; + public readonly resourcePolicy?: TableBucketPolicy; + public readonly region = region; + public readonly account = account; protected autoCreatePolicy: boolean = false; /** @@ -202,8 +240,8 @@ export class TableBucket extends TableBucketBase { } return new Import(scope, id, { - account: account, - region: region, + account, + region, physicalName: tableBucketName, }); } @@ -211,13 +249,12 @@ export class TableBucket extends TableBucketBase { /** * Throws an exception if the given table bucket name is not valid. * - * @param physicalName name of the bucket. + * @param bucketName name of the bucket. */ - public static validateBucketName( - physicalName: string, - ): void { - const bucketName = physicalName; - if (!bucketName || Token.isUnresolved(bucketName)) { + public static validateTableBucketName( + bucketName: string | undefined, + ) { + if (bucketName == undefined) { // the name is a late-bound value, not a defined string, so skip validation return; } @@ -261,7 +298,7 @@ export class TableBucket extends TableBucketBase { if (errors.length > 0) { throw new UnscopedValidationError( - `Invalid S3 bucket name (value: ${bucketName})${EOL}${errors.join(EOL)}`, + `Invalid S3 table bucket name (value: ${bucketName})${EOL}${errors.join(EOL)}`, ); } } @@ -318,7 +355,7 @@ export class TableBucket extends TableBucketBase { /** * The resource policy for this tableBucket. */ - public readonly policy?: TableBucketPolicy; + public readonly resourcePolicy?: TableBucketPolicy; /** * The unique Amazon Resource Name (arn) of this table bucket @@ -334,24 +371,23 @@ export class TableBucket extends TableBucketBase { constructor(scope: Construct, id: string, props: TableBucketProps) { super(scope, id, { - physicalName: PhysicalName.GENERATE_IF_NEEDED, + physicalName: props.tableBucketName, }); // Enhanced CDK Analytics Telemetry addConstructMetadata(this, props); - TableBucket.validateBucketName(props.tableBucketName); + TableBucket.validateTableBucketName(props.tableBucketName); TableBucket.validateUnreferencedFileRemoval(props.unreferencedFileRemoval); - const resource = new s3tables.CfnTableBucket(this, id, { + this._resource = new s3tables.CfnTableBucket(this, id, { tableBucketName: props.tableBucketName, unreferencedFileRemoval: props.unreferencedFileRemoval, }); - this._resource = resource; - this.tableBucketName = this.getResourceNameAttribute(resource.ref); + this.tableBucketName = this.getResourceNameAttribute(this._resource.ref); this.tableBucketArn = this.getResourceArnAttribute( - resource.attrTableBucketArn, + this._resource.attrTableBucketArn, { region: props.region, account: props.account, diff --git a/packages/@aws-cdk/aws-s3tables-alpha/lib/util.ts b/packages/@aws-cdk/aws-s3tables-alpha/lib/util.ts index 712403883e53b..8c49c2e7fdab6 100644 --- a/packages/@aws-cdk/aws-s3tables-alpha/lib/util.ts +++ b/packages/@aws-cdk/aws-s3tables-alpha/lib/util.ts @@ -1,7 +1,6 @@ import { IConstruct } from 'constructs'; import { TableBucketAttributes } from './table-bucket'; import * as cdk from 'aws-cdk-lib/core'; -import * as errors from 'aws-cdk-lib/core/lib/errors'; export const S3_TABLES_SERVICE = 's3tables'; @@ -11,7 +10,7 @@ export function parseTableBucketArn(construct: IConstruct, props: TableBucketAtt return props.tableBucketArn; } - if (props.tableBucketName && props.region && props.account) { + if (props.tableBucketName) { return cdk.Stack.of(construct).formatArn({ region: props.region, account: props.account, @@ -22,7 +21,7 @@ export function parseTableBucketArn(construct: IConstruct, props: TableBucketAtt }); } - throw new errors.ValidationError('Cannot determine bucket ARN. At least `tableBucketArn`, `bucketName`, and `account` is needed', construct); + throw new cdk.ValidationError('Cannot determine bucket ARN. At least `tableBucketArn` is needed', construct); } export function parseTableBucketName(construct: IConstruct, props: TableBucketAttributes): string { @@ -39,10 +38,10 @@ export function parseTableBucketName(construct: IConstruct, props: TableBucketAt } } - throw new errors.ValidationError('tableBucketName is required and could not be inferred from context', construct); + throw new cdk.ValidationError('tableBucketName is required and could not be inferred from context', construct); } -export function parseTableBucketRegion(construct: IConstruct, props: TableBucketAttributes): string { +export function parseTableBucketRegion(construct: IConstruct, props: TableBucketAttributes): string | undefined { // if we have an explicit bucket region, use it. if (props.region) { return props.region; @@ -56,10 +55,11 @@ export function parseTableBucketRegion(construct: IConstruct, props: TableBucket } } - throw new errors.ValidationError('Region is required and could not be inferred from context', construct); + // Region is optional, can be inferred later + return undefined; } -export function parseTableBucketAccount(construct: IConstruct, props: TableBucketAttributes): string { +export function parseTableBucketAccount(construct: IConstruct, props: TableBucketAttributes): string | undefined { // if we have an explicit bucket account, use it. if (props.account) { return props.account; @@ -73,7 +73,8 @@ export function parseTableBucketAccount(construct: IConstruct, props: TableBucke } } - throw new errors.ValidationError('Account is required and could not be inferred from context', construct); + // Account is optional, can be inferred later + return undefined; } /** diff --git a/packages/@aws-cdk/aws-s3tables-alpha/test/table-bucket-policy.test.ts b/packages/@aws-cdk/aws-s3tables-alpha/test/table-bucket-policy.test.ts new file mode 100644 index 0000000000000..1c49af6aa3eaf --- /dev/null +++ b/packages/@aws-cdk/aws-s3tables-alpha/test/table-bucket-policy.test.ts @@ -0,0 +1,98 @@ +import { Template } from 'aws-cdk-lib/assertions'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as core from 'aws-cdk-lib/core'; +import * as s3tables from '../lib'; + +/* Allow quotes in the object keys used for CloudFormation template assertions */ +/* eslint-disable quote-props */ + +describe('TableBucketPolicy', () => { + const TABLE_BUCKET_POLICY_CFN_RESOURCE = 'AWS::S3Tables::TableBucketPolicy'; + + let stack: core.Stack; + + beforeEach(() => { + stack = new core.Stack(); + }); + + describe('created with default properties', () => { + let tableBucketPolicy: s3tables.TableBucketPolicy; + let tableBucket: s3tables.TableBucket; + + beforeEach(() => { + tableBucket = new s3tables.TableBucket(stack, 'test-bucket', { + tableBucketName: 'test-bucket', + }), + tableBucketPolicy = new s3tables.TableBucketPolicy(stack, 'ExampleTableBucket', { + tableBucket, + resourcePolicy: new iam.PolicyDocument({ + statements: [ + new iam.PolicyStatement({ + actions: ['s3tables:*'], + resources: ['*'], + }), + ], + }), + }); + }); + + test(`creates a ${TABLE_BUCKET_POLICY_CFN_RESOURCE} resource`, () => { + tableBucketPolicy; + Template.fromStack(stack).resourceCountIs(TABLE_BUCKET_POLICY_CFN_RESOURCE, 1); + }); + + test('with tableBucketARN property', () => { + Template.fromStack(stack).hasResourceProperties(TABLE_BUCKET_POLICY_CFN_RESOURCE, { + 'TableBucketARN': { + 'Fn::GetAtt': ['testbucket04374B72', 'TableBucketARN'], + }, + }); + }); + + test('with tableBucketARN property', () => { + Template.fromStack(stack).hasResourceProperties(TABLE_BUCKET_POLICY_CFN_RESOURCE, { + 'TableBucketARN': { + 'Fn::GetAtt': ['testbucket04374B72', 'TableBucketARN'], + }, + }); + }); + + test('bucket resourcePolicy contains statement', () => { + Template.fromStack(stack).hasResourceProperties(TABLE_BUCKET_POLICY_CFN_RESOURCE, { + 'ResourcePolicy': { + 'Statement': [ + { + 'Action': 's3tables:*', + 'Effect': 'Allow', + 'Resource': '*', + }, + ], + }, + }); + }); + + test('calling addToResourcePolicy multiple times appends statements', () => { + tableBucketPolicy.addToResourcePolicy(new iam.PolicyStatement({ + actions: ['s3:*'], + effect: iam.Effect.DENY, + resources: ['*'], + })); + Template.fromStack(stack).hasResourceProperties(TABLE_BUCKET_POLICY_CFN_RESOURCE, { + 'ResourcePolicy': { + 'Statement': [ + { + 'Action': 's3tables:*', + 'Effect': 'Allow', + 'Resource': '*', + }, + { + 'Action': 's3:*', + 'Effect': 'Deny', + 'Resource': '*', + }, + ], + }, + }); + }); + }); +}); diff --git a/packages/@aws-cdk/aws-s3tables-alpha/test/table-bucket.test.ts b/packages/@aws-cdk/aws-s3tables-alpha/test/table-bucket.test.ts new file mode 100644 index 0000000000000..31b6410e73b5d --- /dev/null +++ b/packages/@aws-cdk/aws-s3tables-alpha/test/table-bucket.test.ts @@ -0,0 +1,371 @@ +import { Template } from 'aws-cdk-lib/assertions'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as core from 'aws-cdk-lib/core'; +import * as s3tables from '../lib'; + +/* Allow quotes in the object keys used for CloudFormation template assertions */ +/* eslint-disable quote-props */ + +describe('TableBucket', () => { + const TABLE_BUCKET_CFN_RESOURCE = 'AWS::S3Tables::TableBucket'; + const TABLE_BUCKET_POLICY_CFN_RESOURCE = 'AWS::S3Tables::TableBucketPolicy'; + + let stack: core.Stack; + + beforeEach(() => { + stack = new core.Stack(); + }); + + describe('created with default properties', () => { + const DEFAULT_PROPS: s3tables.TableBucketProps = { + tableBucketName: 'example-table-bucket', + }; + let tableBucket: s3tables.TableBucket; + + beforeEach(() => { + tableBucket = new s3tables.TableBucket(stack, 'ExampleTableBucket', DEFAULT_PROPS); + }); + + test(`creates a ${TABLE_BUCKET_CFN_RESOURCE} resource`, () => { + Template.fromStack(stack).resourceCountIs(TABLE_BUCKET_CFN_RESOURCE, 1); + }); + + test('with tableBucketName property', () => { + Template.fromStack(stack).hasResourceProperties(TABLE_BUCKET_CFN_RESOURCE, { + 'TableBucketName': DEFAULT_PROPS.tableBucketName, + }); + }); + + test('returns true from addToResourcePolicy', () => { + const result = tableBucket.addToResourcePolicy(new iam.PolicyStatement({ + actions: ['s3tables:*'], + resources: ['*'], + })); + + expect(result.statementAdded).toBe(true); + }); + }); + + describe('created with optional properties', () => { + const TABLE_BUCKET_PROPS: s3tables.TableBucketProps = { + account: '0123456789012', + region: 'us-west-2', + tableBucketName: 'example-table-bucket', + unreferencedFileRemoval: { + noncurrentDays: 10, + unreferencedDays: 10, + status: 'Enabled', + }, + }; + let tableBucket: s3tables.TableBucket; + + beforeEach(() => { + tableBucket = new s3tables.TableBucket(stack, 'ExampleTableBucket', TABLE_BUCKET_PROPS); + }); + + test(`creates a ${TABLE_BUCKET_CFN_RESOURCE} resource`, () => { + Template.fromStack(stack).resourceCountIs(TABLE_BUCKET_CFN_RESOURCE, 1); + }); + + test('has UnreferencedFileRemoval properties', () => { + tableBucket.toString(); + Template.fromStack(stack).hasResourceProperties(TABLE_BUCKET_CFN_RESOURCE, { + 'TableBucketName': TABLE_BUCKET_PROPS.tableBucketName, + 'UnreferencedFileRemoval': { + 'NoncurrentDays': TABLE_BUCKET_PROPS.unreferencedFileRemoval?.noncurrentDays, + 'Status': TABLE_BUCKET_PROPS.unreferencedFileRemoval?.status, + 'UnreferencedDays': TABLE_BUCKET_PROPS.unreferencedFileRemoval?.unreferencedDays, + }, + }); + }); + }); + + describe('defined with resource policy', () => { + const DEFAULT_PROPS: s3tables.TableBucketProps = { + tableBucketName: 'example-table-bucket', + }; + let tableBucket: s3tables.TableBucket; + + beforeEach(() => { + tableBucket = new s3tables.TableBucket(stack, 'ExampleTableBucket', DEFAULT_PROPS); + tableBucket.addToResourcePolicy(new iam.PolicyStatement({ + actions: ['s3tables:*'], + resources: ['*'], + })); + }); + + test('resourcePolicy contains statement', () => { + Template.fromStack(stack).hasResourceProperties(TABLE_BUCKET_POLICY_CFN_RESOURCE, { + 'ResourcePolicy': { + 'Statement': [ + { + 'Action': 's3tables:*', + 'Effect': 'Allow', + 'Resource': '*', + }, + ], + }, + }); + }); + + test('calling multiple times appends statements', () => { + tableBucket.addToResourcePolicy(new iam.PolicyStatement({ + actions: ['s3:*'], + effect: iam.Effect.DENY, + resources: ['*'], + })); + Template.fromStack(stack).hasResourceProperties(TABLE_BUCKET_POLICY_CFN_RESOURCE, { + 'ResourcePolicy': { + 'Statement': [ + { + 'Action': 's3tables:*', + 'Effect': 'Allow', + 'Resource': '*', + }, + { + 'Action': 's3:*', + 'Effect': 'Deny', + 'Resource': '*', + }, + ], + }, + }); + }); + }); + + describe('import existing table bucket with name', () => { + const BUCKET_PROPS = { + tableBucketName: 'example-table-bucket', + }; + let tableBucket: s3tables.ITableBucket; + + beforeEach(() => { + tableBucket = s3tables.TableBucket.fromTableBucketAttributes(stack, 'ExampleTableBucket', BUCKET_PROPS); + }); + + test('has the same name as it was imported with', () => { + expect(tableBucket.tableBucketName).toEqual(BUCKET_PROPS.tableBucketName); + }); + + test('renders the correct ARN for Example Resource', () => { + const arn = stack.resolve(tableBucket.tableBucketArn); + expect(arn).toEqual({ + 'Fn::Join': ['', [ + 'arn:', + { 'Ref': 'AWS::Partition' }, + ':s3tables:', + { 'Ref': 'AWS::Region' }, + ':', + { 'Ref': 'AWS::AccountId' }, + `:bucket/${BUCKET_PROPS.tableBucketName}`, + ]], + }); + }); + + test('returns false from addToResourcePolicy', () => { + const result = tableBucket.addToResourcePolicy(new iam.PolicyStatement({ + actions: ['s3tables:*'], + resources: ['*'], + })); + + expect(result.statementAdded).toEqual(false); + }); + }); + + describe('import existing table bucket with name, region and account', () => { + const BUCKET_PROPS = { + tableBucketName: 'example-table-bucket', + region: 'us-east-2', + account: '123456789012', + }; + let tableBucket: s3tables.ITableBucket; + + beforeEach(() => { + tableBucket = s3tables.TableBucket.fromTableBucketAttributes(stack, 'ExampleTableBucket', BUCKET_PROPS); + }); + + test('has the same name as it was imported with', () => { + expect(tableBucket.tableBucketName).toEqual(BUCKET_PROPS.tableBucketName); + }); + + test('has the same account as it was imported with', () => { + expect(tableBucket.account).toEqual(BUCKET_PROPS.account); + }); + + test('has the same region as it was imported with', () => { + expect(tableBucket.region).toEqual(BUCKET_PROPS.region); + }); + + test('renders the correct ARN for Example Resource', () => { + const arn = stack.resolve(tableBucket.tableBucketArn); + expect(arn).toEqual({ + 'Fn::Join': ['', [ + 'arn:', + { 'Ref': 'AWS::Partition' }, + `:s3tables:${BUCKET_PROPS.region}:${BUCKET_PROPS.account}:bucket/${BUCKET_PROPS.tableBucketName}`, + ]], + }); + }); + + test('returns false from addToResourcePolicy', () => { + const result = tableBucket.addToResourcePolicy(new iam.PolicyStatement({ + actions: ['s3tables:*'], + resources: ['*'], + })); + + expect(result.statementAdded).toEqual(false); + }); + }); + + describe('validateBucketName', () => { + it('should accept valid bucket names', () => { + const validNames = [ + 'my-bucket-123', + 'test-bucket', + 'abc', + 'a'.repeat(63), + '123-bucket', + ]; + + validNames.forEach(name => { + expect(() => s3tables.TableBucket.validateTableBucketName(name)).not.toThrow(); + }); + }); + + it('should skip validation for unresolved tokens', () => { + // const mockToken = { isUnresolved: true }; + // core.Token.isUnresolved = jest.fn().mockReturnValue(true); + expect(() => s3tables.TableBucket.validateTableBucketName(undefined)).not.toThrow(); + }); + + it('should reject bucket names that are too short', () => { + expect(() => s3tables.TableBucket.validateTableBucketName('XX')).toThrow( + /Bucket name must be at least 3/, + ); + }); + + it('should reject bucket names that are too long', () => { + const longName = 'a'.repeat(64); + expect(() => s3tables.TableBucket.validateTableBucketName(longName)).toThrow( + /no more than 63 characters/, + ); + }); + + it('should reject bucket names with illegal characters', () => { + const invalidNames = [ + 'My-Bucket', // uppercase + 'bucket!123', // special character + 'bucket.123', // period + 'bucket_123', // underscore + ]; + + invalidNames.forEach(name => { + expect(() => s3tables.TableBucket.validateTableBucketName(name)).toThrow( + /must only contain lowercase characters, numbers, and hyphens/, + ); + }); + }); + + it('should reject bucket names that start with invalid characters', () => { + const invalidNames = [ + '-bucket', + '.bucket', + ]; + + invalidNames.forEach(name => { + expect(() => s3tables.TableBucket.validateTableBucketName(name)).toThrow( + /must start with a lowercase letter or number/, + ); + }); + }); + + it('should reject bucket names that end with invalid characters', () => { + const invalidNames = [ + 'bucket-', + 'bucket.', + ]; + + invalidNames.forEach(name => { + expect(() => s3tables.TableBucket.validateTableBucketName(name)).toThrow( + /must end with a lowercase letter or number/, + ); + }); + }); + + it('should include the invalid bucket name in the error message', () => { + const invalidName = 'Invalid-Bucket!'; + expect(() => s3tables.TableBucket.validateTableBucketName(invalidName)).toThrow( + /Invalid-Bucket!/, + ); + }); + + it('should handle empty bucket names', () => { + expect(() => s3tables.TableBucket.validateTableBucketName('')).toThrow( + /Bucket name must be at least 3/, + ); + }); + }); + + describe('validateUnreferencedFileRemoval', () => { + it('should not throw error when unreferencedFileRemovalProperty is undefined', () => { + expect(() => s3tables.TableBucket.validateUnreferencedFileRemoval(undefined)).not.toThrow(); + }); + + it('should not throw error for valid property values', () => { + const validProperty = { + noncurrentDays: 1, + unreferencedDays: 1, + status: 'Enabled', + }; + expect(() => s3tables.TableBucket.validateUnreferencedFileRemoval(validProperty)).not.toThrow(); + }); + + it('should throw error when noncurrentDays is less than 1', () => { + const invalidProperty = { + noncurrentDays: 0, + unreferencedDays: 1, + status: 'Enabled', + }; + expect(() => s3tables.TableBucket.validateUnreferencedFileRemoval(invalidProperty)) + .toThrow( + /noncurrentDays must be at least 1/, + ); + }); + + it('should throw error when unreferencedDays is less than 1', () => { + const invalidProperty = { + noncurrentDays: 1, + unreferencedDays: 0, + status: 'Enabled', + }; + expect(() => s3tables.TableBucket.validateUnreferencedFileRemoval(invalidProperty)) + .toThrow( + /unreferencedDays must be at least 1/, + ); + }); + + it('should throw error when status is invalid', () => { + const invalidProperty = { + noncurrentDays: 1, + unreferencedDays: 1, + status: 'Invalid', + }; + expect(() => s3tables.TableBucket.validateUnreferencedFileRemoval(invalidProperty)) + .toThrow( + /status must be one of 'Enabled' or 'Disabled'/, + ); + }); + + it('should not throw error when optional fields are undefined', () => { + const partialProperty = { + status: 'Enabled', + }; + expect(() => s3tables.TableBucket.validateUnreferencedFileRemoval(partialProperty)).not.toThrow(); + }); + + it('should accept both Enabled and Disabled status', () => { + expect(() => s3tables.TableBucket.validateUnreferencedFileRemoval({ status: 'Enabled' })).not.toThrow(); + expect(() => s3tables.TableBucket.validateUnreferencedFileRemoval({ status: 'Disabled' })).not.toThrow(); + }); + }); +}); From 6405b3826650005b6a0a21bfdcd8021d38542f29 Mon Sep 17 00:00:00 2001 From: Soham Kulkarni Date: Tue, 4 Mar 2025 14:04:51 -0800 Subject: [PATCH 4/4] Add integration tests for TableBucket --- .../aws-s3tables-alpha/lib/table-bucket.ts | 12 +- .../@aws-cdk/aws-s3tables-alpha/package.json | 4 +- .../DefaultTestStack.assets.json | 19 ++ .../DefaultTestStack.template.json | 46 ++++ .../OptionsTestStack.assets.json | 19 ++ .../OptionsTestStack.template.json | 75 ++++++ ...efaultTestDeployAssertA1204D2E.assets.json | 19 ++ ...aultTestDeployAssertA1204D2E.template.json | 36 +++ .../integ.table-bucket.js.snapshot/cdk.out | 1 + .../integ.table-bucket.js.snapshot/integ.json | 13 + .../manifest.json | 185 ++++++++++++++ .../integ.table-bucket.js.snapshot/tree.json | 229 ++++++++++++++++++ .../test/integ.table-bucket.ts | 66 +++++ .../test/table-bucket.test.ts | 3 +- 14 files changed, 724 insertions(+), 3 deletions(-) create mode 100644 packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/DefaultTestStack.assets.json create mode 100644 packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/DefaultTestStack.template.json create mode 100644 packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/OptionsTestStack.assets.json create mode 100644 packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/OptionsTestStack.template.json create mode 100644 packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/TableBucketIntegTestDefaultTestDeployAssertA1204D2E.assets.json create mode 100644 packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/TableBucketIntegTestDefaultTestDeployAssertA1204D2E.template.json create mode 100644 packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/cdk.out create mode 100644 packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/integ.json create mode 100644 packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/manifest.json create mode 100644 packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/tree.json create mode 100644 packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.ts diff --git a/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket.ts b/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket.ts index 7ab880556f512..f593443773c2c 100644 --- a/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket.ts +++ b/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket.ts @@ -4,7 +4,7 @@ import * as s3tables from 'aws-cdk-lib/aws-s3tables'; import { TableBucketPolicy } from './table-bucket-policy'; import { validateTableBucketAttributes, S3_TABLES_SERVICE } from './util'; import * as iam from 'aws-cdk-lib/aws-iam'; -import { Resource, IResource, UnscopedValidationError, ArnFormat } from 'aws-cdk-lib/core'; +import { Resource, IResource, UnscopedValidationError, ArnFormat, RemovalPolicy } from 'aws-cdk-lib/core'; import UnreferencedFileRemovalProperty = s3tables.CfnTableBucket.UnreferencedFileRemovalProperty; import { addConstructMetadata } from 'aws-cdk-lib/core/lib/metadata-resource'; @@ -151,6 +151,13 @@ export interface TableBucketProps { * @default - it's assumed the bucket belongs to the same account as the scope it's being imported into */ readonly account?: string; + + /** + * Controls what happens to this table bucket it it stoped being managed by cloudformation. + * + * @default RETAIN + */ + readonly removalPolicy?: RemovalPolicy; } /** @@ -186,6 +193,7 @@ export interface TableBucketAttributes { * * This bucket may not yet have all features that exposed by the underlying CfnTableBucket. * + * @stateful * @example * const tableBucket = new TableBucket(scope, 'ExampleTableBucket', { * bucketName: 'example-bucket', @@ -397,6 +405,8 @@ export class TableBucket extends TableBucketBase { arnFormat: ArnFormat.SLASH_RESOURCE_NAME, }, ); + + this._resource.applyRemovalPolicy(props.removalPolicy); } } diff --git a/packages/@aws-cdk/aws-s3tables-alpha/package.json b/packages/@aws-cdk/aws-s3tables-alpha/package.json index 546649022c9db..660d68de00a50 100644 --- a/packages/@aws-cdk/aws-s3tables-alpha/package.json +++ b/packages/@aws-cdk/aws-s3tables-alpha/package.json @@ -49,6 +49,7 @@ "lint": "cdk-lint", "test": "cdk-test", "integ": "integ-runner --language javascript", + "build+integ": "yarn build && integ-runner --language javascript --verbose", "pkglint": "pkglint -f", "package": "cdk-package", "awslint": "cdk-awslint", @@ -81,7 +82,8 @@ "@aws-cdk/pkglint": "0.0.0", "@types/jest": "^29.5.14", "constructs": "^10.0.0", - "jest": "^29.7.0" + "jest": "^29.7.0", + "@aws-cdk/integ-tests-alpha": "0.0.0" }, "homepage": "https://github.com/aws/aws-cdk", "peerDependencies": { diff --git a/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/DefaultTestStack.assets.json b/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/DefaultTestStack.assets.json new file mode 100644 index 0000000000000..c36a1799040df --- /dev/null +++ b/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/DefaultTestStack.assets.json @@ -0,0 +1,19 @@ +{ + "version": "40.0.0", + "files": { + "0d1856ed54814ca2d90a813b0068a1f3f0f45d4e7511db1413b7aec09c42b52b": { + "source": { + "path": "DefaultTestStack.template.json", + "packaging": "file" + }, + "destinations": { + "current_account-current_region": { + "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", + "objectKey": "0d1856ed54814ca2d90a813b0068a1f3f0f45d4e7511db1413b7aec09c42b52b.json", + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" + } + } + } + }, + "dockerImages": {} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/DefaultTestStack.template.json b/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/DefaultTestStack.template.json new file mode 100644 index 0000000000000..5e4f0abbd19c9 --- /dev/null +++ b/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/DefaultTestStack.template.json @@ -0,0 +1,46 @@ +{ + "Resources": { + "DefaultBucket62385A75": { + "Type": "AWS::S3Tables::TableBucket", + "Properties": { + "TableBucketName": "default-table-bucket" + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + } + }, + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/OptionsTestStack.assets.json b/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/OptionsTestStack.assets.json new file mode 100644 index 0000000000000..8bab3fd00b038 --- /dev/null +++ b/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/OptionsTestStack.assets.json @@ -0,0 +1,19 @@ +{ + "version": "40.0.0", + "files": { + "6f2b7f9deefcd66944c85774e440659181abd1b76d4ff4767316ef7c2316af50": { + "source": { + "path": "OptionsTestStack.template.json", + "packaging": "file" + }, + "destinations": { + "current_account-current_region": { + "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", + "objectKey": "6f2b7f9deefcd66944c85774e440659181abd1b76d4ff4767316ef7c2316af50.json", + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" + } + } + } + }, + "dockerImages": {} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/OptionsTestStack.template.json b/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/OptionsTestStack.template.json new file mode 100644 index 0000000000000..d5a4a744202d5 --- /dev/null +++ b/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/OptionsTestStack.template.json @@ -0,0 +1,75 @@ +{ + "Resources": { + "BucketWithOptions87848D2E": { + "Type": "AWS::S3Tables::TableBucket", + "Properties": { + "TableBucketName": "table-bucket-with-options", + "UnreferencedFileRemoval": { + "NoncurrentDays": 20, + "Status": "Disabled", + "UnreferencedDays": 20 + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "BucketWithOptionsDefaultPolicyF7FBC609": { + "Type": "AWS::S3Tables::TableBucketPolicy", + "Properties": { + "ResourcePolicy": { + "Statement": [ + { + "Action": "s3tables:*", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "TableBucketARN": { + "Fn::GetAtt": [ + "BucketWithOptions87848D2E", + "TableBucketARN" + ] + } + } + } + }, + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/TableBucketIntegTestDefaultTestDeployAssertA1204D2E.assets.json b/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/TableBucketIntegTestDefaultTestDeployAssertA1204D2E.assets.json new file mode 100644 index 0000000000000..485b68f80f184 --- /dev/null +++ b/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/TableBucketIntegTestDefaultTestDeployAssertA1204D2E.assets.json @@ -0,0 +1,19 @@ +{ + "version": "40.0.0", + "files": { + "21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22": { + "source": { + "path": "TableBucketIntegTestDefaultTestDeployAssertA1204D2E.template.json", + "packaging": "file" + }, + "destinations": { + "current_account-current_region": { + "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", + "objectKey": "21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22.json", + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" + } + } + } + }, + "dockerImages": {} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/TableBucketIntegTestDefaultTestDeployAssertA1204D2E.template.json b/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/TableBucketIntegTestDefaultTestDeployAssertA1204D2E.template.json new file mode 100644 index 0000000000000..ad9d0fb73d1dd --- /dev/null +++ b/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/TableBucketIntegTestDefaultTestDeployAssertA1204D2E.template.json @@ -0,0 +1,36 @@ +{ + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/cdk.out b/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/cdk.out new file mode 100644 index 0000000000000..1e02a2deb191b --- /dev/null +++ b/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/cdk.out @@ -0,0 +1 @@ +{"version":"40.0.0"} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/integ.json b/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/integ.json new file mode 100644 index 0000000000000..e2581d86786e4 --- /dev/null +++ b/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/integ.json @@ -0,0 +1,13 @@ +{ + "version": "40.0.0", + "testCases": { + "TableBucketIntegTest/DefaultTest": { + "stacks": [ + "DefaultTestStack", + "OptionsTestStack" + ], + "assertionStack": "TableBucketIntegTest/DefaultTest/DeployAssert", + "assertionStackName": "TableBucketIntegTestDefaultTestDeployAssertA1204D2E" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/manifest.json b/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/manifest.json new file mode 100644 index 0000000000000..5632d84826be1 --- /dev/null +++ b/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/manifest.json @@ -0,0 +1,185 @@ +{ + "version": "40.0.0", + "artifacts": { + "DefaultTestStack.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "DefaultTestStack.assets.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "DefaultTestStack": { + "type": "aws:cloudformation:stack", + "environment": "aws://unknown-account/unknown-region", + "properties": { + "templateFile": "DefaultTestStack.template.json", + "terminationProtection": false, + "validateOnSynth": false, + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}", + "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/0d1856ed54814ca2d90a813b0068a1f3f0f45d4e7511db1413b7aec09c42b52b.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", + "additionalDependencies": [ + "DefaultTestStack.assets" + ], + "lookupRole": { + "arn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-lookup-role-${AWS::AccountId}-${AWS::Region}", + "requiresBootstrapStackVersion": 8, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "dependencies": [ + "DefaultTestStack.assets" + ], + "metadata": { + "/DefaultTestStack/DefaultBucket": [ + { + "type": "aws:cdk:analytics:construct", + "data": "*" + } + ], + "/DefaultTestStack/DefaultBucket/DefaultBucket": [ + { + "type": "aws:cdk:logicalId", + "data": "DefaultBucket62385A75" + } + ], + "/DefaultTestStack/BootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "BootstrapVersion" + } + ], + "/DefaultTestStack/CheckBootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "CheckBootstrapVersion" + } + ] + }, + "displayName": "DefaultTestStack" + }, + "OptionsTestStack.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "OptionsTestStack.assets.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "OptionsTestStack": { + "type": "aws:cloudformation:stack", + "environment": "aws://unknown-account/unknown-region", + "properties": { + "templateFile": "OptionsTestStack.template.json", + "terminationProtection": false, + "validateOnSynth": false, + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}", + "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/6f2b7f9deefcd66944c85774e440659181abd1b76d4ff4767316ef7c2316af50.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", + "additionalDependencies": [ + "OptionsTestStack.assets" + ], + "lookupRole": { + "arn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-lookup-role-${AWS::AccountId}-${AWS::Region}", + "requiresBootstrapStackVersion": 8, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "dependencies": [ + "OptionsTestStack.assets" + ], + "metadata": { + "/OptionsTestStack/BucketWithOptions": [ + { + "type": "aws:cdk:analytics:construct", + "data": "*" + } + ], + "/OptionsTestStack/BucketWithOptions/BucketWithOptions": [ + { + "type": "aws:cdk:logicalId", + "data": "BucketWithOptions87848D2E" + } + ], + "/OptionsTestStack/BucketWithOptions/DefaultPolicy/DefaultPolicy": [ + { + "type": "aws:cdk:logicalId", + "data": "BucketWithOptionsDefaultPolicyF7FBC609" + } + ], + "/OptionsTestStack/BootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "BootstrapVersion" + } + ], + "/OptionsTestStack/CheckBootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "CheckBootstrapVersion" + } + ] + }, + "displayName": "OptionsTestStack" + }, + "TableBucketIntegTestDefaultTestDeployAssertA1204D2E.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "TableBucketIntegTestDefaultTestDeployAssertA1204D2E.assets.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "TableBucketIntegTestDefaultTestDeployAssertA1204D2E": { + "type": "aws:cloudformation:stack", + "environment": "aws://unknown-account/unknown-region", + "properties": { + "templateFile": "TableBucketIntegTestDefaultTestDeployAssertA1204D2E.template.json", + "terminationProtection": false, + "validateOnSynth": false, + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}", + "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", + "additionalDependencies": [ + "TableBucketIntegTestDefaultTestDeployAssertA1204D2E.assets" + ], + "lookupRole": { + "arn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-lookup-role-${AWS::AccountId}-${AWS::Region}", + "requiresBootstrapStackVersion": 8, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "dependencies": [ + "TableBucketIntegTestDefaultTestDeployAssertA1204D2E.assets" + ], + "metadata": { + "/TableBucketIntegTest/DefaultTest/DeployAssert/BootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "BootstrapVersion" + } + ], + "/TableBucketIntegTest/DefaultTest/DeployAssert/CheckBootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "CheckBootstrapVersion" + } + ] + }, + "displayName": "TableBucketIntegTest/DefaultTest/DeployAssert" + }, + "Tree": { + "type": "cdk:tree", + "properties": { + "file": "tree.json" + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/tree.json b/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/tree.json new file mode 100644 index 0000000000000..809b75a48fc9c --- /dev/null +++ b/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.js.snapshot/tree.json @@ -0,0 +1,229 @@ +{ + "version": "tree-0.1", + "tree": { + "id": "App", + "path": "", + "children": { + "DefaultTestStack": { + "id": "DefaultTestStack", + "path": "DefaultTestStack", + "children": { + "DefaultBucket": { + "id": "DefaultBucket", + "path": "DefaultTestStack/DefaultBucket", + "children": { + "DefaultBucket": { + "id": "DefaultBucket", + "path": "DefaultTestStack/DefaultBucket/DefaultBucket", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::S3Tables::TableBucket", + "aws:cdk:cloudformation:props": { + "tableBucketName": "default-table-bucket" + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3tables.CfnTableBucket", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-s3tables-alpha.TableBucket", + "version": "0.0.0", + "metadata": [ + "*" + ] + } + }, + "BootstrapVersion": { + "id": "BootstrapVersion", + "path": "DefaultTestStack/BootstrapVersion", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnParameter", + "version": "0.0.0" + } + }, + "CheckBootstrapVersion": { + "id": "CheckBootstrapVersion", + "path": "DefaultTestStack/CheckBootstrapVersion", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnRule", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.Stack", + "version": "0.0.0" + } + }, + "OptionsTestStack": { + "id": "OptionsTestStack", + "path": "OptionsTestStack", + "children": { + "BucketWithOptions": { + "id": "BucketWithOptions", + "path": "OptionsTestStack/BucketWithOptions", + "children": { + "BucketWithOptions": { + "id": "BucketWithOptions", + "path": "OptionsTestStack/BucketWithOptions/BucketWithOptions", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::S3Tables::TableBucket", + "aws:cdk:cloudformation:props": { + "tableBucketName": "table-bucket-with-options", + "unreferencedFileRemoval": { + "noncurrentDays": 20, + "status": "Disabled", + "unreferencedDays": 20 + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3tables.CfnTableBucket", + "version": "0.0.0" + } + }, + "DefaultPolicy": { + "id": "DefaultPolicy", + "path": "OptionsTestStack/BucketWithOptions/DefaultPolicy", + "children": { + "DefaultPolicy": { + "id": "DefaultPolicy", + "path": "OptionsTestStack/BucketWithOptions/DefaultPolicy/DefaultPolicy", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::S3Tables::TableBucketPolicy", + "aws:cdk:cloudformation:props": { + "resourcePolicy": { + "Statement": [ + { + "Action": "s3tables:*", + "Effect": "Allow", + "Principal": { + "Service": "s3.amazonaws.com" + }, + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "tableBucketArn": { + "Fn::GetAtt": [ + "BucketWithOptions87848D2E", + "TableBucketARN" + ] + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_s3tables.CfnTableBucketPolicy", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-s3tables-alpha.TableBucketPolicy", + "version": "0.0.0", + "metadata": [] + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-s3tables-alpha.TableBucket", + "version": "0.0.0", + "metadata": [ + "*" + ] + } + }, + "BootstrapVersion": { + "id": "BootstrapVersion", + "path": "OptionsTestStack/BootstrapVersion", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnParameter", + "version": "0.0.0" + } + }, + "CheckBootstrapVersion": { + "id": "CheckBootstrapVersion", + "path": "OptionsTestStack/CheckBootstrapVersion", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnRule", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.Stack", + "version": "0.0.0" + } + }, + "TableBucketIntegTest": { + "id": "TableBucketIntegTest", + "path": "TableBucketIntegTest", + "children": { + "DefaultTest": { + "id": "DefaultTest", + "path": "TableBucketIntegTest/DefaultTest", + "children": { + "Default": { + "id": "Default", + "path": "TableBucketIntegTest/DefaultTest/Default", + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.4.2" + } + }, + "DeployAssert": { + "id": "DeployAssert", + "path": "TableBucketIntegTest/DefaultTest/DeployAssert", + "children": { + "BootstrapVersion": { + "id": "BootstrapVersion", + "path": "TableBucketIntegTest/DefaultTest/DeployAssert/BootstrapVersion", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnParameter", + "version": "0.0.0" + } + }, + "CheckBootstrapVersion": { + "id": "CheckBootstrapVersion", + "path": "TableBucketIntegTest/DefaultTest/DeployAssert/CheckBootstrapVersion", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnRule", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.Stack", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/integ-tests-alpha.IntegTestCase", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/integ-tests-alpha.IntegTest", + "version": "0.0.0" + } + }, + "Tree": { + "id": "Tree", + "path": "Tree", + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.4.2" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.App", + "version": "0.0.0" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.ts b/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.ts new file mode 100644 index 0000000000000..721e256d735ad --- /dev/null +++ b/packages/@aws-cdk/aws-s3tables-alpha/test/integ.table-bucket.ts @@ -0,0 +1,66 @@ +import * as core from 'aws-cdk-lib/core'; +import * as s3tables from '../lib'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import { Construct } from 'constructs'; +import { IntegTest } from '@aws-cdk/integ-tests-alpha'; + +/** + * Snapshot test for table bucket with default parameters + */ +class DefaultTestStack extends core.Stack { + public readonly tableBucket: s3tables.TableBucket; + + constructor(scope: Construct, id: string, props?: core.StackProps) { + super(scope, id, props); + + this.tableBucket = new s3tables.TableBucket(this, 'DefaultBucket', { + tableBucketName: 'default-table-bucket', + // we don't want to leave trash in the account after running the deployment of this + removalPolicy: core.RemovalPolicy.DESTROY, + }); + } +} + +/** + * Snapshot test for table bucket with optional parameters + */ +class OptionsTestStack extends core.Stack { + public readonly tableBucket: s3tables.TableBucket; + + constructor(scope: Construct, id: string, props?: core.StackProps) { + super(scope, id, props); + + this.tableBucket = new s3tables.TableBucket(this, 'BucketWithOptions', { + tableBucketName: 'table-bucket-with-options', + account: props?.env?.account, + region: props?.env?.region, + unreferencedFileRemoval: { + noncurrentDays: 20, + status: 'Disabled', + unreferencedDays: 20, + }, + removalPolicy: core.RemovalPolicy.DESTROY, + }); + + const policy = new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ['s3tables:*'], + resources: ['*'], + principals: [ + new iam.ServicePrincipal('s3.amazonaws.com'), + ], + }); + this.tableBucket.addToResourcePolicy(policy); + } +} + +const app = new core.App(); + +const defaultBucketTest = new DefaultTestStack(app, 'DefaultTestStack'); +const bucketWithOptionsTest = new OptionsTestStack(app, 'OptionsTestStack'); + +new IntegTest(app, 'TableBucketIntegTest', { + testCases: [defaultBucketTest, bucketWithOptionsTest], +}); + +app.synth(); diff --git a/packages/@aws-cdk/aws-s3tables-alpha/test/table-bucket.test.ts b/packages/@aws-cdk/aws-s3tables-alpha/test/table-bucket.test.ts index 31b6410e73b5d..bc38aec8ddb7a 100644 --- a/packages/@aws-cdk/aws-s3tables-alpha/test/table-bucket.test.ts +++ b/packages/@aws-cdk/aws-s3tables-alpha/test/table-bucket.test.ts @@ -56,6 +56,7 @@ describe('TableBucket', () => { unreferencedDays: 10, status: 'Enabled', }, + removalPolicy: core.RemovalPolicy.RETAIN, }; let tableBucket: s3tables.TableBucket; @@ -64,11 +65,11 @@ describe('TableBucket', () => { }); test(`creates a ${TABLE_BUCKET_CFN_RESOURCE} resource`, () => { + tableBucket; Template.fromStack(stack).resourceCountIs(TABLE_BUCKET_CFN_RESOURCE, 1); }); test('has UnreferencedFileRemoval properties', () => { - tableBucket.toString(); Template.fromStack(stack).hasResourceProperties(TABLE_BUCKET_CFN_RESOURCE, { 'TableBucketName': TABLE_BUCKET_PROPS.tableBucketName, 'UnreferencedFileRemoval': {