From 38de5b64f4d29f46bd1f544eba4a943956697cda Mon Sep 17 00:00:00 2001 From: Courtney Pacheco <6019922+courtneypacheco@users.noreply.github.com> Date: Thu, 27 Feb 2025 02:38:33 -0500 Subject: [PATCH] Add `launch-ec2-runner-with-fallback` action This action is used to launch an EC2 instance (either as a spot instance or a dedicated instance) in a desired availability zone. If no availability, then backup availability zones will be tried until one is successful. Signed-off-by: Courtney Pacheco <6019922+courtneypacheco@users.noreply.github.com> --- .github/workflows/test.yml | 126 ++++ .yamllint.yaml | 5 +- README.md | 1 + actions/free-disk-space/action.yml | 3 + .../action.yml | 650 ++++++++++++++++++ .../launch-ec2-runner-with-fallback.md | 258 +++++++ .../scripts/get_config_value.sh | 150 ++++ ...nvalid-regions-config-missing-ec2-ami.json | 20 + ...ions-config-missing-security-group-id.json | 20 + ...invalid-regions-config-missing-subnet.json | 16 + .../tests/test-data/valid-regions-config.json | 22 + .../tests/test_get_config_value.sh | 259 +++++++ tox.ini | 12 + 13 files changed, 1541 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml create mode 100644 actions/launch-ec2-runner-with-fallback/action.yml create mode 100644 actions/launch-ec2-runner-with-fallback/launch-ec2-runner-with-fallback.md create mode 100755 actions/launch-ec2-runner-with-fallback/scripts/get_config_value.sh create mode 100644 actions/launch-ec2-runner-with-fallback/tests/test-data/invalid-regions-config-missing-ec2-ami.json create mode 100644 actions/launch-ec2-runner-with-fallback/tests/test-data/invalid-regions-config-missing-security-group-id.json create mode 100644 actions/launch-ec2-runner-with-fallback/tests/test-data/invalid-regions-config-missing-subnet.json create mode 100644 actions/launch-ec2-runner-with-fallback/tests/test-data/valid-regions-config.json create mode 100755 actions/launch-ec2-runner-with-fallback/tests/test_get_config_value.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9a5c4fc --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,126 @@ +# SPDX-License-Identifier: Apache-2.0 +# yamllint disable rule:line-length + +name: Test + +on: + workflow_dispatch: + push: + branches: + - "main" + - "release-**" + paths: + - '**.py' + - 'requirements**.txt' + - 'tox.ini' + - 'actions/**.sh' + - '.github/workflows/test.yml' # This workflow + pull_request: + branches: + - "main" + - "release-**" + paths: + - '**.py' + - 'requirements**.txt' + - 'tox.ini' + - 'actions/**.sh' + - '.github/workflows/test.yml' # This workflow + +env: + LC_ALL: en_US.UTF-8 + +defaults: + run: + shell: bash + +permissions: + contents: read + +jobs: + test-workflow-ready: + permissions: + checks: read + uses: ./.github/workflows/status-checks.yml + with: + # The unit and functional tests will not start until the following job IDs have succeeded + job_ids: >- # Space-separated job ids to wait on for status checks + DCO + shellcheck + lint-workflow-complete + + test: + # Start name with 'test:' for test-workflow-complete job_ids + name: "test: ${{ matrix.python }} on ${{ matrix.platform }}" + needs: ["test-workflow-ready"] + runs-on: "${{ matrix.platform }}" + strategy: + fail-fast: false + matrix: + python: + - "3.10" + - "3.11" + platform: + - "ubuntu-latest" + include: + - python: "3.11" + platform: "macos-latest" + steps: + - name: "Harden Runner" + uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0 + with: + egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs + + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + # https://github.com/actions/checkout/issues/249 + fetch-depth: 0 + + # Always apt-get update before installing any extra packages + # https://docs.github.com/en/actions/using-github-hosted-runners/using-github-hosted-runners/customizing-github-hosted-runners#installing-software-on-ubuntu-runners + - name: Update apt index + if: startsWith(matrix.platform, 'ubuntu') + run: | + sudo apt-get update + + - name: Install tools on MacOS + if: startsWith(matrix.platform, 'macos') + run: | + brew install expect coreutils bash skopeo + + - name: Setup Python ${{ matrix.python }} + uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 + with: + python-version: ${{ matrix.python }} + ## TODO: Uncomment once this change merges into `main`. We can't use caching until it's merged. + # cache: pip + # cache-dependency-path: | + # **/requirements*.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install tox tox-gh>=1.2 + + - name: Run Bash "unit" tests with tox on MacOS + if: startsWith(matrix.platform, 'macos') + run: | + tox -e launch-ec2-runner-with-fallback + + # Default shell on Ubuntu is 'dash', so we need to override the default shell + - name: Run Bash "unit" tests with tox on Linux + if: startsWith(matrix.platform, 'ubuntu') + run: | + # Forcefully overwrite the symlink from `/bin/sh -> /bin/dash` to: `/bin/sh -> /bin/bash`. + sudo ln -sf /bin/bash /bin/sh + tox -e launch-ec2-runner-with-fallback + shell: bash + + test-workflow-complete: + permissions: + checks: read + uses: ./.github/workflows/status-checks.yml + with: + job_ids: >- # Space-separated job ids to wait on for status checks + test-workflow-ready + test: \ No newline at end of file diff --git a/.yamllint.yaml b/.yamllint.yaml index 7b73842..75c0189 100644 --- a/.yamllint.yaml +++ b/.yamllint.yaml @@ -8,4 +8,7 @@ rules: line-length: max: 120 allow-non-breakable-words: true - allow-non-breakable-inline-mappings: false \ No newline at end of file + allow-non-breakable-inline-mappings: false + ignore: + # REASON: This file needs lines that are longer than 120 in order to load variables to the env + - /actions/launch-ec2-runner-with-fallback/action.yml \ No newline at end of file diff --git a/README.md b/README.md index c73a720..a6c918a 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Below is a list of the in-house GitHub actions stored in this repository: | Name | Description | Example Use Cases | | --- | --- | --- | | [free-disk-space](./actions/free-disk-space/free-disk-space.md) | Used to reclaim disk space on either a GitHub or EC2 runner. | | +| [launch-ec2-runner-with-fallback](./actions/launch-ec2-runner-with-fallback/launch-ec2-runner-with-fallback.md) | Used launch an EC2 instance in AWS, either as a spot instance or a dedicated instance. If your preferred availability zone lacks availability for your instance type, "backup" availability zones will be tried. | | ## How to Use One or More In-House GitHub Actions diff --git a/actions/free-disk-space/action.yml b/actions/free-disk-space/action.yml index 96af0ce..4e7f9a5 100644 --- a/actions/free-disk-space/action.yml +++ b/actions/free-disk-space/action.yml @@ -1,5 +1,8 @@ +# SPDX-License-Identifier: Apache-2.0 + name: 'Free Disk Space' description: 'Frees disk space on the runner' +author: 'InstructLab' runs: using: "composite" steps: diff --git a/actions/launch-ec2-runner-with-fallback/action.yml b/actions/launch-ec2-runner-with-fallback/action.yml new file mode 100644 index 0000000..f4012e3 --- /dev/null +++ b/actions/launch-ec2-runner-with-fallback/action.yml @@ -0,0 +1,650 @@ +# SPDX-License-Identifier: Apache-2.0 + +name: "Launch EC2 Runner with Fallback" +description: "Launches an EC2 instance in AWS, incorporating fallback logic if the desired AZ or region has insufficient instance capacity." +author: "InstructLab" +inputs: + ########################## + # AWS authentication # + ########################## + aws_access_key_id: + required: true + description: >- + AWS access key ID that will be used to launch your desired instance. + aws_secret_access_key: + required: true + description: >- + AWS secret access key that will be used to launch your desired instance. + github_token: + required: true + description: >- + GitHub Personal Access Token with the `repo` scope assigned. + + ############################## + # Region launching configs # + ############################## + regions_config: + required: true + description: >- + A JSON string that defines which regions and subnets to try, along with the AMIs and security groups to use within those regions. + + # # EXAMPLE INPUT STRING FOR `regions_config`: + # [ + # { + # "region": "us-east-1", + # "subnets": { + # "us-east-1a": "${{ secrets.SUBNET_US_EAST_1A }}", + # "us-east-1b": "${{ secrets.SUBNET_US_EAST_1B }}", + # "us-east-1c": "${{ secrets.SUBNET_US_EAST_1C }}", + # }, + # "ec2-ami": "${{ secrets.EC2_AMI_US_EAST_1 }}", + # "security-group-id": "${{ secrets.SECURITY_GROUP_ID_US_EAST_1 }}", + # }, + # { + # "region": "us-east-2", + # "subnets": { + # "us-east-2a": "${{ secrets.SUBNET_US_EAST_2A }}", + # "us-east-2b": "${{ secrets.SUBNET_US_EAST_2B }}", + # "us-east-2c": "${{ secrets.SUBNET_US_EAST_2C }}", + # "us-east-2d": "${{ secrets.SUBNET_US_EAST_2D }}", + # "us-east-2e": "${{ secrets.SUBNET_US_EAST_2E }}", + # } + # "ec2-ami": "${{ secrets.EC2_AMI_US_EAST_2 }}", + # "security-group-id": "${{ secrets.SECURITY_GROUP_ID_US_EAST_2 }}", + # }, + # ] + + ######################################################## + # Generic AWS instance configs, regardless of region # + ######################################################## + ec2_instance_type: + required: true + description: >- + The desired AWS instance type to use, regardless of region. (Note that some instance types are not available in certain regions.) + aws_resource_tags: + required: true # We should require resource tags for resource management purposes, even though AWS sees them as optional + description: >- + Resource tags to apply to the desired AWS instance, upon successful launch. + try_spot_instance_first: + required: false + description: >- + If set to "true", then the EC2 instance will be launched as a spot instance rather than a dedicated EC2 instance. If a spot + instance cannot be launched in any of the desired availability zones (e.g., due to insufficient capacity on AWS), then a + dedicated instance will be tried next. (Note: This option is not always desirable for certain instance types.) + +# Contains the necessary metadata to stop an EC2 instance +outputs: + label: + value: ${{ steps.selected-availability-zone.outputs.label }} + description: The EC2 availability zone that the instance successfully launched under + ec2-instance-id: + value: ${{ steps.selected-availability-zone.outputs.ec2-instance-id }} + description: The instance ID associated with the successfully-launched EC2 instance + ec2-instance-region: + value: ${{ steps.selected-availability-zone.outputs.ec2-instance-region}} + description: The region where the EC2 instance was launched + +runs: + using: "composite" + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + # https://github.com/actions/checkout/issues/249 + fetch-depth: 0 + # Must set 'path' due to a bug with actions/checkout + path: "pull-request-changes" + + #################################### + # Preload configs to env # + #################################### + - name: "Read and load configs" + id: load-configs + run: | + # shellcheck disable=SC2086 + # Save `regions_config` input to a file readable by the CLI tool. + echo '${{ inputs.regions_config }}' >> ${REGIONS_TMP} + + # We need to reference this action's bash scripts to obtain configs + cd ${{ github.action_path }}/scripts + + ################# LOAD 'us-east-2a' CONFIGS ################# + availability_zone="us-east-2a" + subnet_id=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "subnet") + ec2_ami=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "ec2-ami") + security_group_id=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "security-group-id") + echo "US_EAST_2A_SUBNET_ID=${subnet_id}" >> "$GITHUB_OUTPUT" + echo "US_EAST_2A_SECURITY_GROUP_ID=${security_group_id}" >> "$GITHUB_OUTPUT" + echo "US_EAST_2A_AWS_EC2_AMI=${ec2_ami}" >> "$GITHUB_OUTPUT" + + echo "Loaded 'us-east-2a' confgs" + + ################# LOAD 'us-east-2b' CONFIGS ################# + availability_zone="us-east-2b" + subnet_id=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "subnet") + ec2_ami=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "ec2-ami") + security_group_id=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "security-group-id") + echo "US_EAST_2B_SUBNET_ID=${subnet_id}" >> "$GITHUB_OUTPUT" + echo "US_EAST_2B_SECURITY_GROUP_ID=${security_group_id}" >> "$GITHUB_OUTPUT" + echo "US_EAST_2B_AWS_EC2_AMI=${ec2_ami}" >> "$GITHUB_OUTPUT" + + echo "Loaded 'us-east-2b' confgs" + + ################# LOAD 'us-east-2c' CONFIGS ################# + availability_zone="us-east-2c" + subnet_id=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "subnet") + ec2_ami=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "ec2-ami") + security_group_id=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "security-group-id") + echo "US_EAST_2C_SUBNET_ID=${subnet_id}" >> "$GITHUB_OUTPUT" + echo "US_EAST_2C_SECURITY_GROUP_ID=${security_group_id}" >> "$GITHUB_OUTPUT" + echo "US_EAST_2C_AWS_EC2_AMI=${ec2_ami}" >> "$GITHUB_OUTPUT" + + echo "Loaded 'us-east-2c' confgs" + + ################# LOAD 'us-east-1a' CONFIGS ################# + availability_zone="us-east-1a" + subnet_id=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "subnet") + ec2_ami=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "ec2-ami") + security_group_id=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "security-group-id") + echo "US_EAST_1A_SUBNET_ID=${subnet_id}" >> "$GITHUB_OUTPUT" + echo "US_EAST_1A_SECURITY_GROUP_ID=${security_group_id}" >> "$GITHUB_OUTPUT" + echo "US_EAST_1A_AWS_EC2_AMI=${ec2_ami}" >> "$GITHUB_OUTPUT" + + echo "Loaded 'us-east-1a' confgs" + + ################# LOAD 'us-east-1b' CONFIGS ################# + availability_zone="us-east-1b" + subnet_id=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "subnet") + ec2_ami=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "ec2-ami") + security_group_id=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "security-group-id") + echo "US_EAST_1B_SUBNET_ID=${subnet_id}" >> "$GITHUB_OUTPUT" + echo "US_EAST_1B_SECURITY_GROUP_ID=${security_group_id}" >> "$GITHUB_OUTPUT" + echo "US_EAST_1B_AWS_EC2_AMI=${ec2_ami}" >> "$GITHUB_OUTPUT" + + echo "Loaded 'us-east-1b' confgs" + + ################# LOAD 'us-east-1c' CONFIGS ################# + availability_zone="us-east-1c" + subnet_id=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "subnet") + ec2_ami=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "ec2-ami") + security_group_id=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "security-group-id") + echo "US_EAST_1C_SUBNET_ID=${subnet_id}" >> "$GITHUB_OUTPUT" + echo "US_EAST_1C_SECURITY_GROUP_ID=${security_group_id}" >> "$GITHUB_OUTPUT" + echo "US_EAST_1C_AWS_EC2_AMI=${ec2_ami}" >> "$GITHUB_OUTPUT" + + echo "Loaded 'us-east-1c' confgs" + + ################# LOAD 'us-east-1d' CONFIGS ################# + availability_zone="us-east-1d" + subnet_id=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "subnet") + ec2_ami=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "ec2-ami") + security_group_id=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "security-group-id") + echo "US_EAST_1D_SUBNET_ID=${subnet_id}" >> "$GITHUB_OUTPUT" + echo "US_EAST_1D_SECURITY_GROUP_ID=${security_group_id}" >> "$GITHUB_OUTPUT" + echo "US_EAST_1D_AWS_EC2_AMI=${ec2_ami}" >> "$GITHUB_OUTPUT" + + echo "Loaded 'us-east-1d' confgs" + + ################# LOAD 'us-east-1e' CONFIGS ################# + availability_zone="us-east-1e" + subnet_id=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "subnet") + ec2_ami=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "ec2-ami") + security_group_id=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "security-group-id") + echo "US_EAST_1E_SUBNET_ID=${subnet_id}" >> "$GITHUB_OUTPUT" + echo "US_EAST_1E_SECURITY_GROUP_ID=${security_group_id}" >> "$GITHUB_OUTPUT" + echo "US_EAST_1E_AWS_EC2_AMI=${ec2_ami}" >> "$GITHUB_OUTPUT" + + echo "Loaded 'us-east-1e' confgs" + + ################# LOAD 'us-east-1f' CONFIGS ################# + availability_zone="us-east-1f" + subnet_id=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "subnet") + ec2_ami=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "ec2-ami") + security_group_id=$(./get_config_value.sh -c "${REGIONS_TMP}" -l "${{ env.LOG_LEVEL }}" -z "${availability_zone}" -v "security-group-id") + echo "US_EAST_1F_SUBNET_ID=${subnet_id}" >> "$GITHUB_OUTPUT" + echo "US_EAST_1F_SECURITY_GROUP_ID=${security_group_id}" >> "$GITHUB_OUTPUT" + echo "US_EAST_1F_AWS_EC2_AMI=${ec2_ami}" >> "$GITHUB_OUTPUT" + + echo "Loaded 'us-east-1f' confgs" + + # Cleanup + rm -rf ${REGIONS_TMP} + shell: bash + env: + LOG_LEVEL: "ERROR" + REGIONS_TMP: "/tmp/regions-test.txt" + + #################################### + # Spot Instance Launching Attempts # + #################################### + # 1.) Try us-east-2a first + - name: "[ SPOT INSTANCE ] Configure AWS credentials for us-east-2" + uses: aws-actions/configure-aws-credentials@ececac1a45f3b08a01d2dd070d28d111c5fe6722 # v4.1.0 + with: + aws-access-key-id: ${{ inputs.aws_access_key_id }} + aws-secret-access-key: ${{ inputs.aws_secret_access_key }} + aws-region: "us-east-2" + + - name: "[ SPOT INSTANCE ] Attempt to start EC2 runner as a SPOT instance on us-east-2a" + if: inputs.try_spot_instance_first == 'true' || inputs.try_spot_instance_first == 'True' + id: start-ec2-spot-runner-us-east-2a + uses: machulav/ec2-github-runner@28fbe1c4d7d9ba74134ca5ebc559d5b0a989a856 # v2.3.8 + continue-on-error: true + with: + mode: start + market-type: spot # Define spot instance here + github-token: ${{ inputs.github_token }} + ec2-image-id: ${{ steps.load-configs.outputs.US_EAST_2A_AWS_EC2_AMI }} + ec2-instance-type: ${{ inputs.ec2_instance_type }} + subnet-id: ${{ steps.load-configs.outputs.US_EAST_2A_SUBNET_ID }} + security-group-id: "${{ steps.load-configs.outputs.US_EAST_2A_SECURITY_GROUP_ID }}" + iam-role-name: instructlab-ci-runner + aws-resource-tags: ${{ inputs.aws_resource_tags }} + + # 2.) Try us-east-2b next + # (NOTE: We don't need to check if "try_spot_instance_first" is true or false. If the us-east-2a spot instance attempt + # wasn't triggered, then it's impossible for: ".outcome == failure". The ".outcome" variable will be empty in that case.) + - name: "[ SPOT INSTANCE ] Attempt to start EC2 runner as a SPOT instance on us-east-2b" + if: steps.start-ec2-spot-runner-us-east-2a.outcome == 'failure' + id: start-ec2-spot-runner-us-east-2b + uses: machulav/ec2-github-runner@28fbe1c4d7d9ba74134ca5ebc559d5b0a989a856 # v2.3.8 + continue-on-error: true + with: + mode: start + market-type: spot # Define spot instance here + github-token: ${{ inputs.github_token }} + ec2-image-id: ${{ steps.load-configs.outputs.US_EAST_2B_AWS_EC2_AMI }} + ec2-instance-type: ${{ inputs.ec2_instance_type }} + subnet-id: ${{ steps.load-configs.outputs.US_EAST_2B_SUBNET_ID }} + security-group-id: ${{ steps.load-configs.outputs.US_EAST_2B_SECURITY_GROUP_ID }} + iam-role-name: instructlab-ci-runner + aws-resource-tags: ${{ inputs.aws_resource_tags }} + + # 3.) Try us-east-2c next + - name: "[ SPOT INSTANCE ] Attempt to start EC2 runner as a SPOT instance on us-east-2c" + if: steps.start-ec2-spot-runner-us-east-2b.outcome == 'failure' + id: start-ec2-spot-runner-us-east-2c + uses: machulav/ec2-github-runner@28fbe1c4d7d9ba74134ca5ebc559d5b0a989a856 # v2.3.8 + continue-on-error: true + with: + mode: start + market-type: spot # Define spot instance here + github-token: ${{ inputs.github_token }} + ec2-image-id: ${{ steps.load-configs.outputs.US_EAST_2C_AWS_EC2_AMI }} + ec2-instance-type: ${{ inputs.ec2_instance_type }} + subnet-id: ${{ steps.load-configs.outputs.US_EAST_2C_SUBNET_ID }} + security-group-id: ${{ steps.load-configs.outputs.US_EAST_2C_SECURITY_GROUP_ID }} + iam-role-name: instructlab-ci-runner + aws-resource-tags: ${{ inputs.aws_resource_tags }} + + # 4.) Try us-east-1a next + - name: "[ SPOT INSTANCE ] Configure AWS credentials for us-east-1" + if: steps.start-ec2-spot-runner-us-east-2c.outcome == 'failure' + uses: aws-actions/configure-aws-credentials@ececac1a45f3b08a01d2dd070d28d111c5fe6722 # v4.1.0 + with: + aws-access-key-id: ${{ inputs.aws_access_key_id }} + aws-secret-access-key: ${{ inputs.aws_secret_access_key }} + aws-region: "us-east-1" + + - name: "[ SPOT INSTANCE ] Attempt to start EC2 runner as a SPOT instance on us-east-1a" + if: steps.start-ec2-spot-runner-us-east-2c.outcome == 'failure' + id: start-ec2-spot-runner-us-east-1a + uses: machulav/ec2-github-runner@28fbe1c4d7d9ba74134ca5ebc559d5b0a989a856 # v2.3.8 + continue-on-error: true + with: + mode: start + market-type: spot # Define spot instance here + github-token: ${{ inputs.github_token }} + ec2-image-id: ${{ steps.load-configs.outputs.US_EAST_1A_AWS_EC2_AMI }} + ec2-instance-type: ${{ inputs.ec2_instance_type }} + subnet-id: ${{ steps.load-configs.outputs.US_EAST_1A_SUBNET_ID }} + security-group-id: ${{ steps.load-configs.outputs.US_EAST_1A_SECURITY_GROUP_ID }} + iam-role-name: instructlab-ci-runner + aws-resource-tags: ${{ inputs.aws_resource_tags }} + + # 5.) Try us-east-1b next + - name: "[ SPOT INSTANCE ] Attempt to start EC2 runner as a SPOT instance on us-east-1b (if no availability still)" + if: steps.start-ec2-spot-runner-us-east-1a.outcome == 'failure' + id: start-ec2-spot-runner-us-east-1b + uses: machulav/ec2-github-runner@28fbe1c4d7d9ba74134ca5ebc559d5b0a989a856 # v2.3.8 + continue-on-error: true + with: + mode: start + market-type: spot # Define spot instance here + github-token: ${{ inputs.github_token }} + ec2-image-id: ${{ steps.load-configs.outputs.US_EAST_1B_AWS_EC2_AMI }} + ec2-instance-type: ${{ inputs.ec2_instance_type }} + subnet-id: ${{ steps.load-configs.outputs.US_EAST_1B_SUBNET_ID }} + security-group-id: ${{ steps.load-configs.outputs.US_EAST_1B_SECURITY_GROUP_ID }} + iam-role-name: instructlab-ci-runner + aws-resource-tags: ${{ inputs.aws_resource_tags }} + + # 6.) Try us-east-1c next + - name: "[ SPOT INSTANCE ] Attempt to start EC2 runner as a SPOT instance on us-east-1c (if no availability still)" + if: steps.start-ec2-spot-runner-us-east-1b.outcome == 'failure' + id: start-ec2-spot-runner-us-east-1c + uses: machulav/ec2-github-runner@28fbe1c4d7d9ba74134ca5ebc559d5b0a989a856 # v2.3.8 + continue-on-error: true + with: + mode: start + market-type: spot # Define spot instance here + github-token: ${{ inputs.github_token }} + ec2-image-id: ${{ steps.load-configs.outputs.US_EAST_1C_AWS_EC2_AMI }} + ec2-instance-type: ${{ inputs.ec2_instance_type }} + subnet-id: ${{ steps.load-configs.outputs.US_EAST_1C_SUBNET_ID }} + security-group-id: ${{ steps.load-configs.outputs.US_EAST_1C_SECURITY_GROUP_ID }} + iam-role-name: instructlab-ci-runner + aws-resource-tags: ${{ inputs.aws_resource_tags }} + + # 7.) Try us-east-1d next + - name: "[ SPOT INSTANCE ] Attempt to start EC2 runner as a SPOT instance on us-east-1d (if no availability still)" + if: steps.start-ec2-spot-runner-us-east-1c.outcome == 'failure' + id: start-ec2-spot-runner-us-east-1d + uses: machulav/ec2-github-runner@28fbe1c4d7d9ba74134ca5ebc559d5b0a989a856 # v2.3.8 + continue-on-error: true + with: + mode: start + market-type: spot # Define spot instance here + github-token: ${{ inputs.github_token }} + ec2-image-id: ${{ steps.load-configs.outputs.US_EAST_1D_AWS_EC2_AMI }} + ec2-instance-type: ${{ inputs.ec2_instance_type }} + subnet-id: ${{ steps.load-configs.outputs.US_EAST_1D_SUBNET_ID }} + security-group-id: ${{ steps.load-configs.outputs.US_EAST_1D_SECURITY_GROUP_ID }} + iam-role-name: instructlab-ci-runner + aws-resource-tags: ${{ inputs.aws_resource_tags }} + + # 8.) Try us-east-1e next + - name: "[ SPOT INSTANCE ] Attempt to start EC2 runner as a SPOT instance on us-east-1e (if no availability still)" + if: steps.start-ec2-spot-runner-us-east-1d.outcome == 'failure' + id: start-ec2-spot-runner-us-east-1e + uses: machulav/ec2-github-runner@28fbe1c4d7d9ba74134ca5ebc559d5b0a989a856 # v2.3.8 + continue-on-error: true + with: + mode: start + market-type: spot # Define spot instance here + github-token: ${{ inputs.github_token }} + ec2-image-id: ${{ steps.load-configs.outputs.US_EAST_1E_AWS_EC2_AMI }} + ec2-instance-type: ${{ inputs.ec2_instance_type }} + subnet-id: ${{ steps.load-configs.outputs.US_EAST_1E_SUBNET_ID }} + security-group-id: ${{ steps.load-configs.outputs.US_EAST_1E_SECURITY_GROUP_ID }} + iam-role-name: instructlab-ci-runner + aws-resource-tags: ${{ inputs.aws_resource_tags }} + + # 9.) Try us-east-1f next + - name: "[ SPOT INSTANCE ] Attempt to start EC2 runner as a SPOT instance on us-east-1e (if no availability still)" + if: steps.start-ec2-spot-runner-us-east-1e.outcome == 'failure' + id: start-ec2-spot-runner-us-east-1f + uses: machulav/ec2-github-runner@28fbe1c4d7d9ba74134ca5ebc559d5b0a989a856 # v2.3.8 + continue-on-error: true + with: + mode: start + market-type: spot # Define spot instance here + github-token: ${{ inputs.github_token }} + ec2-image-id: ${{ steps.load-configs.outputs.US_EAST_1F_AWS_EC2_AMI }} + ec2-instance-type: ${{ inputs.ec2_instance_type }} + subnet-id: ${{ steps.load-configs.outputs.US_EAST_1F_SUBNET_ID }} + security-group-id: ${{ steps.load-configs.outputs.US_EAST_1F_SECURITY_GROUP_ID }} + iam-role-name: instructlab-ci-runner + aws-resource-tags: ${{ inputs.aws_resource_tags }} + + ######################################### + # Dedicated Instance Launching Attempts # + ######################################### + # If the spot instance attempts failed OR a dedicated instance is preferred... + + # 1.) Try us-east-2a first + - name: "[ DEDICATED INSTANCE ] Configure AWS credentials for us-east-2" + if: steps.start-ec2-spot-runner-us-east-1f.outcome == 'failure' || ( inputs.try_spot_instance_first != 'true' && inputs.try_spot_instance_first != 'True' ) + uses: aws-actions/configure-aws-credentials@ececac1a45f3b08a01d2dd070d28d111c5fe6722 # v4.1.0 + with: + aws-access-key-id: ${{ inputs.aws_access_key_id }} + aws-secret-access-key: ${{ inputs.aws_secret_access_key }} + aws-region: "us-east-2" + + - name: "[ DEDICATED INSTANCE ] Attempt to start EC2 runner as a DEDICATED instance on us-east-2a" + if: steps.start-ec2-spot-runner-us-east-1f.outcome == 'failure' || ( inputs.try_spot_instance_first != 'true' && inputs.try_spot_instance_first != 'True' ) + id: start-ec2-dedicated-runner-us-east-2a + uses: machulav/ec2-github-runner@28fbe1c4d7d9ba74134ca5ebc559d5b0a989a856 # v2.3.8 + continue-on-error: true + with: + # NOTE: 'market-type' purposefully is not set! + mode: start + github-token: ${{ inputs.github_token }} + ec2-image-id: ${{ steps.load-configs.outputs.US_EAST_2A_AWS_EC2_AMI }} + ec2-instance-type: ${{ inputs.ec2_instance_type }} + subnet-id: ${{ steps.load-configs.outputs.US_EAST_2A_SUBNET_ID }} + security-group-id: ${{ steps.load-configs.outputs.US_EAST_2A_SECURITY_GROUP_ID }} + iam-role-name: instructlab-ci-runner + aws-resource-tags: ${{ inputs.aws_resource_tags }} + + # 2.) Try us-east-2b next + - name: "[ DEDICATED INSTANCE ] Attempt to start EC2 runner as a DEDICATED instance on us-east-2b" + if: steps.start-ec2-dedicated-runner-us-east-2a.outcome == 'failure' + id: start-ec2-dedicated-runner-us-east-2b + uses: machulav/ec2-github-runner@28fbe1c4d7d9ba74134ca5ebc559d5b0a989a856 # v2.3.8 + continue-on-error: true + with: + # NOTE: 'market-type' purposefully is not set! + mode: start + github-token: ${{ inputs.github_token }} + ec2-image-id: ${{ steps.load-configs.outputs.US_EAST_2B_AWS_EC2_AMI }} + ec2-instance-type: ${{ inputs.ec2_instance_type }} + subnet-id: ${{ steps.load-configs.outputs.US_EAST_2B_SUBNET_ID }} + security-group-id: ${{ steps.load-configs.outputs.US_EAST_2B_SECURITY_GROUP_ID }} + iam-role-name: instructlab-ci-runner + aws-resource-tags: ${{ inputs.aws_resource_tags }} + + # 3.) Try us-east-2c next + - name: "[ DEDICATED INSTANCE ] Attempt to start EC2 runner as a DEDICATED instance on us-east-2c" + if: steps.start-ec2-dedicated-runner-us-east-2b.outcome == 'failure' + id: start-ec2-dedicated-runner-us-east-2c + uses: machulav/ec2-github-runner@28fbe1c4d7d9ba74134ca5ebc559d5b0a989a856 # v2.3.8 + continue-on-error: true + with: + # NOTE: 'market-type' purposefully is not set! + mode: start + github-token: ${{ inputs.github_token }} + ec2-image-id: ${{ steps.load-configs.outputs.US_EAST_2C_AWS_EC2_AMI }} + ec2-instance-type: ${{ inputs.ec2_instance_type }} + subnet-id: ${{ steps.load-configs.outputs.US_EAST_2C_SUBNET_ID }} + security-group-id: ${{ steps.load-configs.outputs.US_EAST_2C_SECURITY_GROUP_ID }} + iam-role-name: instructlab-ci-runner + aws-resource-tags: ${{ inputs.aws_resource_tags }} + + # 4.) Try us-east-1a next + - name: "[ SPOT INSTANCE ] Configure AWS credentials for us-east-1" + if: steps.start-ec2-dedicated-runner-us-east-2c.outcome == 'failure' + uses: aws-actions/configure-aws-credentials@ececac1a45f3b08a01d2dd070d28d111c5fe6722 # v4.1.0 + with: + aws-access-key-id: ${{ inputs.aws_access_key_id }} + aws-secret-access-key: ${{ inputs.aws_secret_access_key }} + aws-region: "us-east-1" + + - name: "[ DEDICATED INSTANCE ] Attempt to start EC2 runner as a DEDICATED instance on us-east-1a" + if: steps.start-ec2-dedicated-runner-us-east-2c.outcome == 'failure' + id: start-ec2-dedicated-runner-us-east-1a + uses: machulav/ec2-github-runner@28fbe1c4d7d9ba74134ca5ebc559d5b0a989a856 # v2.3.8 + continue-on-error: true + with: + # NOTE: 'market-type' purposefully is not set! + mode: start + github-token: ${{ inputs.github_token }} + ec2-image-id: ${{ steps.load-configs.outputs.US_EAST_1A_AWS_EC2_AMI }} + ec2-instance-type: ${{ inputs.ec2_instance_type }} + subnet-id: ${{ steps.load-configs.outputs.US_EAST_1A_SUBNET_ID }} + security-group-id: ${{ steps.load-configs.outputs.US_EAST_1A_SECURITY_GROUP_ID }} + iam-role-name: instructlab-ci-runner + aws-resource-tags: ${{ inputs.aws_resource_tags }} + + # 5.) Try us-east-1b next + - name: "[ DEDICATED INSTANCE ] Attempt to start EC2 runner as a DEDICATED instance on us-east-1b (if no availability still)" + if: steps.start-ec2-dedicated-runner-us-east-1a.outcome == 'failure' + id: start-ec2-dedicated-runner-us-east-1b + uses: machulav/ec2-github-runner@28fbe1c4d7d9ba74134ca5ebc559d5b0a989a856 # v2.3.8 + continue-on-error: true + with: + # NOTE: 'market-type' purposefully is not set! + mode: start + github-token: ${{ inputs.github_token }} + ec2-image-id: ${{ steps.load-configs.outputs.US_EAST_1B_AWS_EC2_AMI }} + ec2-instance-type: ${{ inputs.ec2_instance_type }} + subnet-id: ${{ steps.load-configs.outputs.US_EAST_1B_SUBNET_ID }} + security-group-id: ${{ steps.load-configs.outputs.US_EAST_1B_SECURITY_GROUP_ID }} + iam-role-name: instructlab-ci-runner + aws-resource-tags: ${{ inputs.aws_resource_tags }} + + # 6.) Try us-east-1c next + - name: "[ DEDICATED INSTANCE ] Attempt to start EC2 runner as a DEDICATED instance on us-east-1c (if no availability still)" + if: steps.start-ec2-dedicated-runner-us-east-1b.outcome == 'failure' + id: start-ec2-dedicated-runner-us-east-1c + uses: machulav/ec2-github-runner@28fbe1c4d7d9ba74134ca5ebc559d5b0a989a856 # v2.3.8 + continue-on-error: true + with: + # NOTE: 'market-type' purposefully is not set! + mode: start + github-token: ${{ inputs.github_token }} + ec2-image-id: ${{ steps.load-configs.outputs.US_EAST_1C_AWS_EC2_AMI }} + ec2-instance-type: ${{ inputs.ec2_instance_type }} + subnet-id: ${{ steps.load-configs.outputs.US_EAST_1C_SUBNET_ID }} + security-group-id: ${{ steps.load-configs.outputs.US_EAST_1C_SECURITY_GROUP_ID }} + iam-role-name: instructlab-ci-runner + aws-resource-tags: ${{ inputs.aws_resource_tags }} + + # 7.) Try us-east-1d next + - name: "[ DEDICATED INSTANCE ] Attempt to start EC2 runner as a DEDICATED instance on us-east-1d (if no availability still)" + if: steps.start-ec2-dedicated-runner-us-east-1c.outcome == 'failure' + id: start-ec2-dedicated-runner-us-east-1d + uses: machulav/ec2-github-runner@28fbe1c4d7d9ba74134ca5ebc559d5b0a989a856 # v2.3.8 + continue-on-error: true + with: + # NOTE: 'market-type' purposefully is not set! + mode: start + github-token: ${{ inputs.github_token }} + ec2-image-id: ${{ steps.load-configs.outputs.US_EAST_1D_AWS_EC2_AMI }} + ec2-instance-type: ${{ inputs.ec2_instance_type }} + subnet-id: ${{ steps.load-configs.outputs.US_EAST_1D_SUBNET_ID }} + security-group-id: ${{ steps.load-configs.outputs.US_EAST_1D_SECURITY_GROUP_ID }} + iam-role-name: instructlab-ci-runner + aws-resource-tags: ${{ inputs.aws_resource_tags }} + + # 8.) Try us-east-1e next + - name: "[ DEDICATED INSTANCE ] Attempt to start EC2 runner as a DEDICATED instance on us-east-1e (if no availability still)" + if: steps.start-ec2-dedicated-runner-us-east-1d.outcome == 'failure' + id: start-ec2-dedicated-runner-us-east-1e + uses: machulav/ec2-github-runner@28fbe1c4d7d9ba74134ca5ebc559d5b0a989a856 # v2.3.8 + continue-on-error: true + with: + # NOTE: 'market-type' purposefully is not set! + mode: start + github-token: ${{ inputs.github_token }} + ec2-image-id: ${{ steps.load-configs.outputs.US_EAST_1E_AWS_EC2_AMI }} + ec2-instance-type: ${{ inputs.ec2_instance_type }} + subnet-id: ${{ steps.load-configs.outputs.US_EAST_1E_SUBNET_ID }} + security-group-id: ${{ steps.load-configs.outputs.US_EAST_1E_SECURITY_GROUP_ID }} + iam-role-name: instructlab-ci-runner + aws-resource-tags: ${{ inputs.aws_resource_tags }} + + # 9.) Try us-east-1f next + - name: "[ DEDICATED INSTANCE ] Attempt to start EC2 runner as a DEDICATED instance on us-east-1e (if no availability still)" + if: steps.start-ec2-dedicated-runner-us-east-1e.outcome == 'failure' + id: start-ec2-dedicated-runner-us-east-1f + uses: machulav/ec2-github-runner@28fbe1c4d7d9ba74134ca5ebc559d5b0a989a856 # v2.3.8 + continue-on-error: true + with: + # NOTE: 'market-type' purposefully is not set! + mode: start + github-token: ${{ inputs.github_token }} + ec2-image-id: ${{ steps.load-configs.outputs.US_EAST_1F_AWS_EC2_AMI }} + ec2-instance-type: ${{ inputs.ec2_instance_type }} + subnet-id: ${{ steps.load-configs.outputs.US_EAST_1F_SUBNET_ID }} + security-group-id: ${{ steps.load-configs.outputs.US_EAST_1F_SECURITY_GROUP_ID }} + iam-role-name: instructlab-ci-runner + aws-resource-tags: ${{ inputs.aws_resource_tags }} + + # Output the EC2 runner label and instance ID to the 'selected-availability-zone' ID output for reference later. + - name: Determine EC2 availability zone used + id: selected-availability-zone + shell: bash + run: | + # shellcheck disable=SC2086 # False positive error from shellcheck about word splitting/gobbling + + # Clear $GITHUB_OUTPUT in case of code failure + echo "SUBNET_ID=''" >> "$GITHUB_OUTPUT" + echo "SECURITY_GROUP_ID=''" >> "$GITHUB_OUTPUT" + echo "AWS_EC2_AMI=''" >> "$GITHUB_OUTPUT" + + ######## SPOT RUNNERS ######### + if [ "${{ steps.start-ec2-spot-runner-us-east-2a.outcome }}" = "success" ]; then + echo "label=${{ steps.start-ec2-spot-runner-us-east-2a.outputs.label }}" >> $GITHUB_OUTPUT + echo "ec2-instance-id=${{ steps.start-ec2-spot-runner-us-east-2a.outputs.ec2-instance-id }}" >> $GITHUB_OUTPUT + echo "ec2-instance-region=us-east-2" >> $GITHUB_OUTPUT + elif [ "${{ steps.start-ec2-spot-runner-us-east-2b.outcome }}" = "success" ]; then + echo "label=${{ steps.start-ec2-spot-runner-us-east-2b.outputs.label }}" >> $GITHUB_OUTPUT + echo "ec2-instance-id=${{ steps.start-ec2-spot-runner-us-east-2b.outputs.ec2-instance-id }}" >> $GITHUB_OUTPUT + echo "ec2-instance-region=us-east-2" >> $GITHUB_OUTPUT + elif [ "${{ steps.start-ec2-spot-runner-us-east-2c.outcome }}" = "success" ]; then + echo "label=${{ steps.start-ec2-spot-runner-us-east-2c.outputs.label }}" >> $GITHUB_OUTPUT + echo "ec2-instance-id=${{ steps.start-ec2-spot-runner-us-east-2c.outputs.ec2-instance-id }}" >> $GITHUB_OUTPUT + echo "ec2-instance-region=us-east-2" >> $GITHUB_OUTPUT + elif [ "${{ steps.start-ec2-spot-runner-us-east-1a.outcome }}" = "success" ]; then + echo "label=${{ steps.start-ec2-spot-runner-us-east-1a.outputs.label }}" >> $GITHUB_OUTPUT + echo "ec2-instance-id=${{ steps.start-ec2-spot-runner-us-east-1a.outputs.ec2-instance-id }}" >> $GITHUB_OUTPUT + echo "ec2-instance-region=us-east-1" >> $GITHUB_OUTPUT + elif [ "${{ steps.start-ec2-spot-runner-us-east-1b.outcome }}" = "success" ]; then + echo "label=${{ steps.start-ec2-spot-runner-us-east-1b.outputs.label }}" >> $GITHUB_OUTPUT + echo "ec2-instance-id=${{ steps.start-ec2-spot-runner-us-east-1b.outputs.ec2-instance-id }}" >> $GITHUB_OUTPUT + echo "ec2-instance-region=us-east-1" >> $GITHUB_OUTPUT + elif [ "${{ steps.start-ec2-spot-runner-us-east-1c.outcome }}" = "success" ]; then + echo "label=${{ steps.start-ec2-spot-runner-us-east-1c.outputs.label }}" >> $GITHUB_OUTPUT + echo "ec2-instance-id=${{ steps.start-ec2-spot-runner-us-east-1c.outputs.ec2-instance-id }}" >> $GITHUB_OUTPUT + echo "ec2-instance-region=us-east-1" >> $GITHUB_OUTPUT + elif [ "${{ steps.start-ec2-spot-runner-us-east-1d.outcome }}" = "success" ]; then + echo "label=${{ steps.start-ec2-spot-runner-us-east-1d.outputs.label }}" >> $GITHUB_OUTPUT + echo "ec2-instance-id=${{ steps.start-ec2-spot-runner-us-east-1d.outputs.ec2-instance-id }}" >> $GITHUB_OUTPUT + echo "ec2-instance-region=us-east-1" >> $GITHUB_OUTPUT + elif [ "${{ steps.start-ec2-spot-runner-us-east-1e.outcome }}" = "success" ]; then + echo "label=${{ steps.start-ec2-spot-runner-us-east-1e.outputs.label }}" >> $GITHUB_OUTPUT + echo "ec2-instance-id=${{ steps.start-ec2-spot-runner-us-east-1e.outputs.ec2-instance-id }}" >> $GITHUB_OUTPUT + echo "ec2-instance-region=us-east-1" >> $GITHUB_OUTPUT + elif [ "${{ steps.start-ec2-spot-runner-us-east-1f.outcome }}" = "success" ]; then + echo "label=${{ steps.start-ec2-spot-runner-us-east-1f.outputs.label }}" >> $GITHUB_OUTPUT + echo "ec2-instance-id=${{ steps.start-ec2-spot-runner-us-east-1f.outputs.ec2-instance-id }}" >> $GITHUB_OUTPUT + echo "ec2-instance-region=us-east-1" >> $GITHUB_OUTPUT + ######## DEDICATED RUNNERS ######### + elif [ "${{ steps.start-ec2-dedicated-runner-us-east-2a.outcome }}" = "success" ]; then + echo "label=${{ steps.start-ec2-dedicated-runner-us-east-2a.outputs.label }}" >> $GITHUB_OUTPUT + echo "ec2-instance-id=${{ steps.start-ec2-dedicated-runner-us-east-2a.outputs.ec2-instance-id }}" >> $GITHUB_OUTPUT + echo "ec2-instance-region=us-east-2" >> $GITHUB_OUTPUT + elif [ "${{ steps.start-ec2-dedicated-runner-us-east-2b.outcome }}" = "success" ]; then + echo "label=${{ steps.start-ec2-dedicated-runner-us-east-2b.outputs.label }}" >> $GITHUB_OUTPUT + echo "ec2-instance-id=${{ steps.start-ec2-dedicated-runner-us-east-2b.outputs.ec2-instance-id }}" >> $GITHUB_OUTPUT + echo "ec2-instance-region=us-east-2" >> $GITHUB_OUTPUT + elif [ "${{ steps.start-ec2-dedicated-runner-us-east-2c.outcome }}" = "success" ]; then + echo "label=${{ steps.start-ec2-dedicated-runner-us-east-2c.outputs.label }}" >> $GITHUB_OUTPUT + echo "ec2-instance-id=${{ steps.start-ec2-dedicated-runner-us-east-2c.outputs.ec2-instance-id }}" >> $GITHUB_OUTPUT + echo "ec2-instance-region=us-east-2" >> $GITHUB_OUTPUT + elif [ "${{ steps.start-ec2-dedicated-runner-us-east-1a.outcome }}" = "success" ]; then + echo "label=${{ steps.start-ec2-dedicated-runner-us-east-1a.outputs.label }}" >> $GITHUB_OUTPUT + echo "ec2-instance-id=${{ steps.start-ec2-dedicated-runner-us-east-1a.outputs.ec2-instance-id }}" >> $GITHUB_OUTPUT + echo "ec2-instance-region=us-east-1" >> $GITHUB_OUTPUT + elif [ "${{ steps.start-ec2-dedicated-runner-us-east-1b.outcome }}" = "success" ]; then + echo "label=${{ steps.start-ec2-dedicated-runner-us-east-1b.outputs.label }}" >> $GITHUB_OUTPUT + echo "ec2-instance-id=${{ steps.start-ec2-dedicated-runner-us-east-1b.outputs.ec2-instance-id }}" >> $GITHUB_OUTPUT + echo "ec2-instance-region=us-east-1" >> $GITHUB_OUTPUT + elif [ "${{ steps.start-ec2-dedicated-runner-us-east-1c.outcome }}" = "success" ]; then + echo "label=${{ steps.start-ec2-dedicated-runner-us-east-1c.outputs.label }}" >> $GITHUB_OUTPUT + echo "ec2-instance-id=${{ steps.start-ec2-dedicated-runner-us-east-1c.outputs.ec2-instance-id }}" >> $GITHUB_OUTPUT + echo "ec2-instance-region=us-east-1" >> $GITHUB_OUTPUT + elif [ "${{ steps.start-ec2-dedicated-runner-us-east-1d.outcome }}" = "success" ]; then + echo "label=${{ steps.start-ec2-dedicated-runner-us-east-1d.outputs.label }}" >> $GITHUB_OUTPUT + echo "ec2-instance-id=${{ steps.start-ec2-dedicated-runner-us-east-1d.outputs.ec2-instance-id }}" >> $GITHUB_OUTPUT + echo "ec2-instance-region=us-east-1" >> $GITHUB_OUTPUT + elif [ "${{ steps.start-ec2-dedicated-runner-us-east-1e.outcome }}" = "success" ]; then + echo "label=${{ steps.start-ec2-dedicated-runner-us-east-1e.outputs.label }}" >> $GITHUB_OUTPUT + echo "ec2-instance-id=${{ steps.start-ec2-dedicated-runner-us-east-1e.outputs.ec2-instance-id }}" >> $GITHUB_OUTPUT + echo "ec2-instance-region=us-east-1" >> $GITHUB_OUTPUT + elif [ "${{ steps.start-ec2-dedicated-runner-us-east-1f.outcome }}" = "success" ]; then + echo "label=${{ steps.start-ec2-dedicated-runner-us-east-1f.outputs.label }}" >> $GITHUB_OUTPUT + echo "ec2-instance-id=${{ steps.start-ec2-dedicated-runner-us-east-1f.outputs.ec2-instance-id }}" >> $GITHUB_OUTPUT + echo "ec2-instance-region=us-east-1" >> $GITHUB_OUTPUT + fi \ No newline at end of file diff --git a/actions/launch-ec2-runner-with-fallback/launch-ec2-runner-with-fallback.md b/actions/launch-ec2-runner-with-fallback/launch-ec2-runner-with-fallback.md new file mode 100644 index 0000000..c2059a1 --- /dev/null +++ b/actions/launch-ec2-runner-with-fallback/launch-ec2-runner-with-fallback.md @@ -0,0 +1,258 @@ +# Launch EC2 Runner with Fallback + +## Overview + +This action is used to launch an EC2 instance (EC2 runner) in AWS -- either as a spot instance or a dedicated instance. It essentially leverages the [machulav/ec2-github-runner](https://github.com/machulav/ec2-github-runner) GitHub action under the hood to launch EC2 instance in AWS, but implements "fallback logic" if one or more attempts to launch an EC2 instance fail. + +Any CI that leverages this GitHub action will be required to shutdown the instance launched through this action. You can find a complete working example (launching and terminating) under the [Example Usage](#example-usage) section. + +## When to Use it? + +### Insufficient Capacity: AWS Lacks Availablility for your Desired EC2 Instance Type in a Given Availability Zone + +If one of your CI workflows attempts to launch an EC2 instance in AWS but fails due to an `InsufficientInstanceCapacity` error (aka AWS doesn't have any instances available for that instance type), you can leverage this action to try other regions as a backup. + +### Cost Savings: You want to Try Launching Your EC2 Runner as a Spot Instance First + +Spot instances are generally cheaper and can be sufficient for certain sitautions. Just be mindful of the implications of a spot instance. The official AWS documentation emphasizes that spot instances ["can be interrupted by Amazon EC2 when EC2 needs the capacity back."](https://docs.aws.amazon.com/whitepapers/latest/cost-optimization-leveraging-ec2-spot-instances/how-spot-instances-work.html) Thus, if your instance type is "very popular" amongst other AWS users and you can't afford to have interruptions on your EC2 instances, you should actively avoid launching your EC2 instances as spot instances. + +## Which AWS Regions and Availability Zones are Supported? + +The pricing for EC2 instances depends on the region, with some regions charging more money for the same instance type compared to other regions. Given that information, the currently "supported" AWS regions and availability zones for launching an EC2 instance are: + +* US East 1 (Virginia) - `us-east-1` + * `us-east-1a` + * `us-east-1b` + * `us-east-1c` + * `us-east-1d` + * `us-east-1e` + * `us-east-1f` +* US East 2 (Ohio) - `us-east-2` + * `us-east-2a` + * `us-east-2b` + * `us-east-2c` + +Please be aware that although an AWS region may support your requested instance type, some availability zones within that region may not. For example, you may see a message along the lines of: + +```bash +Unsupported: Your requested instance type (g6e.48xlarge) is not supported in your requested Availability Zone (us-east-1e). Please retry your request by not specifying an Availability Zone or choosing us-east-1a, us-east-1b, us-east-1c, us-east-1d. +``` + +Nonetheless, encountering this error message when using the `launch-ec2-runner-with-fallback` action will **not** fail your GitHub workflow. Rather, the error will be logged to the console but ignored, and other availability zones and regions will be tried. Therefore, you can safely use this action with any instance type, even if that instance type is not supported in every region/availability zone you want to try. (It's probably good to try all availability zones anyway in case AWS unexpectedly decides to add support for your instance type in a region it formerly did not support.) Every time you attempt to launch an instance in an unsupported availability zone, the warning message will appear within a matter of 1-3 seconds, so the error is almost instantaneous. + +## How to Call this Action from a Job within a GitHub Workflow + +Consider a simple job definition within a GitHub workflow file that is used to launch an EC2 instance in AWS. You would first call the GitHub `actions/checkout` action to "checkout" this action and store it locally. A list of supported inputs is provided in the next subsection, followed by an example in the following subsection. + +### Supported Inputs + +#### Required inputs: + +| Name | Description | Example Value | +| --- | --- | --- | +| `aws_access_key_id` | AWS access key ID that will be used to launch your desired instance. | `AKIAIOSFODNN7EXAMPLE` | +| `aws_resource_tags` | Resource tags to apply to the desired AWS instance, upon successful launch. | `[{"Key": "Name", "Value": "my-runner"}]`| +| `aws_secret_access_key` | AWS secret access key that will be used to launch your desired instance. | `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY` | +| `ec2_instance_type` | The desired AWS instance type to use, regardless of region. (Note that some instance types are not available in certain regions.) | `g4dn.2xlarge` | +| `github_token` | GitHub Personal Access Token with the `repo` scope assigned. | `ghp_xxxxxxxxx` | +| `regions_config` | A JSON string that defines which regions and subnets to try, along with the AMIs and security groups to use within those regions. | See example in next sub-section | + +#### Optional inputs: + +| Name | Description | Example value | +| --- | --- | --- | +| `try_spot_instance_first` | If set to "true", then the EC2 instance will be launched as a spot instance rather than a dedicated EC2 instance. If a spot instance cannot be launched in any of the desired availability zones (e.g., due to insufficient capacity on AWS), then a dedicated instance will be tried next. (Note: This option is not always desirable for certain instance types.) | `true` | + +#### `regions-config` Formatting + +This input must be a valid JSON string. It is essentially a list of configuration items, where each configuration item corresponds to the configuration for launching an EC2 instance a specific AWS region. The required fields are: + +| Name | Description | Example Value | +| --- | --- | --- | +| `region` | the AWS region code. (You can view [the official "Available AWS Regions" table](https://docs.aws.amazon.com/global-infrastructure/latest/regions/aws-regions.html#available-regions) to see the supported, available codes.) | `us-east-2` | +| `subnets` | a map which tells the GitHub action | - | +| `ec2_ami` | the AMI ID to use in that specific region. (Note that AMI IDs are unique to each region.) | `ami-0123456789` | +| `security_group_id` | security group ID to use when launching the EC2 instance. (Note that security group IDs are unique to each region.) | `sg-02ce123456e7893c7` | + +
+ +Example individual configuration item for launching an EC2 instance in `us-east-1` + +```json +{ + "region": "us-east-1", + "subnets": { + "us-east-1a": "${{ secrets.SUBNET_US_EAST_1A }}", + "us-east-1b": "${{ secrets.SUBNET_US_EAST_1B }}", + "us-east-1c": "${{ secrets.SUBNET_US_EAST_1C }}", + }, + "ec2-ami": "${{ secrets.EC2_AMI_US_EAST_1 }}", + "security-group-id": "${{ secrets.SECURITY_GROUP_ID_US_EAST_1 }}" +} +``` +
+ +Whether you have one or more region configurations, you will need to place them in a list. For example, if you only want to launch in `us-east-1`, your `regions-config` input would be formatted like so: + + +
+ +Example format for `regions-config` for only 1 region + +```json +[ + { + "region": "us-east-1", + "subnets": { + "us-east-1a": "${{ secrets.SUBNET_US_EAST_1A }}", + "us-east-1b": "${{ secrets.SUBNET_US_EAST_1B }}", + "us-east-1c": "${{ secrets.SUBNET_US_EAST_1C }}", + }, + "ec2-ami": "${{ secrets.EC2_AMI_US_EAST_1 }}", + "security-group-id": "${{ secrets.SECURITY_GROUP_ID_US_EAST_1 }}" + } +] +``` + +
+ +
+ +Example format for `regions-config` for multiple regions + +```json +[ + { + "region": "us-east-1", + "subnets": { + "us-east-1a": "${{ secrets.SUBNET_US_EAST_1A }}", + "us-east-1b": "${{ secrets.SUBNET_US_EAST_1B }}", + "us-east-1c": "${{ secrets.SUBNET_US_EAST_1C }}", + }, + "ec2-ami": "${{ secrets.EC2_AMI_US_EAST_1 }}", + "security-group-id": "${{ secrets.SECURITY_GROUP_ID_US_EAST_1 }}" + }, + { + "region": "us-east-2", + "subnets": { + "us-east-2a": "${{ secrets.SUBNET_US_EAST_2A }}", + "us-east-2b": "${{ secrets.SUBNET_US_EAST_2B }}", + "us-east-2c": "${{ secrets.SUBNET_US_EAST_2C }}", + "us-east-2d": "${{ secrets.SUBNET_US_EAST_2D }}", + "us-east-2e": "${{ secrets.SUBNET_US_EAST_2E }}", + }, + "ec2-ami": "${{ secrets.EC2_AMI_US_EAST_2 }}", + "security-group-id": "${{ secrets.SECURITY_GROUP_ID_US_EAST_2 }}" + } +] +``` + +
+ +### Example Usage + +```yaml +jobs: + + # This action only *starts* an EC2 runner. It does not stop it. See other job definition in this example for stopping your EC2 instance. + start-ec2-runner: + runs-on: ubuntu-latest + + # These outputs let you know which AWS availability zone (and therefore region) the instance was launched under, as + # well as the instance ID so that you can later shut down the instance by referencing its ID alongside its region + outputs: + label: ${{ steps.launch-ec2-instance-with-fallback.outputs.label }} + ec2-instance-id: ${{ steps.launch-ec2-instance-with-fallback.outputs.ec2-instance-id }} + ec2-instance-region: ${{ steps.launch-ec2-instance-with-fallback.outputs.ec2-instance-region }} + steps: + + - name: Checkout "launch-ec2-runner-with-fallback" in-house CI action + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + # The action is stored in this repository, so we need to tell GitHub to pull from: {org}/{repo} + repository: instructlab/ci-actions + # clone the "ci-actions" repo to a local directory called "ci-actions", instead of overwriting the current WORKDIR contents + path: ci-actions + # Only checkout the relevant GitHub action + sparse-checkout: | + actions/launch-ec2-runner-with-fallback + + - name: Launch EC2 Runner with Fallback + # Make sure to provide the relative path to `launch-ec2-runner-with-fallback` + uses: ./ci-actions/actions/launch-ec2-runner-with-fallback + with: + # (OPTIONAL) Cost-savings inputs + try_spot_instance_first: "true" + # (REQUIRED) Authentication inputs + aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} + # (REQUIRED) Common EC2 instance configuration inputs + ec2_instance_type: g4dn.2xlarge + aws_resource_tags: > + [ + {"Key": "Name", "Value": "instructlab-ci-github-xlarge-runner"}, + {"Key": "GitHubRepository", "Value": "${{ github.repository }}"}, + {"Key": "GitHubRef", "Value": "${{ github.ref }}"}, + {"Key": "GitHubPR", "Value": "${{ github.event.number }}"} + ] + # (REQUIRED) AWS regions to try launching your EC2 instance in. (If desired, you can + # omit one of the supported regions below. You can also omit specific + # subnets within those regions.) + regions_config: > + [ + { + "region": "us-east-1", + "subnets": { + "us-east-1a": "${{ secrets.SUBNET_US_EAST_1A }}", + "us-east-1b": "${{ secrets.SUBNET_US_EAST_1B }}", + "us-east-1c": "${{ secrets.SUBNET_US_EAST_1C }}", + }, + "ec2-ami": "${{ secrets.EC2_AMI_US_EAST_1 }}", + "security-group-id": "${{ secrets.SECURITY_GROUP_ID_US_EAST_1 }}" + }, + { + "region": "us-east-2", + "subnets": { + "us-east-2a": "${{ secrets.SUBNET_US_EAST_2A }}", + "us-east-2b": "${{ secrets.SUBNET_US_EAST_2B }}", + "us-east-2c": "${{ secrets.SUBNET_US_EAST_2C }}", + "us-east-2d": "${{ secrets.SUBNET_US_EAST_2D }}", + "us-east-2e": "${{ secrets.SUBNET_US_EAST_2E }}", + }, + "ec2-ami": "${{ secrets.EC2_AMI_US_EAST_2 }}", + "security-group-id": "${{ secrets.SECURITY_GROUP_ID_US_EAST_2 }}" + } + ] + + stop-large-ec2-runner: + # Only attempt a shutdown if the instance started + needs: + - start-ec2-runner + runs-on: ubuntu-latest + if: ${{ always() }} + steps: + + # In order to shut down the instance, we need to log into AWS under the region the instance was + # launched. To determine the region, we can reference the output from the `start-ec2-runner` job + # above. Specifically, the `launch-ec2-runner-with-fallback` will return which region the instance + # was launched under so that no post-procesing steps need to be made in your workflow. + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@ececac1a45f3b08a01d2dd070d28d111c5fe6722 # v4.1.0 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ needs.start-ec2-runner.outputs.ec2-instance-region }} + + # Similar to above, we can reference the output from the `start-ec2-runner` job to determine which + # EC2 instance to shut down. + - name: Stop EC2 runner + uses: machulav/ec2-github-runner@fcfb31a5760dad1314a64a0e172b78ec6fc8a17e # v2.3.6 + with: + mode: stop + github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} + label: ${{ needs.start-ec2-runner.outputs.label }} + ec2-instance-id: ${{ needs.start-ec2-runner.outputs.ec2-instance-id }} + +``` + diff --git a/actions/launch-ec2-runner-with-fallback/scripts/get_config_value.sh b/actions/launch-ec2-runner-with-fallback/scripts/get_config_value.sh new file mode 100755 index 0000000..be024f6 --- /dev/null +++ b/actions/launch-ec2-runner-with-fallback/scripts/get_config_value.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2002 # Disables false positives on the 'cat' statements + +set -eo pipefail + +GET_SECURITY_GROUP_ID=0 +GET_SUBNET=0 +GET_AMI_ID=0 + +LOG_LEVEL="INFO" + +usage() { + echo "Usage: $0 [-z] [-c] [-v] [-l] [-h]" + echo " [ REQUIRED FLAGS ]" + echo " Each one of the base options requires the following flags. These flags require input values." + echo " -z Availability zone" + echo " -c Path to the regions configuration JSON file" + echo " -v Value (field) to retrieve from the regions configuration JSON file. (See below for supported fields.)" + echo "" + echo " [ SUPPORTED REGIONS CONFIGURATION FIELDS ]" + echo " Choose ONE of fields when using the -v flag:" + echo " security-group-id Grabs the security group ID for the specified availability zone" + echo " ec2-ami Grabs the EC2 AMI ID for the specified availability zone" + echo " subnet Grabs the subnet associated with the specified availability zone" + echo "" + echo " [ OPTIONAL FLAGS ]" + echo " -l Log level. Set to one of: { INFO , ERROR }. If set to 'ERROR', then '[INFO]' logs will be ignored." + echo "" + echo " [ OTHER ]" + echo " -h Show this help text" +} + +log_info() { + # LOG_LEVEL is intended to disable info logging if desired. Good for unit tests. + if [[ "$LOG_LEVEL" == "INFO" ]]; then + echo "[INFO] ${1}" + fi +} + +log_error() { + echo "[ERROR] ${1}" +} + +is_availability_zone_supported() { + local supported_availability_zones=( + "us-east-1a" + "us-east-1b" + "us-east-1c" + "us-east-1d" + "us-east-1e" + "us-east-1f" + "us-east-2a" + "us-east-2b" + "us-east-2c" + ) + + user_selection=$1 + + local found=0 + for az in "${supported_availability_zones[@]}"; do + if [[ $az == "$user_selection" ]]; then + found=1 + break + fi + done + + if (( found == 0 )); then + log_error "Availability zone '$user_selection' is currently not supported by this GitHub action. If you would like this availability zone supported, please file an issue under: https://github.com/instructlab/ci-actions/issues" + exit 1 + fi +} + +validate_regions_config_json() { + # This will throw an error if the JSON is not valid. + log_info "Validating 'regions_config' input before proceeding..." + python -mjson.tool "$REGIONS_CONFIG_FILE" > /dev/null +} + +get_config_value() { + # Basic error checking to ensure the two required inputs were passed in + if [ -z "${AVAILABILITY_ZONE}" ]; then + log_error "This internal script requires an availability zone, but one has not been provided. Please provide one with the -az flag." + usage + exit 1 + elif [ -z "${REGIONS_CONFIG_FILE}" ]; then + log_error "This internal script requires a regions configuration file, but one has not been provided. Please provide one with the -c flag." + usage + exit 1 + fi + + # Validate the JSON file before proceeding + validate_regions_config_json + + # If the availability zone is NOT supported, then exit the script now + is_availability_zone_supported "${AVAILABILITY_ZONE}" + + # Get the region from the availability zone + region="${AVAILABILITY_ZONE%?}" + + # Determine which config we want to get + if [ "$GET_SECURITY_GROUP_ID" -eq 1 ]; then + jq -r ".[] | select(.region == \"$region\") | to_entries[] | select(.key == \"security-group-id\") | .value" "${REGIONS_CONFIG_FILE}" + elif [ "$GET_SUBNET" -eq 1 ]; then + # First check if there are any subnets defined before we try to index + subnet_map=$(jq -r ".[] | select(.region == \"${region}\") | .subnets" "${REGIONS_CONFIG_FILE}") + if [[ -z "${subnet_map}" || "${subnet_map}" == "null" ]]; then + log_error "No subnets defined for ${region}. Please provide a list of subnets in your 'regions_config' input." + exit 1 + else + echo "$subnet_map" | jq -r "to_entries[] | select(.key == \"${AVAILABILITY_ZONE}\") | .value" + fi + elif [ "$GET_AMI_ID" -eq 1 ]; then + jq -r ".[] | select(.region == \"$region\") | to_entries[] | select(.key == \"ec2-ami\") | .value" "${REGIONS_CONFIG_FILE}" + fi +} + +# Process command line arguments +while getopts ":z:c:v:l:" opt; do + case "${opt}" in + z) + AVAILABILITY_ZONE="${OPTARG}" + ;; + c) + REGIONS_CONFIG_FILE="${OPTARG}" + ;; + v) + if [[ "${OPTARG}" == "security-group-id" ]]; then + GET_SECURITY_GROUP_ID=1 + elif [[ "${OPTARG}" == "ec2-ami" ]]; then + GET_AMI_ID=1 + elif [[ "${OPTARG}" == "subnet" ]]; then + GET_SUBNET=1 + else + log_error "Unrecognized config value '${OPTARG}'. Choose from one of the valid retrievable, config values: { 'security-group-id', 'ec2-ami', 'subnet' }" + exit 1 + fi + ;; + l) + # We don't need to do error checking at this point since we only have 'INFO' and 'ERROR' + LOG_LEVEL="${OPTARG}" + ;; + *) + echo "Invalid option: $1" >&2 + usage + exit 1 ;; + esac +done +shift $((OPTIND-1)) + +get_config_value \ No newline at end of file diff --git a/actions/launch-ec2-runner-with-fallback/tests/test-data/invalid-regions-config-missing-ec2-ami.json b/actions/launch-ec2-runner-with-fallback/tests/test-data/invalid-regions-config-missing-ec2-ami.json new file mode 100644 index 0000000..f82e9bc --- /dev/null +++ b/actions/launch-ec2-runner-with-fallback/tests/test-data/invalid-regions-config-missing-ec2-ami.json @@ -0,0 +1,20 @@ +[ + { + "region": "us-east-2", + "subnets": { + "us-east-2a": "subnet-a", + "us-east-2b": "subnet-b", + "us-east-2c": "subnet-c" + }, + "security-group-id": "sg-0" + }, + { + "region": "us-east-1", + "subnets": { + "us-east-1a": "subnet-a1", + "us-east-1b": "subnet-a2", + "us-east-1c": "subnet-a3" + }, + "security-group-id": "sg-1" + } +] \ No newline at end of file diff --git a/actions/launch-ec2-runner-with-fallback/tests/test-data/invalid-regions-config-missing-security-group-id.json b/actions/launch-ec2-runner-with-fallback/tests/test-data/invalid-regions-config-missing-security-group-id.json new file mode 100644 index 0000000..85332ea --- /dev/null +++ b/actions/launch-ec2-runner-with-fallback/tests/test-data/invalid-regions-config-missing-security-group-id.json @@ -0,0 +1,20 @@ +[ + { + "region": "us-east-2", + "subnets": { + "us-east-2a": "subnet-a", + "us-east-2b": "subnet-b", + "us-east-2c": "subnet-c" + }, + "ec2-ami": "ami-01234567890" + }, + { + "region": "us-east-1", + "subnets": { + "us-east-1a": "subnet-a1", + "us-east-1b": "subnet-a2", + "us-east-1c": "subnet-a3" + }, + "ec2-ami": "ami-0987654321" + } +] \ No newline at end of file diff --git a/actions/launch-ec2-runner-with-fallback/tests/test-data/invalid-regions-config-missing-subnet.json b/actions/launch-ec2-runner-with-fallback/tests/test-data/invalid-regions-config-missing-subnet.json new file mode 100644 index 0000000..b69e20d --- /dev/null +++ b/actions/launch-ec2-runner-with-fallback/tests/test-data/invalid-regions-config-missing-subnet.json @@ -0,0 +1,16 @@ +[ + { + "region": "us-east-2", + "subnets": { + "us-east-2a": "subnet-a", + "us-east-2c": "subnet-c" + }, + "ec2-ami": "ami-01234567890", + "security-group-id": "sg-0" + }, + { + "region": "us-east-1", + "ec2-ami": "ami-0987654321", + "security-group-id": "sg-1" + } +] \ No newline at end of file diff --git a/actions/launch-ec2-runner-with-fallback/tests/test-data/valid-regions-config.json b/actions/launch-ec2-runner-with-fallback/tests/test-data/valid-regions-config.json new file mode 100644 index 0000000..991b348 --- /dev/null +++ b/actions/launch-ec2-runner-with-fallback/tests/test-data/valid-regions-config.json @@ -0,0 +1,22 @@ +[ + { + "region": "us-east-2", + "subnets": { + "us-east-2a": "subnet-a", + "us-east-2b": "subnet-b", + "us-east-2c": "subnet-c" + }, + "ec2-ami": "ami-01234567890", + "security-group-id": "sg-0" + }, + { + "region": "us-east-1", + "subnets": { + "us-east-1a": "subnet-a1", + "us-east-1b": "subnet-a2", + "us-east-1c": "subnet-a3" + }, + "ec2-ami": "ami-0987654321", + "security-group-id": "sg-1" + } +] \ No newline at end of file diff --git a/actions/launch-ec2-runner-with-fallback/tests/test_get_config_value.sh b/actions/launch-ec2-runner-with-fallback/tests/test_get_config_value.sh new file mode 100755 index 0000000..c554e01 --- /dev/null +++ b/actions/launch-ec2-runner-with-fallback/tests/test_get_config_value.sh @@ -0,0 +1,259 @@ +#!/bin/bash +# shellcheck disable=SC2086,SC2128 + +################################### +# GLOBAL VARIABLES (CONFIGS) # +################################### +# Default, *relative* location for 'get_config_value.sh' +GET_CONFIG_VALUE_SCRIPT="../scripts/get_config_value.sh " + +# Logging configs +LOG_LEVEL="ERROR" + +# Valid configs +VALID_REGIONS_CONFIG="test-data/valid-regions-config.json" + +# Invalid configs +INVALID_REGIONS_CONFIG_MISSING_SECURITY_GROUP_ID="test-data/invalid-regions-config-missing-security-group-id.json" +INVALID_REGIONS_CONFIG_MISSING_SUBNET="test-data/invalid-regions-config-missing-subnet.json" +INVALID_REGIONS_CONFIG_MISSING_EC2_AMI="test-data/invalid-regions-config-missing-ec2-ami.json" + +# Keep track of passes, failures, and total tests +NUM_TESTS=0 +NUM_SUCCESSES=0 +NUM_FAILURES=0 + +################################### +# UTILITY FUNCTIONS - NOT TESTS # +################################### +fail_msg(){ + local function_name=$1 + local reason=$2 + echo "[ FAIL ] $function_name() failed: $reason" + + NUM_TESTS=$((NUM_TESTS+1)) + NUM_FAILURES=$((NUM_FAILURES+1)) +} + +pass_msg(){ + local function_name=$1 + echo "[ PASS ] $function_name() passed" + + NUM_TESTS=$((NUM_TESTS+1)) + NUM_SUCCESSES=$((NUM_SUCCESSES+1)) +} + +################################### +# BEGIN TESTS # +################################### +# Make sure to add each NEW test to the "run_tests()" function at the end of this file! + +test_validate_regions_config_field() { + # Inputs + availability_zone="us-east-2a" + + # Computation + actual_result=$(sh ${GET_CONFIG_VALUE_SCRIPT} -l "${LOG_LEVEL}" -c "${VALID_REGIONS_CONFIG}" -z "${availability_zone}" -v "fake-field") + expected_result="[ERROR] Unrecognized config value 'fake-field'. Choose from one of the valid retrievable, config values: { 'security-group-id', 'ec2-ami', 'subnet' }" + + if [[ "${actual_result}" != "${expected_result}" ]]; then + fail_msg $FUNCNAME "${actual_result} != ${expected_result}" + else + pass_msg $FUNCNAME + fi +} + +test_get_subnet_exists() { + # Inputs + availability_zone="us-east-2a" + + # Computation + actual_subnet=$(sh ${GET_CONFIG_VALUE_SCRIPT} -l "${LOG_LEVEL}" -c "${VALID_REGIONS_CONFIG}" -z "${availability_zone}" -v "subnet") + expected_subnet="subnet-a" + + if [[ "${actual_subnet}" != "${expected_subnet}" ]]; then + fail_msg $FUNCNAME "${actual_subnet} != ${expected_subnet}" + else + pass_msg $FUNCNAME + fi +} + +test_get_subnet_does_not_exist() { + # Inputs + availability_zone="us-east-2b" + + # Computation + actual_subnet=$(sh ${GET_CONFIG_VALUE_SCRIPT} -l "${LOG_LEVEL}" -c "${INVALID_REGIONS_CONFIG_MISSING_SUBNET}" -z "${availability_zone}" -v "subnet") + expected_subnet="" + + if [[ "${actual_subnet}" != "${expected_subnet}" ]]; then + fail_msg $FUNCNAME "${actual_subnet} != ${expected_subnet}" + else + pass_msg $FUNCNAME + fi +} + +test_get_subnet_no_subnets_defined() { + # Inputs + availability_zone="us-east-1a" + + # Computation + actual_subnet=$(sh ${GET_CONFIG_VALUE_SCRIPT} -l "${LOG_LEVEL}" -c "${INVALID_REGIONS_CONFIG_MISSING_SUBNET}" -z "${availability_zone}" -v "subnet") + expected_error="[ERROR] No subnets defined for us-east-1. Please provide a list of subnets in your 'regions_config' input." + + if [[ "${actual_subnet}" != "${expected_error}" ]]; then + fail_msg $FUNCNAME "${actual_subnet} != ${expected_error}" + else + pass_msg $FUNCNAME + fi +} + +test_get_subnet_but_availability_zone_is_not_supported() { + # Inputs + availability_zone="us-east-7a" + + # Computation + actual_value=$(sh ${GET_CONFIG_VALUE_SCRIPT} -l ${LOG_LEVEL} -c ${VALID_REGIONS_CONFIG} -z ${availability_zone} -v "subnet") + expected_error="[ERROR] Availability zone '${availability_zone}' is currently not supported by this GitHub action. If you would like this availability zone supported, please file an issue under: https://github.com/instructlab/ci-actions/issues" + + if [[ "${actual_value}" != "${expected_error}" ]]; then + fail_msg $FUNCNAME "Expected a specific error to be thrown, but got: ${actual_value}" + else + pass_msg $FUNCNAME + fi +} + +test_get_security_group_exists() { + # Inputs + availability_zone="us-east-2a" + + # Computation + actual_sg_id=$(sh ${GET_CONFIG_VALUE_SCRIPT} -l "${LOG_LEVEL}" -c "${VALID_REGIONS_CONFIG}" -z "${availability_zone}" -v "security-group-id") + expected_sg_id="sg-0" + + if [[ "${actual_sg_id}" != "${expected_sg_id}" ]]; then + fail_msg $FUNCNAME "${actual_sg_id} != ${expected_sg_id}" + else + pass_msg $FUNCNAME + fi +} + +test_get_security_group_does_not_exist() { + # Inputs + availability_zone="us-east-1a" + + # Computation + actual_sg_id=$(sh ${GET_CONFIG_VALUE_SCRIPT} -l "${LOG_LEVEL}" -c "${INVALID_REGIONS_CONFIG_MISSING_SECURITY_GROUP_ID}" -z "${availability_zone}" -v "security-group-id") + expected_sg_id="" + + if [[ "${actual_sg_id}" != "${expected_sg_id}" ]]; then + fail_msg $FUNCNAME "Expected a specific error to be thrown, but got: ${actual_sg_id}" + else + pass_msg $FUNCNAME + fi +} + +test_get_security_group_but_availability_zone_is_not_supported() { + # Inputs + availability_zone="us-east-7a" + + # Computation + actual_value=$(sh ${GET_CONFIG_VALUE_SCRIPT} -l "${LOG_LEVEL}" -c "${VALID_REGIONS_CONFIG}" -z "${availability_zone}" -v "security-group-id") + expected_error="[ERROR] Availability zone '${availability_zone}' is currently not supported by this GitHub action. If you would like this availability zone supported, please file an issue under: https://github.com/instructlab/ci-actions/issues" + + if [[ "${actual_value}" != "${expected_error}" ]]; then + fail_msg $FUNCNAME "Expected a specific error to be thrown, but got: ${actual_value}" + else + pass_msg $FUNCNAME + fi +} + +test_get_ec2_ami_exists() { + # Inputs + availability_zone="us-east-2a" + + # Computation + actual_ami_id=$(sh ${GET_CONFIG_VALUE_SCRIPT} -l "${LOG_LEVEL}" -c "${VALID_REGIONS_CONFIG}" -z "${availability_zone}" -v "ec2-ami") + expected_ami_id="ami-01234567890" + + if [[ "${actual_ami_id}" != "${expected_ami_id}" ]]; then + fail_msg $FUNCNAME "${actual_ami_id} != ${expected_ami_id}" + else + pass_msg $FUNCNAME + fi +} + +test_get_ec2_ami_does_not_exist() { + # Inputs + availability_zone="us-east-1a" + + # Computation + actual_ami_id=$(sh ${GET_CONFIG_VALUE_SCRIPT} -l "${LOG_LEVEL}" -c "${INVALID_REGIONS_CONFIG_MISSING_EC2_AMI}" -z "${availability_zone}" -v "ec2-ami") + expected_ami_id="" + + if [[ "${actual_ami_id}" != "${expected_ami_id}" ]]; then + fail_msg $FUNCNAME "Expected a specific error to be thrown, but got: ${actual_ami_id}" + else + pass_msg $FUNCNAME + fi +} + +test_get_ec2_ami_but_availability_zone_is_not_supported() { + # Inputs + availability_zone="us-east-7a" + + # Computation + actual_ami_id=$(sh ${GET_CONFIG_VALUE_SCRIPT} -l "${LOG_LEVEL}" -c "${VALID_REGIONS_CONFIG}" -z "${availability_zone}" -v "ec2-ami") + expected_error="[ERROR] Availability zone '${availability_zone}' is currently not supported by this GitHub action. If you would like this availability zone supported, please file an issue under: https://github.com/instructlab/ci-actions/issues" + + if [[ "${actual_value}" != "${expected_error}" ]]; then + fail_msg $FUNCNAME "Expected a specific error to be thrown, but got: ${actual_value}" + else + pass_msg $FUNCNAME + fi +} + +################################### +# TEST RUNNER # +################################### +# Add unit tests here +run_tests() { + echo "====================================================" + echo "Running 'unit' tests for 'get_config_values.sh()'..." + echo "====================================================" + + # User input tests + test_validate_regions_config_field + + # Subnet tests + test_get_subnet_exists + test_get_subnet_does_not_exist + test_get_subnet_no_subnets_defined + test_get_subnet_but_availability_zone_is_not_supported + + # Security group ID tests + test_get_security_group_exists + test_get_security_group_does_not_exist + test_get_security_group_but_availability_zone_is_not_supported + + # AMI ID tests + test_get_ec2_ami_exists + test_get_ec2_ami_does_not_exist + test_get_ec2_ami_but_availability_zone_is_not_supported +} + +################################### +# ANALYZE TEST RESULTS # +################################### +# Run the tests and abort if there is even 1 failure +run_tests + +echo "----------------------------------------------------" +echo ">>> Summary": +echo "${NUM_SUCCESSES} / ${NUM_TESTS} tests passed" + +if (( NUM_FAILURES > 0)); then + echo " ** Detected at least one failure. Review the above output to determine which tests failed and why." + echo " ** All tests are required to pass. Aborting..." + exit 1 +fi \ No newline at end of file diff --git a/tox.ini b/tox.ini index e33c1c2..68f5c71 100644 --- a/tox.ini +++ b/tox.ini @@ -95,6 +95,18 @@ deps = commands = mypy {posargs} +# This test environment is separate for 'launch-ec2-runner-with-fallback' rather than being a +# generic "bash" test env. We need the 'change_dir' specified for each bash test. Unfortunately, +# using 'cd' is not recognized even with 'allowlist_externals' +[testenv:launch-ec2-runner-with-fallback] +description = Test Bash scripts for launch-ec2-runner-with-fallback +skip_install = true +skipsdist = true +change_dir = actions/launch-ec2-runner-with-fallback/tests/ +commands = + # "UNIT" TESTS: launch-ec2-runner-with-fallback + ./test_get_config_value.sh + [gh] python = 3.11 = py311-unitcov