Workflow file for this run
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
--- | ||
name: openBalena tests | ||
on: | ||
workflow_call: | ||
# https://docs.github.com/en/actions/security-guides/automatic-token-authentication | ||
# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions | ||
permissions: | ||
contents: read | ||
id-token: write # AWS GitHub OIDC required: write | ||
packages: read | ||
env: | ||
# Stack ID | ||
# arn:aws:cloudformation:us-east-1:491725000532:stack/balena-tests-s3-certs/814dea60-404d-11ed-b06f-0a7d458f8ba5 | ||
AWS_S3_CERTS_BUCKET: balena-tests-certs | ||
# (kvm) nested virtualisation not supported on AWS/EC2 instance types|classes other than X.metal | ||
AWS_EC2_INSTANCE_TYPES: "c6a.xlarge c6i.xlarge c5n.xlarge c5.xlarge c5a.xlarge m5.xlarge m5n.xlarge m5a.xlarge" | ||
AWS_EC2_LAUNCH_TEMPLATE: lt-02e10a4f66261319d | ||
AWS_EC2_LT_VERSION: "5" | ||
AWS_IAM_USERNAME: balena-tests-iam-User-1GXO3XP12N6LL | ||
AWS_LOGS_RETENTION: "30" | ||
AWS_VPC_SECURITY_GROUP_IDS: sg-057937f4d89d9d51c | ||
AWS_VPC_SUBNET_IDS: 'subnet-02d18a08ea4058574 subnet-0a026eae1df907a09' | ||
# otherwise it tries to send data to an endpoint provided by a private project | ||
# https://github.com/balena-io/analytics-backend | ||
# .. which is not part of openBalena | ||
BALENARC_NO_ANALYTICS: "1" # https://github.com/balena-io/balena-cli/blob/master/lib/events.ts#L62-L70 | ||
DEBUG: "0" # https://github.com/balena-io/balena-cli/issues/2447 | ||
RETRY: "3" | ||
jobs: | ||
test: | ||
runs-on: ["self-hosted", "X64", "distro:jammy"] # tests require socat v1.7.4 | ||
timeout-minutes: 60 | ||
strategy: | ||
matrix: | ||
target: | ||
- compose-private-pki | ||
- balena-public-pki | ||
include: | ||
# tests compose flow using self-signed PKI | ||
- target: compose-private-pki | ||
# Canonical, Ubuntu, 24.04 LTS, amd64 noble image build on 2024-04-23 | ||
ami: ami-04b70fa74e45c3917 | ||
subdomain: auto | ||
dns_tld: balena-devices.com | ||
verbose: ${{ vars.VERBOSE || 'false' }} | ||
# .. balena flow using Let's Encrypt (ACME) PKI | ||
# https://dash.cloudflare.com/001b3ed2352612aaa068aca1b0022736/balena-devices.com/dns | ||
- target: balena-public-pki | ||
# balenaOS-2.113.12-generic-amd64 | ||
ami: ami-03a3995797dee84fa | ||
subdomain: auto | ||
dns_tld: balena-devices.com | ||
environment: balena-cloud.com | ||
fleet: balena/open-balena | ||
verbose: ${{ vars.VERBOSE || 'false' }} | ||
# https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs#handling-failures | ||
fail-fast: true | ||
environment: | ||
name: ${{ matrix.target }} | ||
steps: | ||
- uses: actions/checkout@b80ff79f1755d06ba70441c368a6fe801f5f3a62 | ||
# https://github.com/unfor19/install-aws-cli-action | ||
- name: Setup awscli | ||
uses: unfor19/install-aws-cli-action@v1 | ||
- uses: aws-actions/configure-aws-credentials@bd0758102444af2a09b9e47a2c93d0f091c1252d | ||
with: | ||
aws-region: ${{ vars.AWS_REGION || 'us-east-1' }} | ||
role-session-name: github-${{ github.job }}-${{ github.run_id }}-${{ github.run_attempt }} | ||
# balena-io/environments-bases: aws/balenacloud/ephemeral-tests/balena-tests-iam.yml | ||
role-to-assume: ${{ vars.AWS_IAM_ROLE }} | ||
# https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html#install-plugin-debian | ||
- name: install session-manager-plugin | ||
if: | | ||
github.event_name == 'pull_request' && | ||
github.event.action != 'closed' && | ||
matrix.target == 'compose-private-pki' | ||
run: | | ||
runner_arch="$(echo "${RUNNER_ARCH}" | tr '[:upper:]' '[:lower:]' | sed 's/x64/64bit/g')" | ||
session-manager-plugin || (curl -sSfo session-manager-plugin.deb https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_${runner_arch}/session-manager-plugin.deb \ | ||
&& sudo dpkg -i session-manager-plugin.deb \ | ||
&& rm -f session-manager-plugin.deb) | ||
# https://github.com/balena-io-examples/setup-balena-action | ||
- name: Setup balena CLI | ||
uses: balena-io-examples/setup-balena-action@main | ||
# https://github.com/pdcastro/ssh-uuid#why | ||
# https://github.com/pdcastro/ssh-uuid#linux-debian-ubuntu-others | ||
- name: install additional dependencies | ||
if: | | ||
github.event_name == 'pull_request' && | ||
github.event.action != 'closed' && | ||
matrix.target == 'balena-public-pki' | ||
shell: bash | ||
run: | | ||
set -ue | ||
echo '::notice::install additional dependencies' | ||
[[ '${{ matrix.verbose || 'false' }}' =~ on|On|Yes|yes|true|True ]] && set -x | ||
source src/balena-tests/functions | ||
mkdir -p "${RUNNER_TEMP}/ssh-uuid" | ||
wget -q -O "${RUNNER_TEMP}/ssh-uuid/ssh-uuid" https://raw.githubusercontent.com/pdcastro/ssh-uuid/master/ssh-uuid.sh \ | ||
&& chmod +x "${RUNNER_TEMP}/ssh-uuid/ssh-uuid" \ | ||
&& ln -s "${RUNNER_TEMP}/ssh-uuid/ssh-uuid" "${RUNNER_TEMP}/ssh-uuid/scp-uuid" | ||
with_backoff balena login --token '${{ secrets.BALENA_API_KEY }}' | ||
balena version | ||
"${RUNNER_TEMP}/ssh-uuid/scp-uuid" --help | ||
grep -q "${RUNNER_TEMP}/ssh-uuid" "${GITHUB_PATH}" \ | ||
|| echo "${RUNNER_TEMP}/ssh-uuid" >> "${GITHUB_PATH}" | ||
- name: generate SSH key | ||
id: generate-key-pair | ||
if: | | ||
github.event_name == 'pull_request' && | ||
github.event.action != 'closed' | ||
run: | | ||
set -ue | ||
verbose='+x' | ||
if [[ '${{ matrix.verbose }}' =~ on|On|Yes|yes|true|True ]]; then | ||
verbose='-x' | ||
fi | ||
set ${verbose} | ||
key_name="${{ matrix.target }}-${GITHUB_RUN_ID}-${GITHUB_RUN_NUMBER}-${GITHUB_RUN_ATTEMPT}" | ||
echo "key_name=${key_name}" >> $GITHUB_OUTPUT | ||
set +x | ||
private_key_material="$(aws ec2 create-key-pair \ | ||
--key-name "${key_name}" | jq -r .KeyMaterial)" | ||
public_key="$(aws ec2 describe-key-pairs --include-public-key \ | ||
--key-name "${key_name}" | jq -re .KeyPairs[].PublicKey)" | ||
# https://stackoverflow.com/a/70384422/1559300 | ||
# https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#masking-a-value-in-log | ||
while read -r line; do | ||
echo "::add-mask::${line}" | ||
done <<< "${private_key_material}" | ||
ssh_private_key="$(cat << EOF | ||
$(echo "${private_key_material}") | ||
EOF | ||
)" | ||
echo "ssh_private_key<<EOF" >> $GITHUB_OUTPUT | ||
set ${verbose} | ||
echo "${ssh_private_key}" >> $GITHUB_OUTPUT | ||
echo "EOF" >> $GITHUB_OUTPUT | ||
echo "${ssh_public_key}" >> $GITHUB_OUTPUT | ||
env: | ||
AWS_DEFAULT_REGION: ${{ vars.AWS_REGION || 'us-east-1' }} | ||
# https://github.com/webfactory/ssh-agent | ||
- uses: webfactory/ssh-agent@dc588b651fe13675774614f8e6a936a468676387 # v0.9.0 | ||
if: | | ||
github.event_name == 'pull_request' && | ||
github.event.action != 'closed' | ||
with: | ||
ssh-private-key: ${{ steps.generate-key-pair.outputs.ssh_private_key }} | ||
- name: provision SSH key | ||
id: provision-ssh-key | ||
# wait for cloud-config | ||
# https://github.com/balena-os/cloud-config | ||
timeout-minutes: 5 | ||
if: | | ||
github.event_name == 'pull_request' && | ||
github.event.action != 'closed' && | ||
matrix.target == 'balena-public-pki' | ||
run: | | ||
set -ue | ||
[[ '${{ matrix.verbose || 'false' }}' =~ on|On|Yes|yes|true|True ]] && set -x | ||
source src/balena-tests/functions | ||
with_backoff balena login --token '${{ secrets.BALENA_API_KEY }}' | ||
if ! [[ -e "${HOME}/.ssh/id_rsa" ]]; then | ||
echo '${{ steps.generate-key-pair.outputs.ssh_private_key }}' > "${HOME}/.ssh/id_rsa" | ||
echo '${{ steps.generate-key-pair.outputs.ssh_public_key }}' > "${HOME}/.ssh/id_rsa.pub" | ||
fi | ||
echo "::notice::check $(balena keys | wc -l) keys" | ||
match='' | ||
for key in $(balena keys | grep -v ID | awk '{print $1}'); do | ||
fp=$(balena key ${key} | tail -n 1 | ssh-keygen -E md5 -lf /dev/stdin | awk '{print $2}') | ||
if [[ $fp =~ $(ssh-keygen -E md5 -lf "${HOME}/.ssh/id_rsa" | awk '{print $2}') ]]; then | ||
match="${key}" | ||
break | ||
fi | ||
done | ||
if [[ -z $match ]]; then | ||
balena key add "${GITHUB_SHA}" "${HOME}/.ssh/id_rsa.pub" | ||
else | ||
balena keys | ||
fi | ||
while ! [[ "$(ssh-uuid -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ | ||
${{ steps.register-test-device.outputs.balena_device_uuid }}.balena \ | ||
cat /mnt/boot/config.json | jq -r .uuid)" =~ ${{ steps.register-test-device.outputs.balena_device_uuid }} ]]; do | ||
echo "::warning::Still working..." | ||
sleep "$(( (RANDOM % 5) + 5 ))s" | ||
done | ||
echo "key_id=${GITHUB_SHA}" >> "${GITHUB_OUTPUT}" | ||
- name: (pre)register test device | ||
id: register-test-device | ||
if: | | ||
github.event_name == 'pull_request' && | ||
github.event.action != 'closed' && | ||
matrix.target == 'balena-public-pki' | ||
run: | | ||
set -ue | ||
[[ '${{ matrix.verbose || 'false' }}' =~ on|On|Yes|yes|true|True ]] && set -x | ||
source src/balena-tests/functions | ||
with_backoff balena login --token '${{ secrets.BALENA_API_KEY }}' | ||
balena_device_uuid="$(openssl rand -hex 16)" | ||
# https://www.balena.io/docs/learn/more/masterclasses/advanced-cli/#52-preregistering-a-device | ||
with_backoff balena device register '${{ matrix.fleet }}' --uuid "${balena_device_uuid}" | ||
device_id="$(balena device "${balena_device_uuid}" | grep ^ID: | cut -c20-)" | ||
# the actual version deployed depends on the AWS EC2/AMI, defined in AWS_EC2_LAUNCH_TEMPLATE | ||
os_version="$(balena os versions ${{ vars.DEVICE_TYPE || 'generic-amd64' }} | head -n 1)" | ||
balena config generate \ | ||
--version "${os_version}" \ | ||
--device "${balena_device_uuid}" \ | ||
--network ethernet \ | ||
--appUpdatePollInterval 10 \ | ||
$([[ '${{ vars.DEVELOPMENT_MODE || 'false' }}' =~ true ]] && echo '--dev') \ | ||
--output config.json | ||
with_backoff balena tag set balena ephemeral-test-device --device "${balena_device_uuid}" | ||
github_vars=(GITHUB_ACTOR GITHUB_BASE_REF GITHUB_HEAD_REF GITHUB_JOB \ | ||
GITHUB_REF GITHUB_REF_NAME GITHUB_REF_TYPE GITHUB_REPOSITORY \ | ||
GITHUB_REPOSITORY_OWNER GITHUB_RUN_ATTEMPT GITHUB_RUN_ID GITHUB_RUN_NUMBER \ | ||
GITHUB_SHA GITHUB_WORKFLOW RUNNER_ARCH RUNNER_NAME RUNNER_OS) | ||
for github_var in "${github_vars[@]}"; do | ||
balena tag set ${github_var} "${!github_var}" --device "${balena_device_uuid}" | ||
done | ||
echo "balena_device_uuid=${balena_device_uuid}" >> "${GITHUB_OUTPUT}" | ||
echo "balena_device_id=${device_id}" >> "${GITHUB_OUTPUT}" | ||
# https://github.com/balena-io/balena-cli/issues/1543 | ||
- name: pin device to draft release | ||
if: | | ||
github.event_name == 'pull_request' && | ||
github.event.action != 'closed' && | ||
matrix.target == 'balena-public-pki' | ||
run: | | ||
set -uae | ||
[[ '${{ matrix.verbose || 'false' }}' =~ on|On|Yes|yes|true|True ]] && set -x | ||
source src/balena-tests/functions | ||
with_backoff balena login --token '${{ secrets.BALENA_API_KEY }}' | ||
pr_id='${{ github.event.pull_request.id }}' | ||
head_sha='${{ github.event.pull_request.head.sha || github.event.head_commit.id }}' | ||
release_id="$(with_backoff balena releases '${{ matrix.fleet }}' --json \ | ||
| jq -r --arg pr_id "${pr_id}" --arg head_sha "${head_sha}" '.[] | ||
| select(.release_tag[].tag_key=="balena-ci-commit-sha") | ||
| select(.release_tag[].value==$head_sha) | ||
| select(.release_tag[].tag_key=="balena-ci-id") | ||
| select(.release_tag[].value==$pr_id).commit')" | ||
with_backoff balena device pin \ | ||
${{ steps.register-test-device.outputs.balena_device_uuid }} \ | ||
"${release_id}" | ||
with_backoff balena device ${{ steps.register-test-device.outputs.balena_device_uuid }} | ||
- name: configure test device environment | ||
if: | | ||
github.event_name == 'pull_request' && | ||
github.event.action != 'closed' && | ||
matrix.target == 'balena-public-pki' | ||
run: | | ||
set -ue | ||
[[ '${{ matrix.verbose || 'false' }}' =~ on|On|Yes|yes|true|True ]] && set -x | ||
source src/balena-tests/functions | ||
with_backoff balena login --token '${{ secrets.BALENA_API_KEY }}' | ||
with_backoff balena env add VERBOSE "${{ vars.VERBOSE || 'false' }}" \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
with_backoff balena env add BALENARC_NO_ANALYTICS '1' \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
with_backoff balena env add DNS_TLD '${{ matrix.target }}' \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
with_backoff balena env add DB_HOST db \ | ||
--service api \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
with_backoff balena env add REDIS_HOST redis:6379 \ | ||
--service api \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
# to allow devices running locally to communicate to the local API, we can route | ||
# to the local Docker network aliases instead of public DNS, since (a) DNS_TLD is | ||
# guestfwd(ed) in QEMU to a special internal IP 10.0.2.100; (b) is proxied to | ||
# haproxy network alias on device; and (c) made public with a wildcard DNS record | ||
# (e.g.) | ||
# | ||
# $ dig +short api.auto.balena-devices.com | ||
# 10.0.2.100 | ||
# | ||
with_backoff balena env add API_HOST 'api.${{ matrix.target }}' \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
# not used but required for config.json to be valid | ||
with_backoff balena env add DELTA_HOST 'delta.${{ matrix.target }}' \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
with_backoff balena env add REGISTRY2_HOST 'registry2.${{ matrix.target }}' \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
with_backoff balena env add VPN_HOST 'cloudlink.${{ matrix.target }}' \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
with_backoff balena env add HOST 'api.${{ matrix.target }}' \ | ||
--service api \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
with_backoff balena env add TOKEN_AUTH_CERT_ISSUER 'api.${{ matrix.target }}' \ | ||
--service api \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
with_backoff balena env add REGISTRY2_TOKEN_AUTH_ISSUER 'api.${{ matrix.target }}' \ | ||
--service registry \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
with_backoff balena env add REGISTRY2_TOKEN_AUTH_REALM 'https://api.${{ matrix.target }}/auth/v1/token' \ | ||
--service registry \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
with_backoff balena env add REGISTRY2_S3_REGION_ENDPOINT 's3.${{ matrix.target }}' \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
with_backoff balena env add WEBRESOURCES_S3_HOST 's3.${{ matrix.target }}' \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
# https://github.com/balena-io/cert-manager/blob/master/entry.sh#L255-L278 | ||
# cert-manager will restore the last wildcard certificate from AWS/S3 to avoid | ||
# being rate limited by LetsEncrypt/ACME | ||
with_backoff balena env add AWS_S3_BUCKET '${{ env.AWS_S3_CERTS_BUCKET }}' \ | ||
--service cert-manager \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
# FIXME: still required? | ||
with_backoff balena env add COMMON_REGION '${{ env.AWS_REGION }}' \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
with_backoff balena env add SUPERUSER_EMAIL 'admin@${{ matrix.target }}' \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
with_backoff balena env add ORG_UNIT openBalena \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
# unstable/unsupported functionality | ||
with_backoff balena env add HIDE_UNVERSIONED_ENDPOINT 'false' \ | ||
--service api \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
with_backoff balena env add RELEASE_ASSETS_TEST 'true' \ | ||
--service sut \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
- name: configure test device secrets | ||
if: | | ||
github.event_name == 'pull_request' && | ||
github.event.action != 'closed' && | ||
matrix.target == 'balena-public-pki' | ||
run: | | ||
set -ue | ||
[[ '${{ matrix.verbose || 'false' }}' =~ on|On|Yes|yes|true|True ]] && set -x | ||
source src/balena-tests/functions | ||
with_backoff balena login --token '${{ secrets.BALENA_API_KEY }}' | ||
# cert-manager requires it to get whoami information for the user | ||
with_backoff balena env add API_TOKEN '${{ secrets.BALENA_API_KEY }}' \ | ||
--service cert-manager \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
# cert-manager requires is to request wildcard SSL certificate from LetsEncrypt | ||
with_backoff balena env add CLOUDFLARE_API_TOKEN '${{ secrets.CLOUDFLARE_API_TOKEN }}' \ | ||
--service cert-manager \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
# AWS credentials to backup/restore PKI assets | ||
with_backoff balena env add AWS_ACCESS_KEY_ID '${{ env.AWS_ACCESS_KEY_ID }}' \ | ||
--service cert-manager \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
with_backoff balena env add AWS_SECRET_ACCESS_KEY '${{ env.AWS_SECRET_ACCESS_KEY }}' \ | ||
--service cert-manager \ | ||
--device '${{ steps.register-test-device.outputs.balena_device_uuid }}' | ||
- name: provision balenaOS ephemeral SUT | ||
id: balena-sut | ||
if: | | ||
github.event_name == 'pull_request' && | ||
github.event.action != 'closed' && | ||
matrix.target == 'balena-public-pki' | ||
run: | | ||
set -ue | ||
[[ '${{ matrix.verbose || 'false' }}' =~ on|On|Yes|yes|true|True ]] && set -x | ||
for subnet_id in ${{ env.AWS_VPC_SUBNET_IDS }}; do | ||
# spot, on-demand | ||
for market_type in ${{ vars.MARKET_TYPES || 'spot' }}; do | ||
for instance_type in ${AWS_EC2_INSTANCE_TYPES}; do | ||
# https://docs.aws.amazon.com/cli/latest/reference/ec2/run-instances.html | ||
response="$(aws ec2 run-instances \ | ||
--image-id ${{ matrix.ami }} \ | ||
--launch-template 'LaunchTemplateId=${{ env.AWS_EC2_LAUNCH_TEMPLATE }},Version=${{ env.AWS_EC2_LT_VERSION }}' \ | ||
--instance-type "${instance_type}" \ | ||
$([[ $market_type =~ spot ]] && echo '--instance-market-options MarketType=spot') \ | ||
--security-group-ids '${{ env.AWS_VPC_SECURITY_GROUP_IDS }}' \ | ||
--subnet-id "${subnet_id}" \ | ||
--associate-public-ip-address \ | ||
--user-data file://config.json \ | ||
--tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=open-balena-tests},{Key=matrix.target,Value=${{ matrix.target }}},{Key=MarketType,Value=${market_type}},{Key=Owner,Value=${{ env.AWS_IAM_USERNAME }}},{Key=GITHUB_SHA,Value=${GITHUB_SHA}-tests},{Key=GITHUB_RUN_ID,Value=${GITHUB_RUN_ID}-tests},{Key=GITHUB_RUN_NUMBER,Value=${GITHUB_RUN_NUMBER}-tests},{Key=GITHUB_RUN_ATTEMPT,Value=${GITHUB_RUN_ATTEMPT}-tests}]" || true)" | ||
[[ -n $response ]] && break | ||
done | ||
[[ -n $response ]] && break | ||
done | ||
[[ -n $response ]] && break | ||
done | ||
[[ -z $response ]] && exit 1 | ||
instance_id="$(echo "${response}" | jq -r '.Instances[].InstanceId')" | ||
aws ec2 wait instance-running --instance-ids "${instance_id}" | ||
aws ec2 wait instance-status-ok --instance-ids "${instance_id}" | ||
echo "instance_id=${instance_id}" >> "${GITHUB_OUTPUT}" | ||
env: | ||
AWS_DEFAULT_REGION: ${{ vars.AWS_REGION || 'us-east-1' }} | ||
- name: wait for application | ||
timeout-minutes: 10 | ||
if: | | ||
github.event_name == 'pull_request' && | ||
github.event.action != 'closed' && | ||
matrix.target == 'balena-public-pki' | ||
run: | | ||
set -ue | ||
[[ '${{ matrix.verbose || 'false' }}' =~ on|On|Yes|yes|true|True ]] && set -x | ||
source src/balena-tests/functions | ||
with_backoff balena login --token '${{ secrets.BALENA_API_KEY }}' | ||
balena whoami && ssh-add -l | ||
while [[ "$(curl -X POST --silent --retry ${{ env.RETRY }} --fail \ | ||
'https://api.${{ matrix.environment }}/supervisor/v1/device' \ | ||
--header 'authorization: Bearer ${{ secrets.BALENA_API_KEY }}' \ | ||
--header 'Content-Type:application/json' \ | ||
--data '{"uuid": "${{ steps.register-test-device.outputs.balena_device_uuid }}", "method": "GET"}' \ | ||
--compressed | jq -r '.update_pending')" =~ ^true$ ]]; do | ||
sleep "$(( ( RANDOM % ${{ env.RETRY }} ) + ${{ env.RETRY }} ))s" | ||
done | ||
# wait for services to start running | ||
while with_backoff ssh-uuid -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ | ||
${{ steps.register-test-device.outputs.balena_device_uuid }}.balena \ | ||
'balena ps -q | xargs balena inspect | jq -r .[].State.Status' \ | ||
| grep -E 'created|restarting|removing|paused|exited|dead'; do | ||
echo "::warning::Still working..." | ||
sleep "$(( (RANDOM % 30) + 30 ))s" | ||
done | ||
# wait for Docker healthchecks | ||
while with_backoff ssh-uuid -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ | ||
${{ steps.register-test-device.outputs.balena_device_uuid }}.balena \ | ||
'balena ps -q | xargs balena inspect \ | ||
| jq -r ".[] | select(.State.Health.Status!=null).Name + \":\" + .State.Health.Status"' \ | ||
| grep -E ':starting|:unhealthy'; do | ||
echo "::warning::Still working..." | ||
sleep "$(( (RANDOM % 30) + 30 ))s" | ||
done | ||
# (TBC) https://www.balena.io/docs/reference/supervisor/docker-compose/ | ||
# due to lack of long form depends_on support in compositions, restart to ensure all | ||
# components are running with the latest configuration; preferred over restart via | ||
# Supervisor API restart due to potential HTTP [timeouts](https://github.com/balena-os/balena-supervisor/issues/1157) | ||
- name: restart components | ||
timeout-minutes: 10 | ||
if: | | ||
github.event_name == 'pull_request' && | ||
github.event.action != 'closed' && | ||
matrix.target == 'balena-public-pki' | ||
run: | | ||
set -ue | ||
[[ '${{ matrix.verbose || 'false' }}' =~ on|On|Yes|yes|true|True ]] && set -x | ||
source src/balena-tests/functions | ||
with_backoff balena login --token '${{ secrets.BALENA_API_KEY }}' | ||
balena whoami && ssh-add -l | ||
with_backoff ssh-uuid -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ | ||
${{ steps.register-test-device.outputs.balena_device_uuid }}.balena \ | ||
"balena ps -aq | xargs balena inspect \ | ||
| jq -re '.[] | ||
| select(.Name | contains(\"_supervisor\") | not).Id' \ | ||
| xargs balena restart" | ||
# wait for Docker healthchecks | ||
while with_backoff ssh-uuid -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ | ||
${{ steps.register-test-device.outputs.balena_device_uuid }}.balena \ | ||
'balena ps -q | xargs balena inspect \ | ||
| jq -r ".[] | select(.State.Health.Status!=null).Name + \":\" + .State.Health.Status"' \ | ||
| grep -E ':starting|:unhealthy'; do | ||
echo "::warning::Still working..." | ||
sleep "$(( (RANDOM % 30) + 30 ))s" | ||
done | ||
- name: SUT&DUT (balena) | ||
if: | | ||
github.event_name == 'pull_request' && | ||
github.event.action != 'closed' && | ||
matrix.target == 'balena-public-pki' | ||
timeout-minutes: 20 | ||
# https://giters.com/gfx/example-github-actions-with-tty | ||
# https://github.com/actions/runner/issues/241#issuecomment-924327172 | ||
shell: 'script -q -e -c "bash {0}"' | ||
run: | | ||
set -ue | ||
[[ '${{ matrix.verbose || 'false' }}' =~ on|On|Yes|yes|true|True ]] && set -x | ||
source src/balena-tests/functions | ||
with_backoff balena login --token '${{ secrets.BALENA_API_KEY }}' | ||
balena whoami && ssh-add -l | ||
(with_backoff ssh-uuid -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ | ||
${{ steps.register-test-device.outputs.balena_device_uuid }}.balena \ | ||
"balena ps -aq | xargs balena inspect \ | ||
| jq -re '.[] | select(.Name | contains(\"sut_\")).Id' \ | ||
| xargs balena logs -f") & | ||
# tests service is working while its status == running | ||
status='' | ||
while [[ "$status" =~ Running ]]; do | ||
status="$(curl --silent --retry ${{ env.RETRY }} --fail \ | ||
'https://api.${{ matrix.environment }}/supervisor/v2/applications/state' \ | ||
--header 'authorization: Bearer ${{ secrets.BALENA_API_KEY }}' \ | ||
--header 'Content-Type:application/json' \ | ||
--data '{"uuid": "${{ steps.register-test-device.outputs.balena_device_uuid }}", "method": "GET"}' \ | ||
--compressed | jq -r '.[].services.sut.status')" | ||
echo "::warning::Still working..." | ||
sleep "$(( ( RANDOM % ${{ env.RETRY }} ) + ${{ env.RETRY }} ))s" | ||
done | ||
# .. once the service exits with status == exited, it is assumed to be finished | ||
status='' | ||
while ! [[ "$status" =~ exited ]]; do | ||
echo "::warning::Still working..." | ||
status="$(curl --silent --retry ${{ env.RETRY }} --fail \ | ||
'https://api.${{ matrix.environment }}/supervisor/v2/applications/state' \ | ||
--header 'authorization: Bearer ${{ secrets.BALENA_API_KEY }}' \ | ||
--header 'Content-Type:application/json' \ | ||
--data '{"uuid": "${{ steps.register-test-device.outputs.balena_device_uuid }}", "method": "GET"}' \ | ||
--compressed | jq -r '.[].services.sut.status')" | ||
sleep "$(( ( RANDOM % ${{ env.RETRY }} ) + ${{ env.RETRY }} ))s" | ||
done | ||
# .. check its exit code | ||
expected_exit_code=0 | ||
actual_exit_code="$(with_backoff ssh-uuid -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ | ||
${{ steps.register-test-device.outputs.balena_device_uuid }}.balena \ | ||
"balena ps -aq | xargs balena inspect \ | ||
| jq -re '.[] | select(.Name | contains(\"sut_\")).State.ExitCode'")" | ||
[[ $expected_exit_code -eq $actual_exit_code ]] || false | ||
env: | ||
ATTEMPTS: 2 | ||
- name: provision Ubuntu ephemeral SUT | ||
id: ubuntu-sut | ||
if: | | ||
github.event_name == 'pull_request' && | ||
github.event.action != 'closed' && | ||
matrix.target == 'compose-private-pki' | ||
run: | | ||
set -ue | ||
[[ '${{ matrix.verbose }}' =~ on|On|Yes|yes|true|True ]] && set -x | ||
function cleanup() { | ||
rm -f user-data.yml | ||
} | ||
trap 'cleanup' EXIT | ||
aws sts get-caller-identity | ||
# https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/user-data.html | ||
# https://cloudinit.readthedocs.io/en/latest/reference/modules.html#update-etc-hosts | ||
cat << EOF > user-data.yml | ||
#cloud-config | ||
output : { all : '| tee -a /var/log/cloud-init-output.log' } | ||
manage_etc_hosts: api.${{ matrix.subdomain }}.${{ matrix.dns_tld }} | ||
packages: | ||
- git | ||
- jq | ||
- wget | ||
write_files: | ||
- path: /root/.env | ||
permissions: 0600 | ||
content: | | ||
DNS_TLD=${{ matrix.subdomain }}.${{ matrix.dns_tld }} | ||
PRODUCTION_MODE=false | ||
VERBOSE=${{ matrix.verbose }} | ||
- path: /root/functions | ||
permissions: 0755 | ||
content: | | ||
# https://coderwall.com/p/--eiqg/exponential-backoff-in-bash | ||
function with_backoff() { | ||
local max_attempts=${ATTEMPTS-5} | ||
local timeout=${TIMEOUT-1} | ||
local attempt=0 | ||
local exitCode=0 | ||
set +e | ||
while [[ $attempt < $max_attempts ]] | ||
do | ||
"$@" | ||
exitCode=$? | ||
if [[ $exitCode == 0 ]] | ||
then | ||
break | ||
fi | ||
echo "Failure! Retrying in $timeout.." 1>&2 | ||
sleep "$timeout" | ||
attempt=$(( attempt + 1 )) | ||
timeout=$(( timeout * 2 )) | ||
done | ||
if [[ $exitCode != 0 ]] | ||
then | ||
echo "You've failed me for the last time! ($*)" 1>&2 | ||
fi | ||
set -e | ||
return $exitCode | ||
} | ||
# docs/getting-started.md | ||
- path: /root/getting-started.sh | ||
permissions: 0755 | ||
content: | | ||
#!/usr/bin/env bash | ||
set -ax | ||
[[ '${{ matrix.verbose }}' =~ on|On|Yes|yes|true|True ]] && set -x | ||
source /root/functions | ||
apt-get update | ||
which openssl || apt-get install -y make openssl | ||
which git || apt-get install -y make git | ||
which jq || apt-get install -y make jq | ||
which make || apt-get install make | ||
which yq || with_backoff wget -q https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -O /usr/bin/yq | ||
chmod +x /usr/bin/yq | ||
yq --version | ||
which docker || curl -fsSL https://get.docker.com | sh - | ||
usermod -aG docker ubuntu | ||
systemctl enable docker && systemctl start docker | ||
chown ubuntu:docker /var/run/docker.sock | ||
id -u balena || useradd -s /bin/bash -m -G docker,sudo balena | ||
echo 'balena ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/balena | ||
while ! docker ps; do sleep $(((RANDOM%3)+1)); done | ||
with_backoff docker login \ | ||
--username='${{ secrets.DOCKERHUB_USER }}' \ | ||
--password='${{ secrets.DOCKERHUB_TOKEN }}' | ||
with_backoff docker login ghcr.io \ | ||
--username=token \ | ||
--password=${{ secrets.GITHUB_TOKEN }} | ||
if [ ! -f /sys/fs/cgroup/cgroup.controllers ]; then | ||
echo "cgroups v2 is disabled" | ||
else | ||
echo "cgroups v2 is enabled" | ||
source /etc/default/grub | ||
sed -i '/GRUB_CMDLINE_LINUX/d' /etc/default/grub | ||
echo GRUB_CMDLINE_LINUX=$(printf '\"%s systemd.unified_cgroup_hierarchy=0\"\n' "${GRUB_CMDLINE_LINUX}") > /etc/default/grub | ||
update-grub | ||
reboot | ||
fi | ||
# cloud-init runs as root | ||
# (e.g.) https://cloudinit.readthedocs.io/en/latest/reference/merging.html#example-cloud-config | ||
runcmd: | ||
- '/root/getting-started.sh' # FIXME: this may run before the script is written | ||
EOF | ||
for subnet_id in ${{ env.AWS_VPC_SUBNET_IDS }}; do | ||
# spot, on-demand | ||
for market_type in ${{ vars.MARKET_TYPES }}; do | ||
for instance_type in ${AWS_EC2_INSTANCE_TYPES}; do | ||
# https://docs.aws.amazon.com/cli/latest/reference/ec2/run-instances.html | ||
response="$(aws ec2 run-instances \ | ||
--image-id "${{ matrix.ami }}" \ | ||
--launch-template "LaunchTemplateId=${AWS_EC2_LAUNCH_TEMPLATE},Version=${AWS_EC2_LT_VERSION}" \ | ||
--instance-type "${instance_type}" \ | ||
$([[ $market_type =~ spot ]] && echo '--instance-market-options MarketType=spot') \ | ||
--security-group-ids "${AWS_VPC_SECURITY_GROUP_IDS}" \ | ||
--subnet-id "${subnet_id}" \ | ||
--key-name '${{ steps.generate-key-pair.outputs.key_name }}' \ | ||
--associate-public-ip-address \ | ||
--user-data file://user-data.yml \ | ||
--tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=open-balena-tests},{Key=matrix.target,Value=${{ matrix.target }}},{Key=MarketType,Value=${market_type}},{Key=Owner,Value=${{ env.AWS_IAM_USERNAME }}},{Key=GITHUB_SHA,Value=${GITHUB_SHA}-tests},{Key=GITHUB_RUN_ID,Value=${GITHUB_RUN_ID}-tests},{Key=GITHUB_RUN_NUMBER,Value=${GITHUB_RUN_NUMBER}-tests},{Key=GITHUB_RUN_ATTEMPT,Value=${GITHUB_RUN_ATTEMPT}-tests}]" || true)" | ||
[[ -n $response ]] && break | ||
done | ||
[[ -n $response ]] && break | ||
done | ||
[[ -n $response ]] && break | ||
done | ||
[[ -z $response ]] && exit 1 | ||
instance_id="$(echo "${response}" | jq -r '.Instances[].InstanceId')" | ||
echo "instance_id=${instance_id}" >> $GITHUB_OUTPUT | ||
aws ec2 wait instance-running --instance-ids "${instance_id}" | ||
aws ec2 wait instance-status-ok --instance-ids "${instance_id}" | ||
env: | ||
AWS_DEFAULT_REGION: ${{ vars.AWS_REGION || 'us-east-1' }} | ||
COMMIT: ${{ github.event.pull_request.head.sha || github.event.head_commit.id || github.event.pull_request.head.ref }} | ||
- name: SUT&DUT (compose) | ||
if: | | ||
github.event_name == 'pull_request' && | ||
github.event.action != 'closed' && | ||
matrix.target == 'compose-private-pki' | ||
run: | | ||
set -ue | ||
[[ '${{ matrix.verbose }}' =~ on|On|Yes|yes|true|True ]] && set -x | ||
function log_output() { | ||
rm -f "{HOME}/.ssh/config" | ||
aws ssm list-command-invocations \ | ||
--details \ | ||
--output text \ | ||
--command-id "${id}" || true | ||
aws logs describe-log-streams \ | ||
--log-group-name open-balena-tests \ | ||
--log-stream-name-prefix "${id}" || true | ||
aws logs put-retention-policy \ | ||
--log-group-name open-balena-tests \ | ||
--retention-in-days "${{ env.AWS_LOGS_RETENTION }}" || true | ||
} | ||
trap 'log_output' EXIT | ||
# docs/getting-started.md | ||
CMDS="set -ax \ | ||
&& cloud-init status --wait --long && cat </var/log/cloud-init-output.log \ | ||
&& sudo -u balena git clone https://token:${{ steps.ephemeral.outputs.token }}/${{ github.repository }}.git /home/balena/open-balena \ | ||
&& cd /home/balena/open-balena \ | ||
&& sudo -u balena git checkout ${COMMIT} | ||
&& source /root/.env \ | ||
&& sudo -Eu balena make up \ | ||
&& sudo -u balena make self-signed \ | ||
&& sudo -u balena make verify \ | ||
&& sudo -u balena make restart \ | ||
&& sudo -u balena docker compose logs -f sut | ||
# https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-getting-started-enable-ssh-connections.html | ||
cat << EOF > "${HOME}/.ssh/config" | ||
host i-* | ||
StrictHostKeyChecking no | ||
UserKnownHostsFile /dev/null | ||
ProxyCommand sh -c "aws ssm start-session --target %h --document-name AWS-StartSSHSession --parameters 'portNumber=%p'" | ||
EOF | ||
# AWS-RunShellScript runs as root | ||
id="$(aws ssm send-command \ | ||
--instance-ids ${{ steps.launch-ec2-instance.outputs.instance_id }} \ | ||
--document-name AWS-RunShellScript \ | ||
--comment "open-balena-tests@${{ matrix.target }}" \ | ||
--parameters commands=["${CMDS}"] \ | ||
--cloud-watch-output-config '{"CloudWatchLogGroupName":"open-balena-tests","CloudWatchOutputEnabled":true}' | jq -r .Command.CommandId)" | ||
[[ -n $id ]] || false | ||
while [[ $(aws logs describe-log-streams \ | ||
--log-group-name open-balena-tests \ | ||
--log-stream-name-prefix "${id}" | jq -r '.logStreams|length') -le 0 ]]; do | ||
echo '::info::waiting for logs...' | ||
done | ||
# https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#grouping-log-lines | ||
CYAN='\033[0;36m'; NC='\033[0m'; echo -e "::group::${CYAN}${{ env.SLUG }}${NC}" | ||
while [[ "$(aws ssm list-command-invocations --command-id "${id}" \ | ||
| jq -r '.CommandInvocations[].Status')" =~ InProgress ]]; do | ||
docker ps -q | xargs docker logs --timestamps --follow --details \ | ||
|| echo '::info::waiting for logs...' | ||
sleep $(((RANDOM%1) + 1))s | ||
done | ||
echo "::endgroup::" | ||
if ! [[ "$(aws ssm list-command-invocations --command-id "${id}" \ | ||
| jq -r '.CommandInvocations[].Status')" =~ Success ]]; then | ||
false | ||
fi | ||
env: | ||
AWS_DEFAULT_REGION: ${{ vars.AWS_REGION || 'us-east-1' }} | ||
DOCKER_HOST: ssh://ec2-user@${{ steps.launch-ec2-instance.outputs.instance_id }}:22 | ||
COMMIT: ${{ github.event.pull_request.head.sha || github.event.head_commit.id || github.event.pull_request.head.ref }} | ||
- name: remove SSH key | ||
if: always() && matrix.target == 'balena-public-pki' | ||
continue-on-error: true | ||
run: | | ||
set -ue | ||
[[ '${{ matrix.verbose || 'false' }}' =~ on|On|Yes|yes|true|True ]] && set -x | ||
source src/balena-tests/functions | ||
with_backoff balena login --token '${{ secrets.BALENA_API_KEY }}' | ||
with_backoff balena keys | grep ${{ steps.provision-ssh-key.outputs.key_id }} \ | ||
| awk '{print $1}' | xargs balena key rm --yes | ||
- name: destroy balena test device | ||
if: | | ||
github.event_name == 'pull_request' && | ||
github.event.action != 'closed' && | ||
matrix.target == 'balena-public-pki' | ||
continue-on-error: true | ||
run: | | ||
set -ue | ||
[[ '${{ matrix.verbose || 'false' }}' =~ on|On|Yes|yes|true|True ]] && set -x | ||
source src/balena-tests/functions | ||
with_backoff balena login --token '${{ secrets.BALENA_API_KEY }}' | ||
with_backoff balena device rm ${{ steps.register-test-device.outputs.balena_device_uuid }} --yes | ||
env: | ||
AWS_DEFAULT_REGION: ${{ vars.AWS_REGION || 'us-east-1' }} | ||
# always destroy test EC2 instances even if the workflow is cancelled | ||
- name: destroy AWS test device | ||
if: always() | ||
run: | | ||
set -ue | ||
[[ '${{ matrix.verbose || 'false' }}' =~ on|On|Yes|yes|true|True ]] && set -x | ||
source src/balena-tests/functions | ||
if [[ -n '${{ steps.balena-sut.outputs.instance_id }}' ]]; then | ||
with_backoff aws ec2 terminate-instances \ | ||
--instance-ids ${{ steps.balena-sut.outputs.instance_id }} | ||
fi | ||
if [[ -n '${{ steps.ubuntu-sut.outputs.instance_id }}' ]]; then | ||
with_backoff aws ec2 terminate-instances \ | ||
--instance-ids ${{ steps.ubuntu-sut.outputs.instance_id }} | ||
fi | ||
with_backoff aws ec2 describe-instances --filters Name=tag:GITHUB_SHA,Values=${GITHUB_SHA}-tests \ | ||
| jq -r .Reservations[].Instances[].InstanceId \ | ||
| xargs aws ec2 terminate-instances --instance-ids | ||
stale_instances=$(mktemp) | ||
aws ec2 describe-instances --filters \ | ||
Name=tag:Name,Values=open-balena-tests \ | ||
Name=instance-state-name,Values=running \ | ||
| jq -re '.Reservations[].Instances[].InstanceId + " " + .Reservations[].Instances[].LaunchTime' > ${stale_instances} || true | ||
if test -s "${stale_instances}"; then | ||
while IFS= read -r line; do | ||
instance_id=$(echo ${line} | awk '{print $1}') | ||
launch_time=$(echo ${line} | awk '{print $2}') | ||
now=$(date +%s) | ||
then=$(date --date ${launch_time} +%s) | ||
days_since_launch=$(( (now - then) / 86400 )) | ||
if [[ -n $days_since_launch ]] && [[ $days_since_launch -ge 1 ]]; then | ||
with_backoff aws ec2 terminate-instances --instance-ids ${instance_id} | ||
fi | ||
done <${stale_instances} | ||
rm -f ${stale_instances} | ||
fi | ||
env: | ||
AWS_DEFAULT_REGION: ${{ vars.AWS_REGION || 'us-east-1' }} | ||
# remove orphaned ACME DNS-01 validation records | ||
# https://letsencrypt.org/docs/challenge-types/#dns-01-challenge | ||
# FIXME: clean up older _acme-challenge.auto TXT records | ||
- name: cleanup-dns-records | ||
if: always() && matrix.target == 'balena-public-pki' | ||
continue-on-error: true | ||
run: | | ||
set -ue | ||
[[ '${{ matrix.verbose || 'false' }}' =~ on|On|Yes|yes|true|True ]] && set -x | ||
if [[ -n '${{ steps.register-test-device.outputs.balena_device_uuid }}' ]]; then | ||
match="${{ steps.register-test-device.outputs.balena_device_uuid }}.${{ matrix.subdomain }}" | ||
zone_id="$(curl --silent --retry ${{ env.RETRY }} \ | ||
"https://api.cloudflare.com/client/v4/zones?name=${{ matrix.dns_tld }}" \ | ||
-H 'Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}' | jq -r '.result[].id')" | ||
for record in "$(curl --silent --retry ${{ env.RETRY }} \ | ||
"https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records" \ | ||
-H 'Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}' \ | ||
| jq -r --arg match "${match}" '.result[] | select(((.type=="TXT") and (.name | contains($match))))' \ | ||
| base64)"; do | ||
json="$(echo "${record}" | base64 -d | jq -r)" | ||
id="$(echo "${json}" | jq -r .id)" | ||
name="$(echo "${json}" | jq -r .name)" | ||
if [[ -n $id ]] && [[ -n $name ]]; then | ||
echo "::warning::Orphaned DNS record ${name} (${id})..." | ||
if [[ -z $DRY_RUN ]]; then | ||
curl -X DELETE --silent --retry ${{ env.RETRY }} \ | ||
"https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records/${id}" \ | ||
-H 'Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}' | ||
fi | ||
fi | ||
done | ||
fi | ||
env: | ||
DRY_RUN: false |