diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1aed598b..a6e769664 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1178,3 +1178,65 @@ jobs: echo "::error::Failed to upload docs artifact" exit 1 fi + aws-cdk-toolkit-lib_release_api_extractor_docs: + name: "@aws-cdk/toolkit-lib: Publish API Extractor docs to S3" + needs: aws-cdk-toolkit-lib_release_npm + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + environment: releasing + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: aws-cdk-toolkit-lib_build-artifact + path: dist + - name: Authenticate Via OIDC Role + id: creds + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-region: us-east-1 + role-to-assume: ${{ vars.AWS_ROLE_TO_ASSUME_FOR_ACCOUNT }} + role-session-name: s3-api-extractor-docs-publishing@aws-cdk-cli + mask-aws-account-id: true + - name: Assume the publishing role + id: publishing-creds + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-region: us-east-1 + role-to-assume: ${{ vars.PUBLISH_TOOLKIT_LIB_DOCS_ROLE_ARN }} + role-session-name: s3-api-extractor-docs-publishing@aws-cdk-cli + mask-aws-account-id: true + role-chaining: true + - name: Publish API Extractor docs + env: + BUCKET_NAME: ${{ vars.DOCS_BUCKET_NAME }} + DOCS_STREAM: toolkit-lib + run: |- + echo "Uploading API Extractor docs to S3" + echo "::add-mask::$BUCKET_NAME" + S3_PATH="$DOCS_STREAM/aws-cdk-toolkit-lib-api-model-v$(cat dist/version.txt).zip" + LATEST="latest-api-model-toolkit-lib" + + # Capture both stdout and stderr + if OUTPUT=$(aws s3api put-object \ + --bucket "$BUCKET_NAME" \ + --key "$S3_PATH" \ + --body dist/api-extractor-docs.zip \ + --if-none-match "*" 2>&1); then + + # File was uploaded successfully, update the latest pointer + echo "New API Extractor docs artifact uploaded successfully, updating latest pointer" + echo "$S3_PATH" | aws s3 cp - "s3://$BUCKET_NAME/$LATEST" + + elif echo "$OUTPUT" | grep -q "PreconditionFailed"; then + # Check specifically for PreconditionFailed in the error output + echo "::warning::File already exists in S3. Skipping upload." + exit 0 + + else + # Any other error (permissions, etc) + echo "::error::Failed to upload API Extractor docs artifact" + exit 1 + fi diff --git a/.projenrc.ts b/.projenrc.ts index ee4f3bd38..f370685dc 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -4,6 +4,7 @@ import type { TypeScriptWorkspaceOptions } from 'cdklabs-projen-project-types/li import * as pj from 'projen'; import { Stability } from 'projen/lib/cdk'; import { AdcPublishing } from './projenrc/adc-publishing'; +import { ApiExtractorDocsPublishing } from './projenrc/api-extractor-docs-publishing'; import { BundleCli } from './projenrc/bundle'; import { CodeCovWorkflow } from './projenrc/codecov'; import { ESLINT_RULES } from './projenrc/eslint'; @@ -1202,6 +1203,12 @@ new S3DocsPublishing(toolkitLib, { roleToAssume: '${{ vars.PUBLISH_TOOLKIT_LIB_DOCS_ROLE_ARN }}', }); +new ApiExtractorDocsPublishing(toolkitLib, { + docsStream: 'toolkit-lib', + bucketName: '${{ vars.DOCS_BUCKET_NAME }}', + roleToAssume: '${{ vars.PUBLISH_TOOLKIT_LIB_DOCS_ROLE_ARN }}', +}); + // Eslint rules toolkitLib.eslint?.addRules({ '@cdklabs/no-throw-default-error': 'error', @@ -1282,6 +1289,48 @@ for (const tsconfig of [toolkitLib.tsconfigDev]) { } } +// Add API Extractor configuration +new pj.JsonFile(toolkitLib, 'api-extractor.json', { + marker: false, + obj: { + projectFolder: '.', + mainEntryPointFilePath: '/lib/index.d.ts', + bundledPackages: [], + apiReport: { + enabled: false + }, + docModel: { + enabled: true, + apiJsonFilePath: './dist/.api.json', + projectFolderUrl: 'https://github.com/aws/aws-cdk-cli' + }, + dtsRollup: { + enabled: false + }, + tsdocMetadata: { + enabled: false + }, + messages: { + compilerMessageReporting: { + default: { + logLevel: 'warning' + } + }, + extractorMessageReporting: { + default: { + logLevel: 'warning' + } + }, + tsdocMessageReporting: { + default: { + logLevel: 'warning' + } + } + } + }, + committed: true, +}); + // Add a command for the docs const toolkitLibDocs = toolkitLib.addTask('docs', { exec: 'typedoc lib/index.ts', diff --git a/packages/@aws-cdk/cloud-assembly-schema/README.md b/packages/@aws-cdk/cloud-assembly-schema/README.md index 703c2c940..54229d39f 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/README.md +++ b/packages/@aws-cdk/cloud-assembly-schema/README.md @@ -5,8 +5,8 @@ This module is part of the [AWS Cloud Development Kit](https://github.com/aws/aw ## Cloud Assembly The _Cloud Assembly_ is the output of the synthesis operation. It is produced as part of the -[`cdk synth`](https://github.com/aws/aws-cdk/tree/main/packages/aws-cdk#cdk-synthesize) -command, or the [`app.synth()`](https://github.com/aws/aws-cdk/blob/main/packages/@aws-cdk/core/lib/app.ts#L135) method invocation. +[`cdk synth`](https://github.com/aws/aws-cdk-cli/tree/main/packages/aws-cdk#cdk-synthesize) +command, or the [`app.synth()`](https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk-lib/core/lib/stage.ts#L219) method invocation. Its essentially a set of files and directories, one of which is the `manifest.json` file. It defines the set of instructions that are needed in order to deploy the assembly directory. @@ -51,4 +51,3 @@ cannot be guaranteed because some instructions will be ignored. ## Contributing See [Contribution Guide](./CONTRIBUTING.md) - diff --git a/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/context-queries.ts b/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/context-queries.ts index b9194ec36..c040659e9 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/context-queries.ts +++ b/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/context-queries.ts @@ -355,11 +355,11 @@ export interface KeyContextQuery extends ContextLookupRoleOptions { } /** - * Query input for lookup up Cloudformation resources using CC API + * Query input for lookup up CloudFormation resources using CC API */ export interface CcApiContextQuery extends ContextLookupRoleOptions { /** - * The Cloudformation resource type. + * The CloudFormation resource type. * See https://docs.aws.amazon.com/cloudcontrolapi/latest/userguide/supported-resources.html */ readonly typeName: string; @@ -367,8 +367,8 @@ export interface CcApiContextQuery extends ContextLookupRoleOptions { /** * Identifier of the resource to look up using `GetResource`. * - * Specifying exactIdentifier will return exactly one result, or throw an error. - * + * Specifying exactIdentifier will return exactly one result, or throw an error + * unless `ignoreErrorOnMissingContext` is set. * * @default - Either exactIdentifier or propertyMatch should be specified. */ @@ -377,7 +377,10 @@ export interface CcApiContextQuery extends ContextLookupRoleOptions { /** * Returns any resources matching these properties, using `ListResources`. * - * Specifying propertyMatch will return 0 or more results. + * By default, specifying propertyMatch will successfully return 0 or more + * results. To throw an error if the number of results is unexpected (and + * prevent the query results from being committed to context), specify + * `expectedMatchCount`. * * ## Notes on property completeness * @@ -413,6 +416,23 @@ export interface CcApiContextQuery extends ContextLookupRoleOptions { */ readonly propertiesToReturn: string[]; + /** + * Expected count of results if `propertyMatch` is specified. + * + * If the expected result count does not match the actual count, + * by default an error is produced and the result is not committed to cached + * context, and the user can correct the situation and try again without + * having to manually clear out the context key using `cdk context --remove` + * + * If the value of * `ignoreErrorOnMissingContext` is `true`, the value of + * `expectedMatchCount` is `at-least-one | exactly-one` and the number + * of found resources is 0, `dummyValue` is returned and committed to context + * instead. + * + * @default 'any' + */ + readonly expectedMatchCount?: 'any' | 'at-least-one' | 'at-most-one' | 'exactly-one'; + /** * The value to return if the resource was not found and `ignoreErrorOnMissingContext` is true. * @@ -432,8 +452,8 @@ export interface CcApiContextQuery extends ContextLookupRoleOptions { * * - In case of an `exactIdentifier` lookup, return the `dummyValue` if the resource with * that identifier was not found. - * - In case of a `propertyMatch` lookup, this setting currently does not have any effect, - * as `propertyMatch` queries can legally return 0 resources. + * - In case of a `propertyMatch` lookup, return the `dummyValue` if `expectedMatchCount` + * is `at-least-one | exactly-one` and the number of resources found was 0. * * if `ignoreErrorOnMissingContext` is set, `dummyValue` should be set and be an array. * diff --git a/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.schema.json b/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.schema.json index 220587180..06d442784 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.schema.json +++ b/packages/@aws-cdk/cloud-assembly-schema/schema/cloud-assembly.schema.json @@ -1024,19 +1024,19 @@ ] }, "CcApiContextQuery": { - "description": "Query input for lookup up Cloudformation resources using CC API", + "description": "Query input for lookup up CloudFormation resources using CC API", "type": "object", "properties": { "typeName": { - "description": "The Cloudformation resource type.\nSee https://docs.aws.amazon.com/cloudcontrolapi/latest/userguide/supported-resources.html", + "description": "The CloudFormation resource type.\nSee https://docs.aws.amazon.com/cloudcontrolapi/latest/userguide/supported-resources.html", "type": "string" }, "exactIdentifier": { - "description": "Identifier of the resource to look up using `GetResource`.\n\nSpecifying exactIdentifier will return exactly one result, or throw an error. (Default - Either exactIdentifier or propertyMatch should be specified.)", + "description": "Identifier of the resource to look up using `GetResource`.\n\nSpecifying exactIdentifier will return exactly one result, or throw an error\nunless `ignoreErrorOnMissingContext` is set. (Default - Either exactIdentifier or propertyMatch should be specified.)", "type": "string" }, "propertyMatch": { - "description": "Returns any resources matching these properties, using `ListResources`.\n\nSpecifying propertyMatch will return 0 or more results.\n\n## Notes on property completeness\n\nCloudControl API's `ListResources` may return fewer properties than\n`GetResource` would, depending on the resource implementation.\n\nThe resources that `propertyMatch` matches against will *only ever* be the\nproperties returned by the `ListResources` call. (Default - Either exactIdentifier or propertyMatch should be specified.)", + "description": "Returns any resources matching these properties, using `ListResources`.\n\nBy default, specifying propertyMatch will successfully return 0 or more\nresults. To throw an error if the number of results is unexpected (and\nprevent the query results from being committed to context), specify\n`expectedMatchCount`.\n\n## Notes on property completeness\n\nCloudControl API's `ListResources` may return fewer properties than\n`GetResource` would, depending on the resource implementation.\n\nThe resources that `propertyMatch` matches against will *only ever* be the\nproperties returned by the `ListResources` call. (Default - Either exactIdentifier or propertyMatch should be specified.)", "$ref": "#/definitions/Record" }, "propertiesToReturn": { @@ -1046,11 +1046,21 @@ "type": "string" } }, + "expectedMatchCount": { + "description": "Expected count of results if `propertyMatch` is specified.\n\nIf the expected result count does not match the actual count,\nby default an error is produced and the result is not committed to cached\ncontext, and the user can correct the situation and try again without\nhaving to manually clear out the context key using `cdk context --remove`\n\nIf the value of * `ignoreErrorOnMissingContext` is `true`, the value of\n`expectedMatchCount` is `at-least-one | exactly-one` and the number\nof found resources is 0, `dummyValue` is returned and committed to context\ninstead. (Default 'any')", + "enum": [ + "any", + "at-least-one", + "at-most-one", + "exactly-one" + ], + "type": "string" + }, "dummyValue": { "description": "The value to return if the resource was not found and `ignoreErrorOnMissingContext` is true.\n\nIf supplied, `dummyValue` should be an array of objects.\n\n`dummyValue` does not have to have elements, and it may have objects with\ndifferent properties than the properties in `propertiesToReturn`, but it\nwill be easiest for downstream code if the `dummyValue` conforms to\nthe expected response shape. (Default - No dummy value available)" }, "ignoreErrorOnMissingContext": { - "description": "Ignore an error and return the `dummyValue` instead if the resource was not found.\n\n- In case of an `exactIdentifier` lookup, return the `dummyValue` if the resource with\n that identifier was not found.\n- In case of a `propertyMatch` lookup, this setting currently does not have any effect,\n as `propertyMatch` queries can legally return 0 resources.\n\nif `ignoreErrorOnMissingContext` is set, `dummyValue` should be set and be an array.", + "description": "Ignore an error and return the `dummyValue` instead if the resource was not found.\n\n- In case of an `exactIdentifier` lookup, return the `dummyValue` if the resource with\n that identifier was not found.\n- In case of a `propertyMatch` lookup, return the `dummyValue` if `expectedMatchCount`\n is `at-least-one | exactly-one` and the number of resources found was 0.\n\nif `ignoreErrorOnMissingContext` is set, `dummyValue` should be set and be an array.", "default": false, "type": "boolean" }, diff --git a/packages/@aws-cdk/cloud-assembly-schema/schema/version.json b/packages/@aws-cdk/cloud-assembly-schema/schema/version.json index 935cecd71..8e791e665 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/schema/version.json +++ b/packages/@aws-cdk/cloud-assembly-schema/schema/version.json @@ -1,4 +1,4 @@ { - "schemaHash": "5683db246fac20b864d94d7bceef24ebda1a38c8c1f8ef0d5978534097dc9504", - "revision": 42 + "schemaHash": "78936b0f9299bbe47497dd77f8065d71e65d8d739a0413ad7698ad03b22ef83e", + "revision": 43 } \ No newline at end of file diff --git a/packages/@aws-cdk/node-bundle/README.md b/packages/@aws-cdk/node-bundle/README.md index e386f54a6..2ec143bb0 100644 --- a/packages/@aws-cdk/node-bundle/README.md +++ b/packages/@aws-cdk/node-bundle/README.md @@ -151,4 +151,4 @@ Note that this will balloon up the package size significantly. If you are bundling a CLI application that also has top level exports, we suggest to extract the CLI functionality into a function, and add this function as an export to `index.js`. -> See [aws-cdk](https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk/bin/cdk.ts) as an example. \ No newline at end of file +> See [aws-cdk](https://github.com/aws/aws-cdk-cli/blob/main/packages/aws-cdk/bin/cdk) as an example. diff --git a/packages/@aws-cdk/toolkit-lib/.gitattributes b/packages/@aws-cdk/toolkit-lib/.gitattributes index c1b26c9d0..1a8feca5e 100644 --- a/packages/@aws-cdk/toolkit-lib/.gitattributes +++ b/packages/@aws-cdk/toolkit-lib/.gitattributes @@ -12,6 +12,7 @@ /.projen/deps.json linguist-generated /.projen/files.json linguist-generated /.projen/tasks.json linguist-generated +/api-extractor.json linguist-generated /jest.config.json linguist-generated /LICENSE linguist-generated /package.json linguist-generated diff --git a/packages/@aws-cdk/toolkit-lib/.gitignore b/packages/@aws-cdk/toolkit-lib/.gitignore index 1eb7f0c72..fc499472c 100644 --- a/packages/@aws-cdk/toolkit-lib/.gitignore +++ b/packages/@aws-cdk/toolkit-lib/.gitignore @@ -58,3 +58,4 @@ lib/**/*.js.map lib/init-templates/** !test/_fixtures/**/app.js !test/_fixtures/**/cdk.out +!/api-extractor.json diff --git a/packages/@aws-cdk/toolkit-lib/.projen/files.json b/packages/@aws-cdk/toolkit-lib/.projen/files.json index 493bbd87e..fb4daac5e 100644 --- a/packages/@aws-cdk/toolkit-lib/.projen/files.json +++ b/packages/@aws-cdk/toolkit-lib/.projen/files.json @@ -10,6 +10,7 @@ ".projen/deps.json", ".projen/files.json", ".projen/tasks.json", + "api-extractor.json", "jest.config.json", "LICENSE", "tsconfig.dev.json", diff --git a/packages/@aws-cdk/toolkit-lib/.projen/tasks.json b/packages/@aws-cdk/toolkit-lib/.projen/tasks.json index 88a1303be..8b19331fd 100644 --- a/packages/@aws-cdk/toolkit-lib/.projen/tasks.json +++ b/packages/@aws-cdk/toolkit-lib/.projen/tasks.json @@ -1,5 +1,13 @@ { "tasks": { + "api-extractor-docs": { + "name": "api-extractor-docs", + "steps": [ + { + "exec": "api-extractor run --local || true && mkdir -p dist/api-extractor-docs/cdk/api/toolkit-lib && cp dist/*.api.json dist/api-extractor-docs/cdk/api/toolkit-lib/ && (cat dist/version.txt || echo \"latest\") > dist/api-extractor-docs/cdk/api/toolkit-lib/VERSION && find . -type f -name \"*.md\" -not -path \"*/node_modules/*\" -not -path \"*/dist/*\" | while read file; do mkdir -p \"dist/api-extractor-docs/cdk/api/toolkit-lib/$(dirname \"$file\")\" && cp \"$file\" \"dist/api-extractor-docs/cdk/api/toolkit-lib/$file\"; done && cd dist/api-extractor-docs && zip -r ../api-extractor-docs.zip cdk" + } + ] + }, "build": { "name": "build", "description": "Full release build", @@ -151,6 +159,9 @@ { "exec": "npm pack --pack-destination dist/js" }, + { + "spawn": "api-extractor-docs" + }, { "spawn": "docs", "args": [ diff --git a/packages/@aws-cdk/toolkit-lib/api-extractor.json b/packages/@aws-cdk/toolkit-lib/api-extractor.json new file mode 100644 index 000000000..119264a12 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/api-extractor.json @@ -0,0 +1,36 @@ +{ + "projectFolder": ".", + "mainEntryPointFilePath": "/lib/index.d.ts", + "bundledPackages": [], + "apiReport": { + "enabled": false + }, + "docModel": { + "enabled": true, + "apiJsonFilePath": "./dist/.api.json", + "projectFolderUrl": "https://github.com/aws/aws-cdk-cli" + }, + "dtsRollup": { + "enabled": false + }, + "tsdocMetadata": { + "enabled": false + }, + "messages": { + "compilerMessageReporting": { + "default": { + "logLevel": "warning" + } + }, + "extractorMessageReporting": { + "default": { + "logLevel": "warning" + } + }, + "tsdocMessageReporting": { + "default": { + "logLevel": "warning" + } + } + } +} diff --git a/packages/@aws-cdk/toolkit-lib/package.json b/packages/@aws-cdk/toolkit-lib/package.json index eb253f574..b31694f04 100644 --- a/packages/@aws-cdk/toolkit-lib/package.json +++ b/packages/@aws-cdk/toolkit-lib/package.json @@ -7,6 +7,7 @@ "directory": "packages/@aws-cdk/toolkit-lib" }, "scripts": { + "api-extractor-docs": "npx projen api-extractor-docs", "build": "npx projen build", "bump": "npx projen bump", "check-for-updates": "npx projen check-for-updates", diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index 0a580ed62..bfbd7ae04 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -536,7 +536,7 @@ and might have breaking changes in the future. - `Fn::Split` - `Fn::Sub` -> *: `Fn::GetAtt` is only partially supported. Refer to [this implementation](https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk/lib/api/evaluate-cloudformation-template.ts#L477-L492) for supported resources and attributes. +> *: `Fn::GetAtt` is only partially supported. Refer to [this implementation](https://github.com/aws/aws-cdk-cli/blob/main/packages/aws-cdk/lib/api/cloudformation/evaluate-cloudformation-template.ts#L256-L266) for supported resources and attributes. ### `cdk rollback` diff --git a/packages/aws-cdk/lib/context-providers/cc-api-provider.ts b/packages/aws-cdk/lib/context-providers/cc-api-provider.ts index cfe040c64..d57a992c7 100644 --- a/packages/aws-cdk/lib/context-providers/cc-api-provider.ts +++ b/packages/aws-cdk/lib/context-providers/cc-api-provider.ts @@ -39,7 +39,7 @@ export class CcApiContextProviderPlugin implements ContextProviderPlugin { resources = await this.getResource(cloudControl, args.typeName, args.exactIdentifier); } else if (args.propertyMatch) { // use listResource - resources = await this.listResources(cloudControl, args.typeName, args.propertyMatch); + resources = await this.listResources(cloudControl, args.typeName, args.propertyMatch, args.expectedMatchCount); } else { throw new ContextProviderError(`Provider protocol error: neither exactIdentifier nor propertyMatch is specified in ${JSON.stringify(args)}.`); } @@ -98,6 +98,7 @@ export class CcApiContextProviderPlugin implements ContextProviderPlugin { cc: ICloudControlClient, typeName: string, propertyMatch: Record, + expectedMatchCount?: CcApiContextQuery['expectedMatchCount'], ): Promise { try { const result = await cc.listResources({ @@ -113,6 +114,13 @@ export class CcApiContextProviderPlugin implements ContextProviderPlugin { }); }); + if ((expectedMatchCount === 'at-least-one' || expectedMatchCount === 'exactly-one') && found.length === 0) { + throw new ZeroResourcesFoundError(`Could not find any resources matching ${JSON.stringify(propertyMatch)}`); + } + if ((expectedMatchCount === 'at-most-one' || expectedMatchCount === 'exactly-one') && found.length > 1) { + throw new ContextProviderError(`Found ${found.length} resources matching ${JSON.stringify(propertyMatch)}; please narrow the search criteria`); + } + return found; } catch (err: any) { if (!(err instanceof ContextProviderError) && !(err instanceof ZeroResourcesFoundError)) { diff --git a/packages/aws-cdk/test/context-providers/cc-api-provider.test.ts b/packages/aws-cdk/test/context-providers/cc-api-provider.test.ts index f425bb199..0c5a3df6c 100644 --- a/packages/aws-cdk/test/context-providers/cc-api-provider.test.ts +++ b/packages/aws-cdk/test/context-providers/cc-api-provider.test.ts @@ -4,6 +4,14 @@ import { mockCloudControlClient, MockSdkProvider, restoreSdkMocksToDefault } fro let provider: CcApiContextProviderPlugin; +const INDIFFERENT_PROPERTYMATCH_PROPS = { + account: '123456789012', + region: 'us-east-1', + typeName: 'AWS::RDS::DBInstance', + propertyMatch: { }, + propertiesToReturn: ['Index'], +}; + beforeEach(() => { provider = new CcApiContextProviderPlugin(new MockSdkProvider()); restoreSdkMocksToDefault(); @@ -180,6 +188,87 @@ test('looks up RDS instance using CC API listResources - error in CC API', async ).rejects.toThrow('error while listing AWS::RDS::DBInstance resources'); // THEN }); +test.each([ + [undefined], + ['any'], + ['at-most-one'], +] as const)('return an empty array for empty result when expectedMatchCount is %s', async (expectedMatchCount) => { + // GIVEN + mockCloudControlClient.on(ListResourcesCommand).resolves({ + ResourceDescriptions: [ + { Identifier: 'pl-xxxx', Properties: '{"PrefixListName":"name1","PrefixListId":"pl-xxxx","OwnerId":"123456789012"}' }, + { Identifier: 'pl-yyyy', Properties: '{"PrefixListName":"name1","PrefixListId":"pl-yyyy","OwnerId":"234567890123"}' }, + { Identifier: 'pl-zzzz', Properties: '{"PrefixListName":"name2","PrefixListId":"pl-zzzz","OwnerId":"123456789012"}' }, + ], + }); + + // WHEN + const results = await provider.getValue({ + account: '123456789012', + region: 'us-east-1', + typeName: 'AWS::EC2::PrefixList', + propertyMatch: { PrefixListName: 'name3' }, + propertiesToReturn: ['PrefixListId'], + expectedMatchCount, + }); + + // THEN + expect(results.length).toEqual(0); +}); + + +test.each([ + ['at-least-one'], + ['exactly-one'] +] as const)('throws an error for empty result when expectedMatchCount is %s', async (expectedMatchCount) => { + // GIVEN + mockCloudControlClient.on(ListResourcesCommand).resolves({ + ResourceDescriptions: [ + { Identifier: 'pl-xxxx', Properties: '{"PrefixListName":"name1","PrefixListId":"pl-xxxx","OwnerId":"123456789012"}' }, + { Identifier: 'pl-yyyy', Properties: '{"PrefixListName":"name1","PrefixListId":"pl-yyyy","OwnerId":"234567890123"}' }, + { Identifier: 'pl-zzzz', Properties: '{"PrefixListName":"name2","PrefixListId":"pl-zzzz","OwnerId":"123456789012"}' }, + ], + }); + + await expect( + // WHEN + provider.getValue({ + account: '123456789012', + region: 'us-east-1', + typeName: 'AWS::EC2::PrefixList', + propertyMatch: { PrefixListName: 'name3' }, + propertiesToReturn: ['PrefixListId'], + expectedMatchCount, + }), + ).rejects.toThrow('Could not find any resources matching {"PrefixListName":"name3"}'); // THEN +}); + +test.each([ + ['at-most-one'], + ['exactly-one'] +] as const)('throws an error for multiple results when expectedMatchCount is %s', async (expectedMatchCount) => { + // GIVEN + mockCloudControlClient.on(ListResourcesCommand).resolves({ + ResourceDescriptions: [ + { Identifier: 'pl-xxxx', Properties: '{"PrefixListName":"name1","PrefixListId":"pl-xxxx","OwnerId":"123456789012"}' }, + { Identifier: 'pl-yyyy', Properties: '{"PrefixListName":"name1","PrefixListId":"pl-yyyy","OwnerId":"234567890123"}' }, + { Identifier: 'pl-zzzz', Properties: '{"PrefixListName":"name2","PrefixListId":"pl-zzzz","OwnerId":"123456789012"}' }, + ], + }); + + await expect( + // WHEN + provider.getValue({ + account: '123456789012', + region: 'us-east-1', + typeName: 'AWS::EC2::PrefixList', + propertyMatch: { PrefixListName: 'name1' }, + propertiesToReturn: ['PrefixListId'], + expectedMatchCount, + }), + ).rejects.toThrow('Found 2 resources matching {"PrefixListName":"name1"}'); // THEN +}); + test('error by specifying both exactIdentifier and propertyMatch', async () => { // GIVEN mockCloudControlClient.on(GetResourceCommand).resolves({ @@ -425,6 +514,43 @@ describe('dummy value', () => { }), ).rejects.toThrow('dummyValue must be an array of objects'); }); + + test.each(['at-least-one', 'exactly-one'] as const)('dummyValue is returned when list operation returns 0 values for expectedMatchCount %p', async (expectedMatchCount) => { + // GIVEN + mockCloudControlClient.on(ListResourcesCommand).resolves({ + ResourceDescriptions: [] + }); + + // WHEN/THEN + await expect( + provider.getValue({ + ...INDIFFERENT_PROPERTYMATCH_PROPS, + expectedMatchCount, + ignoreErrorOnMissingContext: true, + dummyValue: [{ Dummy: true }], + }), + ).resolves.toEqual([{ Dummy: true }]); + }); + + test('ignoreErrorOnMissingContext does not suppress errors for at-most-one', async () => { + // GIVEN + mockCloudControlClient.on(ListResourcesCommand).resolves({ + ResourceDescriptions: [ + { Properties: JSON.stringify({ Index: 1 }) }, + { Properties: JSON.stringify({ Index: 2 }) }, + ] + }); + + // WHEN/THEN + await expect( + provider.getValue({ + ...INDIFFERENT_PROPERTYMATCH_PROPS, + expectedMatchCount: 'at-most-one', + ignoreErrorOnMissingContext: true, + dummyValue: [{ Dummy: true }], + }), + ).rejects.toThrow(/Found 2 resources matching/); + }); }); /* eslint-enable */ diff --git a/projenrc/api-extractor-docs-publishing.ts b/projenrc/api-extractor-docs-publishing.ts new file mode 100644 index 000000000..0c7664b37 --- /dev/null +++ b/projenrc/api-extractor-docs-publishing.ts @@ -0,0 +1,147 @@ +import type { Monorepo, TypeScriptWorkspace } from 'cdklabs-projen-project-types/lib/yarn'; +import { Component, github } from 'projen'; + +export interface ApiExtractorDocsPublishingProps { + /** + * The docs stream to publish to. + */ + readonly docsStream: string; + + /** + * The role arn (or github expression) for OIDC to assume to do the actual publishing. + */ + readonly roleToAssume: string; + + /** + * The bucket name (or github expression) to publish to. + */ + readonly bucketName: string; +} + +export class ApiExtractorDocsPublishing extends Component { + private readonly github: github.GitHub; + private readonly props: ApiExtractorDocsPublishingProps; + + constructor(project: TypeScriptWorkspace, props: ApiExtractorDocsPublishingProps) { + super(project); + + const gh = (project.parent! as Monorepo).github; + if (!gh) { + throw new Error('This workspace does not have a GitHub instance'); + } + this.github = gh; + + this.props = props; + + // Add a task to run api-extractor and zip the output + const apiExtractorDocsTask = project.addTask('api-extractor-docs', { + exec: [ + // Run api-extractor to generate the API model + // Use || true to ensure the task continues even if api-extractor reports failures + 'api-extractor run --local || true', + // Create a directory for the API model + 'mkdir -p dist/api-extractor-docs/cdk/api/toolkit-lib', + // Copy the API model to the directory + 'cp dist/*.api.json dist/api-extractor-docs/cdk/api/toolkit-lib/', + // Add version file + '(cat dist/version.txt || echo "latest") > dist/api-extractor-docs/cdk/api/toolkit-lib/VERSION', + // Find and copy all markdown files (excluding node_modules) + 'find . -type f -name "*.md" -not -path "*/node_modules/*" -not -path "*/dist/*" | while read file; do ' + + 'mkdir -p "dist/api-extractor-docs/cdk/api/toolkit-lib/$(dirname "$file")" && ' + + 'cp "$file" "dist/api-extractor-docs/cdk/api/toolkit-lib/$file"; ' + + 'done', + // Zip the API model and markdown files + 'cd dist/api-extractor-docs && zip -r ../api-extractor-docs.zip cdk', + ].join(' && '), + }); + + // Add the api-extractor-docs task to the package task + project.packageTask.spawn(apiExtractorDocsTask); + } + + public preSynthesize() { + const releaseWf = this.github.tryFindWorkflow('release'); + if (!releaseWf) { + throw new Error('Could not find release workflow'); + } + + const safeName = this.project.name.replace('@', '').replace('/', '-'); + + releaseWf.addJob(`${safeName}_release_api_extractor_docs`, { + name: `${this.project.name}: Publish API Extractor docs to S3`, + environment: 'releasing', // <-- this has the configuration + needs: [`${safeName}_release_npm`], + runsOn: ['ubuntu-latest'], + permissions: { + idToken: github.workflows.JobPermission.WRITE, + contents: github.workflows.JobPermission.READ, + }, + steps: [ + { + name: 'Download build artifacts', + uses: 'actions/download-artifact@v4', + with: { + name: `${safeName}_build-artifact`, + path: 'dist', + }, + }, + { + name: 'Authenticate Via OIDC Role', + id: 'creds', + uses: 'aws-actions/configure-aws-credentials@v4', + with: { + 'aws-region': 'us-east-1', + 'role-to-assume': '${{ vars.AWS_ROLE_TO_ASSUME_FOR_ACCOUNT }}', + 'role-session-name': 's3-api-extractor-docs-publishing@aws-cdk-cli', + 'mask-aws-account-id': true, + }, + }, + { + name: 'Assume the publishing role', + id: 'publishing-creds', + uses: 'aws-actions/configure-aws-credentials@v4', + with: { + 'aws-region': 'us-east-1', + 'role-to-assume': this.props.roleToAssume, + 'role-session-name': 's3-api-extractor-docs-publishing@aws-cdk-cli', + 'mask-aws-account-id': true, + 'role-chaining': true, + }, + }, + { + name: 'Publish API Extractor docs', + env: { + BUCKET_NAME: this.props.bucketName, + DOCS_STREAM: this.props.docsStream, + }, + run: `echo "Uploading API Extractor docs to S3" + echo "::add-mask::$BUCKET_NAME" + S3_PATH="$DOCS_STREAM/${safeName}-api-model-v$(cat dist/version.txt).zip" + LATEST="latest-api-model-${this.props.docsStream}" + + # Capture both stdout and stderr + if OUTPUT=$(aws s3api put-object \\ + --bucket "$BUCKET_NAME" \\ + --key "$S3_PATH" \\ + --body dist/api-extractor-docs.zip \\ + --if-none-match "*" 2>&1); then + + # File was uploaded successfully, update the latest pointer + echo "New API Extractor docs artifact uploaded successfully, updating latest pointer" + echo "$S3_PATH" | aws s3 cp - "s3://$BUCKET_NAME/$LATEST" + + elif echo "$OUTPUT" | grep -q "PreconditionFailed"; then + # Check specifically for PreconditionFailed in the error output + echo "::warning::File already exists in S3. Skipping upload." + exit 0 + + else + # Any other error (permissions, etc) + echo "::error::Failed to upload API Extractor docs artifact" + exit 1 + fi`, + }, + ], + }); + } +}