projenrc/cdk-cli-integ-tests.ts (320 lines of code) (raw):
import type { javascript } from 'projen';
import { Component, github } from 'projen';
const NOT_FLAGGED_EXPR = "!contains(github.event.pull_request.labels.*.name, 'pr/exempt-integ-test')";
/**
* Options for atmosphere service usage.
*/
export interface AtmosphereOptions {
/**
* Atmosphere service endpoint.
*/
readonly endpoint: string;
/**
* Which pool to retrieve environments from.
*/
readonly pool: string;
/**
* OIDC role to assume prior to using atmosphere. Must be allow listed
* on the service endpoint.
*/
readonly oidcRoleArn: string;
}
export interface CdkCliIntegTestsWorkflowProps {
/**
* Runners for the workflow
*/
readonly buildRunsOn: string;
/**
* Runners for the workflow
*/
readonly testRunsOn: string;
/**
* GitHub environment name for approvals
*
* MUST be configured to require manual approval.
*/
readonly approvalEnvironment: string;
/**
* GitHub environment name for running the tests
*
* MUST be configured without approvals, and with the following vars and secrets:
*
* - vars: AWS_ROLE_TO_ASSUME_FOR_TESTING
*
* And the role needs to be configured to allow the AssumeRole operation.
*/
readonly testEnvironment: string;
/**
* Packages that are locally transfered (we will never use the upstream versions)
*
* Takes package names; these are expected to live in `packages/<NAME>/dist/js`.
*/
readonly localPackages: string[];
/**
* The official repo this workflow is used for
*/
readonly sourceRepo: string;
/**
* If given, allows accessing upstream versions of these packages
*
* @default - No upstream versions
*/
readonly allowUpstreamVersions?: string[];
/**
* Enable atmosphere service to retrieve AWS test environments.
*
* @default - atmosphere is not used
*/
readonly enableAtmosphere?: AtmosphereOptions;
/**
* Specifies the maximum number of workers the worker-pool will spawn for running tests.
*
* @default - the cli integ test package determines a sensible default
*/
readonly maxWorkers?: string;
}
/**
* Add a workflow for running the tests
*
* This MUST be a separate workflow that runs in privileged context. We have a couple
* of options:
*
* - `workflow_run`: we can trigger a privileged workflow run after the unprivileged
* `pull_request` workflow finishes and reuse its output artifacts. The
* problem is that the second run is disconnected from the PR so we would need
* to script in visibility for approvals and success (by posting comments, for
* example)
* - Use only a `pull_request_target` workflow on the PR: this either would run
* a privileged workflow on any user code submission (might be fine given the
* workflow's `permissions`, but I'm sure this will make our security team uneasy
* anyway), OR this would mean any build needs human confirmation which means slow
* feedback.
* - Use a `pull_request` for a regular fast-feedback build, and a separate
* `pull_request_target` for the integ tests. This means we're building twice.
*
* Ultimately, our build isn't heavy enough to put in a lot of effort deduping
* it, so we'll go with the simplest solution which is the last one: 2
* independent workflows.
*
* projen doesn't make it easy to copy the relevant parts of the 'build' workflow,
* so they're unfortunately duplicated here.
*/
export class CdkCliIntegTestsWorkflow extends Component {
constructor(repo: javascript.NodeProject, props: CdkCliIntegTestsWorkflowProps) {
super(repo);
const buildWorkflow = repo.buildWorkflow;
const runTestsWorkflow = repo.github?.addWorkflow('integ');
if (!buildWorkflow || !runTestsWorkflow) {
throw new Error('Expected build and run tests workflow');
}
((buildWorkflow as any).workflow as github.GithubWorkflow);
props.allowUpstreamVersions?.forEach((pack) => {
if (!props.localPackages.includes(pack)) {
throw new Error(`Package in allowUpstreamVersions but not in localPackages: ${pack}`);
}
});
let maxWorkersArg = '';
if (props.maxWorkers) {
maxWorkersArg = ` --maxWorkers=${props.maxWorkers}`;
}
runTestsWorkflow.on({
pullRequestTarget: {
branches: [],
},
// Needs to trigger and report success on merge queue builds as well
mergeGroup: {},
// Never hurts to be able to run this manually
workflowDispatch: {},
});
// The 'build' part runs on the 'integ-approval' environment, which requires
// approval. The actual runs access the real environment, not requiring approval
// anymore.
//
// This is for 2 reasons:
// - The build job is the first one that runs. That means you get asked approval
// immediately after push, instead of 5 minutes later after the build completes.
// - The build job is only one job, versus the tests which are a matrix build.
// If the matrix test job needs approval, the Pull Request timeline gets spammed
// with an approval request for every individual run.
const JOB_PREPARE = 'prepare';
runTestsWorkflow.addJob(JOB_PREPARE, {
environment: props.approvalEnvironment,
runsOn: [props.buildRunsOn],
permissions: {
contents: github.workflows.JobPermission.READ,
},
env: {
CI: 'true',
},
// Don't run again on the merge queue, we already got confirmation that it works and the
// tests are quite expensive.
if: `github.event_name != 'merge_group' && ${NOT_FLAGGED_EXPR}`,
steps: [
{
name: 'Checkout',
uses: 'actions/checkout@v4',
with: {
// IMPORTANT! This must be `head.sha` not `head.ref`, otherwise we
// are vulnerable to a TOCTOU attack.
ref: '${{ github.event.pull_request.head.sha }}',
repository: '${{ github.event.pull_request.head.repo.full_name }}',
},
},
// We used to fetch tags from the repo using 'checkout', but if it's a fork
// the tags won't be there, so we have to fetch them from upstream.
//
// The tags are necessary to realistically bump versions
{
name: 'Fetch tags from origin repo',
run: [
// Can be either aws/aws-cdk-cli or aws/aws-cdk-cli-testing
// (Must clone over HTTPS because we have no SSH auth set up)
`git remote add upstream https://github.com/${props.sourceRepo}.git`,
'git fetch upstream \'refs/tags/*:refs/tags/*\'',
].join('\n'),
},
{
name: 'Setup Node.js',
uses: 'actions/setup-node@v4',
with: {
'node-version': 'lts/*',
},
},
{
name: 'Install dependencies',
run: 'yarn install --check-files',
},
{
name: 'Bump to realistic versions',
run: 'yarn workspaces run bump',
env: {
TESTING_CANDIDATE: 'true',
},
},
{
name: 'build',
run: 'npx projen build',
env: {
// This is necessary to prevent projen from resetting the version numbers to
// 0.0.0 during its synthesis.
RELEASE: 'true',
},
},
{
name: 'Upload artifact',
uses: 'actions/upload-artifact@v4.4.0',
with: {
name: 'build-artifact',
path: 'packages/**/dist/js/*.tgz',
overwrite: 'true',
},
},
],
});
const verdaccioConfig = {
storage: './storage',
auth: { htpasswd: { file: './htpasswd' } },
uplinks: { npmjs: { url: 'https://registry.npmjs.org/' } },
packages: {} as Record<string, unknown>,
};
for (const pack of props.localPackages) {
const allowUpstream = props.allowUpstreamVersions?.includes(pack);
verdaccioConfig.packages[pack] = {
access: '$all',
publish: '$all',
proxy: allowUpstream ? 'npmjs' : 'none',
};
}
verdaccioConfig.packages['**'] = {
access: '$all',
proxy: 'npmjs',
};
// bash only expands {...} if there's a , in there, otherwise it will leave the
// braces in literally. So we need to do case analysis here. Thanks, I hate it.
const tarballBashExpr = props.localPackages.length === 1
? `packages/${props.localPackages[0]}/dist/js/*.tgz`
: `packages/{${props.localPackages.join(',')}}/dist/js/*.tgz`;
// We create a matrix job for the test.
// This job will run all the different test suites in parallel.
const JOB_INTEG_MATRIX = 'integ_matrix';
runTestsWorkflow.addJob(JOB_INTEG_MATRIX, {
environment: props.testEnvironment,
runsOn: [props.testRunsOn],
needs: [JOB_PREPARE],
permissions: {
contents: github.workflows.JobPermission.READ,
idToken: github.workflows.JobPermission.WRITE,
},
env: {
// Otherwise Maven is too noisy
MAVEN_ARGS: '--no-transfer-progress',
// This is not actually a canary, but this prevents the tests from making
// assumptions about the availability of source packages.
IS_CANARY: 'true',
CI: 'true',
// This is necessary because the new versioning of @aws-cdk/cli-lib-alpha
// matches the CLI and not the framework.
CLI_LIB_VERSION_MIRRORS_CLI: 'true',
},
// Don't run again on the merge queue, we already got confirmation that it works and the
// tests are quite expensive.
if: `github.event_name != 'merge_group' && ${NOT_FLAGGED_EXPR}`,
strategy: {
failFast: false,
matrix: {
domain: {
suite: [
'cli-integ-tests',
'init-csharp',
'init-fsharp',
'init-go',
'init-java',
'init-javascript',
'init-python',
'init-typescript-app',
'init-typescript-lib',
'tool-integrations',
],
},
},
},
steps: [
{
name: 'Download build artifacts',
uses: 'actions/download-artifact@v4',
with: {
name: 'build-artifact',
path: 'packages',
},
},
{
name: 'Set up JDK 18',
if: 'matrix.suite == \'init-java\' || matrix.suite == \'cli-integ-tests\'',
uses: 'actions/setup-java@v4',
with: {
'java-version': '18',
'distribution': 'corretto',
},
},
{
name: 'Authenticate Via OIDC Role',
id: 'creds',
uses: 'aws-actions/configure-aws-credentials@v4',
with: {
'aws-region': 'us-east-1',
'role-duration-seconds': props.enableAtmosphere ? 60 * 60 : 4 * 60 * 60,
// Expect this in Environment Variables
'role-to-assume': props.enableAtmosphere ? props.enableAtmosphere.oidcRoleArn : '${{ vars.AWS_ROLE_TO_ASSUME_FOR_TESTING }}',
'role-session-name': 'run-tests@aws-cdk-cli-integ',
'output-credentials': true,
},
},
// This is necessary for the init tests to succeed, they set up a git repo.
{
name: 'Set git identity',
run: [
'git config --global user.name "aws-cdk-cli-integ"',
'git config --global user.email "noreply@example.com"',
].join('\n'),
},
{
name: 'Install Verdaccio',
run: 'npm install -g verdaccio pm2',
},
{
name: 'Create Verdaccio config',
run: [
'mkdir -p $HOME/.config/verdaccio',
`echo '${JSON.stringify(verdaccioConfig)}' > $HOME/.config/verdaccio/config.yaml`,
].join('\n'),
},
{
name: 'Start Verdaccio',
run: [
'pm2 start verdaccio -- --config $HOME/.config/verdaccio/config.yaml',
'sleep 5 # Wait for Verdaccio to start',
].join('\n'),
},
{
name: 'Configure npm to use local registry',
run: [
// This token is a bogus token. It doesn't represent any actual secret, it just needs to exist.
'echo \'//localhost:4873/:_authToken="MWRjNDU3OTE1NTljYWUyOTFkMWJkOGUyYTIwZWMwNTI6YTgwZjkyNDE0NzgwYWQzNQ=="\' > ~/.npmrc',
'echo \'registry=http://localhost:4873/\' >> ~/.npmrc',
].join('\n'),
},
{
name: 'Find an locally publish all tarballs',
run: [
`for pkg in ${tarballBashExpr}; do`,
' npm publish $pkg',
'done',
].join('\n'),
},
{
name: 'Download and install the test artifact',
run: [
'npm install @aws-cdk-testing/cli-integ',
].join('\n'),
},
{
name: 'Determine latest package versions',
id: 'versions',
run: [
'CLI_VERSION=$(cd ${TMPDIR:-/tmp} && npm view aws-cdk version)',
'echo "CLI version: ${CLI_VERSION}"',
'echo "cli_version=${CLI_VERSION}" >> $GITHUB_OUTPUT',
'LIB_VERSION=$(cd ${TMPDIR:-/tmp} && npm view aws-cdk-lib version)',
'echo "lib version: ${LIB_VERSION}"',
'echo "lib_version=${LIB_VERSION}" >> $GITHUB_OUTPUT',
].join('\n'),
},
{
name: 'Run the test suite: ${{ matrix.suite }}',
run: [
`npx run-suite${maxWorkersArg} --use-cli-release=\${{ steps.versions.outputs.cli_version }} --framework-version=\${{ steps.versions.outputs.lib_version }} \${{ matrix.suite }}`,
].join('\n'),
env: {
JSII_SILENCE_WARNING_DEPRECATED_NODE_VERSION: 'true',
JSII_SILENCE_WARNING_UNTESTED_NODE_VERSION: 'true',
JSII_SILENCE_WARNING_KNOWN_BROKEN_NODE_VERSION: 'true',
DOCKERHUB_DISABLED: 'true',
...(props.enableAtmosphere ?
{
CDK_INTEG_ATMOSPHERE_ENABLED: 'true',
CDK_INTEG_ATMOSPHERE_ENDPOINT: props.enableAtmosphere.endpoint,
CDK_INTEG_ATMOSPHERE_POOL: props.enableAtmosphere.pool,
} :
{
AWS_REGIONS: ['us-east-2', 'eu-west-1', 'eu-north-1', 'ap-northeast-1', 'ap-south-1'].join(','),
}),
CDK_MAJOR_VERSION: '2',
RELEASE_TAG: 'latest',
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}',
INTEG_LOGS: 'logs',
},
},
{
name: 'Set workflow summary',
run: [
'cat logs/md/*.md >> $GITHUB_STEP_SUMMARY',
].join('\n'),
},
{
name: 'Upload logs',
uses: 'actions/upload-artifact@v4.4.0',
id: 'logupload',
with: {
name: 'logs-${{ matrix.suite }}',
path: 'logs/',
overwrite: 'true',
},
},
{
name: 'Append artifact URL',
run: [
'echo "" >> $GITHUB_STEP_SUMMARY',
'echo "[Logs](${{ steps.logupload.outputs.artifact-url }})" >> $GITHUB_STEP_SUMMARY',
].join('\n'),
},
],
});
// Add a job that collates all matrix jobs into a single status
// This is required so that we can setup required status checks
// and if we ever change the test matrix, we don't need to update
// the status check configuration.
runTestsWorkflow.addJob('integ', {
permissions: {},
runsOn: [props.testRunsOn],
needs: [JOB_PREPARE, JOB_INTEG_MATRIX],
if: 'always()',
steps: [
{
name: 'Integ test result',
run: `echo \${{ needs.${JOB_INTEG_MATRIX}.result }}`,
},
{
// Don't fail the job if the test was successful or intentionally skipped
if: `\${{ !(contains(fromJSON('["success", "skipped"]'), needs.${JOB_PREPARE}.result) && contains(fromJSON('["success", "skipped"]'), needs.${JOB_INTEG_MATRIX}.result)) }}`,
name: 'Set status based on matrix job',
run: 'exit 1',
},
],
});
}
}