diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..ccaa150 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,83 @@ +name: Run the source through a pipeline + +on: + push: + paths-ignore: + - '**.md' + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + pipeline: + name: Build and deploy through CodePipeline + # The pipeline pulls the branch from this repository, so it cannot test branches in forks + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v4 + + # LocalStack stops tracking a build whose image is still downloading, + # so pull the CodeBuild images (about 17 GB) before starting the pipeline + - name: Pull the CodeBuild images + run: | + # Anonymous ECR Public pulls are rate limited per IP, which runners share + for image in public.ecr.aws/codebuild/amazonlinux-x86_64-standard:5.0 localstack/aws-codebuild-local:2; do + for attempt in 1 2 3 4 5; do + docker pull -q "$image" && break + [ "$attempt" = 5 ] && exit 1 + echo "Pull of $image failed, retrying in 60s" + sleep 60 + done + done + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: corretto + java-version: 11 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install lstk + run: | + npm install -g @localstack/lstk + lstk setup aws --force --non-interactive + + - name: Start LocalStack + env: + LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }} + # Forwarded as CODEPIPELINE_GH_TOKEN, used to download the source archive from GitHub + LOCALSTACK_CODEPIPELINE_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: lstk start + + - name: Run the pipeline + env: + REPOSITORY: ${{ github.repository }} + BRANCH: ${{ github.head_ref || github.ref_name }} + EXPECTED_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} + MAX_WAIT_SECONDS: 1200 + run: ./test.sh + + - name: Show pipeline state and LocalStack logs + if: failure() + run: | + lstk aws codepipeline get-pipeline-state --name source-pipeline || true + lstk aws codepipeline list-action-executions --pipeline-name source-pipeline || true + builds=$(lstk aws codebuild list-builds --query 'ids' --output text || true) + [ -n "$builds" ] && lstk aws codebuild batch-get-builds --ids $builds \ + --query 'builds[].{id: id, status: buildStatus, phases: phases[].[phaseType, phaseStatus, contexts[0].message]}' || true + lstk aws cloudformation describe-stack-events --stack-name source-pipeline-stack || true + docker ps -a + lstk logs | grep -vE 'ListPipelineExecutions|ListActionExecutions' | tail -400 diff --git a/README.md b/README.md index 6197279..46ceec3 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,25 @@ mvn install This compiles `MessageUtil.java`, runs the JUnit tests, and produces `target/messageUtil-1.0.jar`. +## Testing + +`test.sh` runs this repository through a LocalStack CodePipeline: + +1. **Source:** a CodeStarSourceConnection action fetches `$REPOSITORY` at `$BRANCH` (default `localstack-samples/test-codepipeline-source` at `main`). +2. **Build:** a CodeBuild action runs `buildspec.yml`, so the Maven build and the JUnit tests run in CodeBuild. +3. **Deploy:** a CloudFormation action creates `cfn_stack.yaml` with a queue name override. + +It then checks that the build artifact holds `target/messageUtil-1.0.jar` with the compiled `MessageUtil`, and that the stack created the queue and output its URL. It needs `javap` (any JDK) and LocalStack started with a GitHub token for downloading the source: + +```bash +LOCALSTACK_CODEPIPELINE_GH_TOKEN= lstk start +./test.sh +``` + +Pull `public.ecr.aws/codebuild/amazonlinux-x86_64-standard:5.0` before the first run. LocalStack stops tracking a build if its image is still downloading a few seconds after the build starts. + +CI runs the same test on every pull request against the pull request branch. The ECS blue/green deployment (`appspec.yml`, `Dockerfile`) is not covered. + ## License Licensed under the Apache License 2.0. See [LICENSE](LICENSE). diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..18ca8d0 --- /dev/null +++ b/test.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# Run this repository through a LocalStack CodePipeline, as the source of: +# 1. a CodeBuild action using buildspec.yml (Maven build and JUnit tests) +# 2. a CloudFormation action deploying cfn_stack.yaml +# Then check the build artifact holds the jar and the stack created the queue. +# LocalStack only runs V1 pipelines, so the pipeline type is set explicitly. +set -euo pipefail + +REPOSITORY=${REPOSITORY:-localstack-samples/test-codepipeline-source} +BRANCH=${BRANCH:-main} +PIPELINE=${PIPELINE:-source-pipeline} +PROJECT=${PROJECT:-message-util-build} +STACK=${STACK:-source-pipeline-stack} +QUEUE=${QUEUE:-source-pipeline-queue} +BUCKET=${BUCKET:-source-pipeline-artifacts} +ROLE_ARN=arn:aws:iam::000000000000:role/source-pipeline-role +MAX_WAIT_SECONDS=${MAX_WAIT_SECONDS:-1800} +WORK_DIR=$(mktemp -d) +trap 'rm -rf "$WORK_DIR"' EXIT + +echo "Source: $REPOSITORY@$BRANCH" + +lstk aws iam create-role --role-name source-pipeline-role --assume-role-policy-document '{ + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "sts:AssumeRole", "Principal": {"Service": [ + "codepipeline.amazonaws.com", "codebuild.amazonaws.com", "cloudformation.amazonaws.com"]}}] +}' > /dev/null +lstk aws iam put-role-policy --role-name source-pipeline-role --policy-name source-pipeline \ + --policy-document '{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": [ + "s3:*", "codebuild:*", "cloudformation:*", "sqs:*", "logs:*", "iam:PassRole"], "Resource": "*"}]}' + +CONNECTION_ARN=$(lstk aws codeconnections create-connection --connection-name source-pipeline \ + --provider-type GitHub --query ConnectionArn --output text) + +# No buildspec override, so CodeBuild uses buildspec.yml from the source +lstk aws codebuild create-project --name "$PROJECT" \ + --source type=CODEPIPELINE --artifacts type=CODEPIPELINE \ + --environment type=LINUX_CONTAINER,image=aws/codebuild/standard:5.0,computeType=BUILD_GENERAL1_SMALL \ + --service-role "$ROLE_ARN" > /dev/null + +lstk aws s3 mb "s3://$BUCKET" > /dev/null + +cat > "$WORK_DIR/pipeline.json" < /dev/null + +status="" +waited=0 +until [ "$status" = Succeeded ]; do + if [[ $status =~ ^(Failed|Stopped|Superseded)$ ]]; then + echo "Test failed: pipeline execution ended with status $status" + lstk aws codepipeline get-pipeline-state --name "$PIPELINE" + exit 1 + fi + if [ "$status" = None ] && (( waited >= 120 )); then + echo "Test failed: no pipeline execution started within 120s" + exit 1 + fi + if (( waited >= MAX_WAIT_SECONDS )); then + echo "Test failed: pipeline did not finish within ${MAX_WAIT_SECONDS}s, last status: $status" + exit 1 + fi + sleep 10 + waited=$((waited + 10)) + status=$(lstk aws codepipeline list-pipeline-executions --pipeline-name "$PIPELINE" \ + --query 'pipelineExecutionSummaries[0].status' --output text) +done +echo "PASS: pipeline execution succeeded" + +source_revision=$(lstk aws codepipeline list-action-executions --pipeline-name "$PIPELINE" \ + --query "actionExecutionDetails[?actionName=='Source'].output.outputVariables.CommitId | [0]" --output text) +if [ -n "${EXPECTED_COMMIT:-}" ] && [ "$source_revision" != "$EXPECTED_COMMIT" ]; then + echo "Test failed: the source action fetched $source_revision, expected $EXPECTED_COMMIT" + exit 1 +fi +echo "PASS: the source action fetched commit $source_revision" + +read -r out_bucket out_key < <(lstk aws codepipeline list-action-executions --pipeline-name "$PIPELINE" \ + --query "actionExecutionDetails[?actionName=='Build'].output.outputArtifacts[0].s3location | [0].[bucket, key]" \ + --output text) +lstk aws s3 cp "s3://$out_bucket/$out_key" "$WORK_DIR/build-output.zip" > /dev/null +unzip -q -o "$WORK_DIR/build-output.zip" -d "$WORK_DIR/build-output" +JAR="$WORK_DIR/build-output/target/messageUtil-1.0.jar" +if [ ! -f "$JAR" ]; then + echo "Test failed: the build artifact has no target/messageUtil-1.0.jar" + unzip -l "$WORK_DIR/build-output.zip" + exit 1 +fi +methods=$(javap -cp "$JAR" MessageUtil) +if [[ "$methods" != *"printMessage()"* || "$methods" != *"salutationMessage()"* ]]; then + echo "Test failed: MessageUtil in the jar is missing its methods: $methods" + exit 1 +fi +echo "PASS: the build artifact holds messageUtil-1.0.jar with the compiled MessageUtil" + +stack_status=$(lstk aws cloudformation describe-stacks --stack-name "$STACK" \ + --query 'Stacks[0].StackStatus' --output text) +queue_output=$(lstk aws cloudformation describe-stacks --stack-name "$STACK" \ + --query "Stacks[0].Outputs[?OutputKey=='QueueURL'].OutputValue | [0]" --output text) +queue_url=$(lstk aws sqs get-queue-url --queue-name "$QUEUE" --query QueueUrl --output text) +if [ "$stack_status" != CREATE_COMPLETE ] || [ "$queue_output" != "$queue_url" ]; then + echo "Test failed: stack $STACK is $stack_status with QueueURL '$queue_output', queue URL is '$queue_url'" + exit 1 +fi +echo "PASS: cfn_stack.yaml created queue $QUEUE and output its URL" + +echo "Test passed: the repository builds and deploys through CodePipeline"