From 8b48382815b6045abed69b91e556fe4b557595cb Mon Sep 17 00:00:00 2001 From: Joe Kurokawa Date: Fri, 11 Sep 2026 18:46:25 +0000 Subject: [PATCH] Add BATS unit testing to project. --- .github/Dockerfile.al2023-test | 9 +- .github/Dockerfile.debian-sid-test | 2 +- .github/Dockerfile.debian12-test | 2 +- .github/container-tests-al2023.sh | 4 + .github/workflows/shellcheck.yml | 8 +- CONTRIBUTING.md | 14 ++ GNUmakefile | 20 ++- bin/set-hostname-imds.sh | 3 +- bin/setup-policy-routes.sh | 13 +- debian/control | 2 +- debian/rules | 4 +- lib/lib.sh | 34 ++-- tests/bin.bats | 89 +++++++++++ tests/config_generation.bats | 245 +++++++++++++++++++++++++++++ tests/imds.bats | 163 +++++++++++++++++++ tests/install_and_reload.bats | 85 ++++++++++ tests/interface_identity.bats | 151 ++++++++++++++++++ tests/reloader.bats | 78 +++++++++ tests/test_helper.bash | 26 +++ 19 files changed, 916 insertions(+), 36 deletions(-) create mode 100644 tests/bin.bats create mode 100644 tests/config_generation.bats create mode 100644 tests/imds.bats create mode 100644 tests/install_and_reload.bats create mode 100644 tests/interface_identity.bats create mode 100644 tests/reloader.bats create mode 100644 tests/test_helper.bash diff --git a/.github/Dockerfile.al2023-test b/.github/Dockerfile.al2023-test index eaf9bb0..b674261 100644 --- a/.github/Dockerfile.al2023-test +++ b/.github/Dockerfile.al2023-test @@ -1,3 +1,10 @@ FROM public.ecr.aws/amazonlinux/amazonlinux:2023 -RUN dnf -y install make rpm-build openssh-clients +ARG BATS_VERSION=v1.14.0 + +RUN dnf -y install git make rpm-build openssh-clients && \ + git clone --depth 1 --branch "${BATS_VERSION}" \ + https://github.com/bats-core/bats-core.git /tmp/bats && \ + /tmp/bats/install.sh /usr/local && \ + rm -rf /tmp/bats && \ + dnf clean all diff --git a/.github/Dockerfile.debian-sid-test b/.github/Dockerfile.debian-sid-test index 7cb62b4..4121f55 100644 --- a/.github/Dockerfile.debian-sid-test +++ b/.github/Dockerfile.debian-sid-test @@ -2,4 +2,4 @@ FROM public.ecr.aws/debian/debian:sid RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get -y dist-upgrade && \ - DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends build-essential debhelper git devscripts shellcheck + DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends bats build-essential debhelper git devscripts shellcheck diff --git a/.github/Dockerfile.debian12-test b/.github/Dockerfile.debian12-test index 31e37cb..3d60b6f 100644 --- a/.github/Dockerfile.debian12-test +++ b/.github/Dockerfile.debian12-test @@ -1,3 +1,3 @@ FROM public.ecr.aws/debian/debian:12 -RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends build-essential debhelper git devscripts shellcheck +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends bats build-essential debhelper git devscripts shellcheck diff --git a/.github/container-tests-al2023.sh b/.github/container-tests-al2023.sh index 92522f9..a5572d1 100755 --- a/.github/container-tests-al2023.sh +++ b/.github/container-tests-al2023.sh @@ -1,5 +1,9 @@ #!/bin/bash +set -euo pipefail + +make unit-test + v=$(rpmspec -q --qf "%{version}" amazon-ec2-net-utils.spec) make scratch-sources version=${v} mv ../amazon-ec2-net-utils-${v}.tar.gz . diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index 5643acd..c07cb95 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -1,4 +1,4 @@ -name: shellcheck +name: shellcheck and unit tests on: push: @@ -15,6 +15,6 @@ jobs: steps: - uses: actions/checkout@v3 - name: Install dependencies - run: sudo apt-get update && DEBIAN_FRONTEND=noninteractive sudo apt-get -y install shellcheck - - name: Run shellcheck - run: shellcheck -s bash -S warning bin/*.sh lib/*.sh + run: sudo apt-get update && DEBIAN_FRONTEND=noninteractive sudo apt-get -y install bats shellcheck + - name: Run checks + run: make check diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b00b45..2837520 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,6 +47,20 @@ To send us a pull request, please: GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). +## Running tests + +The test suite requires ShellCheck and Bats. Run all checks with: + + make check + +The Bats executable can be supplied explicitly when it is not installed in +`PATH`: + + make unit-test BATS=/path/to/bats + +Unit tests use temporary directories and do not write network configuration +under `/run`. + ## Finding contributions to work on Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. diff --git a/GNUmakefile b/GNUmakefile index 92b0e21..d5b152c 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -12,10 +12,13 @@ SHARE_DIR=${DESTDIR}/${PREFIX}/share/${pkgname} SHELLSCRIPTS=$(wildcard bin/*.sh) SHELLLIBS=$(wildcard lib/*.sh) +SHELLTESTS=$(wildcard tests/*.bash tests/*.bats) UDEVRULES=$(wildcard udev/*.rules) DIRS:=${BINDIR} ${UDEVDIR} ${SYSTEMDDIR} ${SYSTEMD_SYSTEM_DIR} ${SYSTEMD_NETWORK_DIR} ${SHARE_DIR} +BATS?=bats + .PHONY: help help: ## show help @egrep -h '\s##\s' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' @@ -40,12 +43,23 @@ install: ${SHELLSCRIPTS} ${UDEVRULES} ${SHELLLIBS} | ${DIRS} ## Install the soft $(foreach f,$(wildcard systemd/network/*.network),install -m644 $f ${SYSTEMD_NETWORK_DIR};) $(foreach f,$(wildcard systemd/system/*.service systemd/system/*.timer),install -m644 $f ${SYSTEMD_SYSTEM_DIR};) -.PHONY: check -check: ## Run tests - @set -x; for script in ${SHELLSCRIPTS} ${SHELLLIBS}; do \ +.PHONY: lint +lint: ## Run shellcheck + @set -x; for script in ${SHELLSCRIPTS} ${SHELLLIBS} ${SHELLTESTS}; do \ shellcheck --severity warning $${script};\ done +.PHONY: unit-test +unit-test: ## Run Bats unit tests + @command -v ${BATS} >/dev/null 2>&1 || { \ + echo "Bats is required to run unit tests. Set BATS=/path/to/bats if it is not in PATH." >&2; \ + exit 1; \ + } + ${BATS} tests + +.PHONY: check +check: lint unit-test ## Run all checks + .PHONY: scratch-rpm scratch-rpm: source_version_suffix=$(shell git describe --dirty --tags | sed "s,^v${version},,") scratch-rpm: rpm_version_suffix=$(shell git describe --dirty --tags | sed "s,^v${version},,; s,-,.,g") diff --git a/bin/set-hostname-imds.sh b/bin/set-hostname-imds.sh index 5a416c6..04cd156 100755 --- a/bin/set-hostname-imds.sh +++ b/bin/set-hostname-imds.sh @@ -16,10 +16,11 @@ set -eCo pipefail libdir=${LIBDIR_OVERRIDE:-/usr/share/amazon-ec2-net-utils} +hostname_file=${EC2_NET_UTILS_HOSTNAME_FILE_OVERRIDE:-/etc/hostname} . "${libdir}/lib.sh" -if [ -s /etc/hostname ]; then +if [ -s "$hostname_file" ]; then info "Static hostname is already set - not modifying existing hostname" exit 0 fi diff --git a/bin/setup-policy-routes.sh b/bin/setup-policy-routes.sh index 5c4be18..9cb37d0 100755 --- a/bin/setup-policy-routes.sh +++ b/bin/setup-policy-routes.sh @@ -16,10 +16,11 @@ set -eo pipefail -o noclobber -o nounset export unitdir lockdir runtimeroot reload_flag -declare -r runtimeroot="/run/amazon-ec2-net-utils" +declare -r runtimeroot="${EC2_NET_UTILS_RUNTIME_ROOT_OVERRIDE:-/run/amazon-ec2-net-utils}" declare -r lockdir="${runtimeroot}/setup-policy-routes" -declare -r unitdir="/run/systemd/network" +declare -r unitdir="${EC2_NET_UTILS_UNIT_DIR_OVERRIDE:-/run/systemd/network}" declare -r reload_flag="${runtimeroot}/.policy-routes-reload-networkd" +declare -r sys_class_net="${EC2_NET_UTILS_SYS_CLASS_NET_OVERRIDE:-/sys/class/net}" libdir=${LIBDIR_OVERRIDE:-AMAZON_EC2_NET_UTILS_LIBDIR} # shellcheck source=../lib/lib.sh @@ -31,7 +32,7 @@ iface="$1" mkdir -p "$runtimeroot" do_setup() { - ether=$(cat /sys/class/net/${iface}/address) + ether=$(cat "${sys_class_net}/${iface}/address") declare -i changes=0 changes+=$(setup_interface $iface $ether) @@ -43,7 +44,7 @@ do_setup() { case "$2" in refresh) register_networkd_reloader - [ -e "/sys/class/net/${iface}" ] || exit 0 + [ -e "${sys_class_net}/${iface}" ] || exit 0 debug "Starting configuration refresh for $iface" do_setup ;; @@ -51,7 +52,7 @@ start) register_networkd_reloader counter=0 max_wait=6000 # 10 minute timeout to avoid infinite loop if sysfs node never appears - while [ ! -e "/sys/class/net/${iface}" ]; do + while [ ! -e "${sys_class_net}/${iface}" ]; do if ((counter % 1000 == 0)); then debug "Waiting for sysfs node to exist for ${iface} (iteration $counter)" fi @@ -78,7 +79,7 @@ remove) # https://github.com/amazonlinux/amazon-ec2-net-utils/pull/107/changes/c35c4d504fea196af3aa4a00c84b17fa54657d9e). # In addtion, this code also runs during upgrade, only run this when sysfs node is not present. # This means that it's an actual detach rather than a restart. - if [ -e "/sys/class/net/${iface}" ]; then + if [ -e "${sys_class_net}/${iface}" ]; then debug "Link ${iface} still present, skipping configuration removal." exit 0 fi diff --git a/debian/control b/debian/control index 85a6602..ec4008a 100644 --- a/debian/control +++ b/debian/control @@ -2,7 +2,7 @@ Source: amazon-ec2-net-utils Section: net Priority: optional Maintainer: Noah Meyerhans -Build-Depends: debhelper-compat (= 12), shellcheck +Build-Depends: bats, debhelper-compat (= 12), shellcheck Standards-Version: 4.4.1 Homepage: https://github.com/amazonlinux/amazon-ec2-net-utils #Vcs-Browser: https://salsa.debian.org/debian/amazon-ec2-net-utils diff --git a/debian/rules b/debian/rules index 98d9494..0ac6932 100755 --- a/debian/rules +++ b/debian/rules @@ -20,8 +20,10 @@ export PREFIX=/usr override_dh_auto_build: +override_dh_auto_test: + $(MAKE) check + # dh_make generated override targets # This is example for Cmake (See https://bugs.debian.org/641051 ) #override_dh_auto_configure: # dh_auto_configure -- # -DCMAKE_LIBRARY_PATH=$(DEB_HOST_MULTIARCH) - diff --git a/lib/lib.sh b/lib/lib.sh index a794ca0..daf1d1c 100644 --- a/lib/lib.sh +++ b/lib/lib.sh @@ -14,33 +14,33 @@ # permissions and limitations under the License. # These should be set by the calling program -declare ether -declare unitdir -declare lockdir -declare reload_flag -declare runtimeroot +declare -g ether +declare -g unitdir +declare -g lockdir +declare -g reload_flag +declare -g runtimeroot # Version information - substituted during installation -declare PACKAGE_VERSION="AMAZON_EC2_NET_UTILS_VERSION" +declare -g PACKAGE_VERSION="AMAZON_EC2_NET_UTILS_VERSION" if [ -z "$PACKAGE_VERSION" ]; then PACKAGE_VERSION="unknown" fi -declare -r USER_AGENT="amazon-ec2-net-utils/$PACKAGE_VERSION" -declare -r imds_endpoints=("http://169.254.169.254/latest" "http://[fd00:ec2::254]/latest") -declare -r imds_token_path="api/token" -declare -r syslog_facility="user" -declare -r syslog_tag="ec2net" -declare -i -r rule_base=10000 -declare -r default_route="DEFAULT" +declare -g -r USER_AGENT="amazon-ec2-net-utils/$PACKAGE_VERSION" +declare -g -a -r imds_endpoints=("http://169.254.169.254/latest" "http://[fd00:ec2::254]/latest") +declare -g -r imds_token_path="api/token" +declare -g -r syslog_facility="user" +declare -g -r syslog_tag="ec2net" +declare -g -i -r rule_base=10000 +declare -g -r default_route="DEFAULT" # Systemd installs routes with a metric of 1024 by default. We # override to a lower metric to ensure that our fully configured # interfaces are preferred over those in the process of being # configured. -declare -i -r metric_base=512 -declare imds_endpoint="" -declare imds_token="" -declare imds_interface="" +declare -g -i -r metric_base=512 +declare -g imds_endpoint="" +declare -g imds_token="" +declare -g imds_interface="" make_token_request() { local ep=${1:-""} diff --git a/tests/bin.bats b/tests/bin.bats new file mode 100644 index 0000000..06784ae --- /dev/null +++ b/tests/bin.bats @@ -0,0 +1,89 @@ +#!/usr/bin/env bats + +load test_helper + +@test "set-hostname-imds sets an empty static hostname from IMDS" { + local call_log="${BATS_TEST_TMPDIR}/set-hostname-calls.log" + local hostname_file="${BATS_TEST_TMPDIR}/etc/hostname" + local mock_libdir="${BATS_TEST_TMPDIR}/set-hostname-lib" + + mkdir -p "$(dirname "$hostname_file")" "$mock_libdir" + : > "$hostname_file" + cat > "${mock_libdir}/lib.sh" <<'EOF' +get_token() { + printf 'get_token\n' >> "$CALL_LOG" +} +get_imds() { + printf 'get_imds %s\n' "$*" >> "$CALL_LOG" + printf 'ip-10-0-0-5.ec2.internal\n' +} +info() { + printf 'info %s\n' "$*" >> "$CALL_LOG" +} +error() { + printf 'error %s\n' "$*" >> "$CALL_LOG" +} +hostnamectl() { + printf 'hostnamectl %s\n' "$*" >> "$CALL_LOG" +} +EOF + + run env \ + CALL_LOG="$call_log" \ + EC2_NET_UTILS_HOSTNAME_FILE_OVERRIDE="$hostname_file" \ + LIBDIR_OVERRIDE="$mock_libdir" \ + bash "${BATS_TEST_DIRNAME}/../bin/set-hostname-imds.sh" + + [ "$status" -eq 0 ] + grep -Fx "get_token" "$call_log" + grep -Fx "get_imds local-hostname" "$call_log" + grep -Fx \ + "info Setting hostname to ip-10-0-0-5.ec2.internal retrieved from IMDS" \ + "$call_log" + grep -Fx \ + "hostnamectl hostname ip-10-0-0-5.ec2.internal" \ + "$call_log" +} + +@test "setup-policy-routes refresh configures an existing interface" { + local call_log="${BATS_TEST_TMPDIR}/policy-route-calls.log" + local mock_libdir="${BATS_TEST_TMPDIR}/policy-route-lib" + local runtime_root="${BATS_TEST_TMPDIR}/run/amazon-ec2-net-utils" + local sys_class_net="${BATS_TEST_TMPDIR}/sys/class/net" + local unit_dir="${BATS_TEST_TMPDIR}/run/systemd/network" + + mkdir -p "$mock_libdir" "${sys_class_net}/ens6" "$unit_dir" + printf '00:11:22:33:44:55\n' > "${sys_class_net}/ens6/address" + cat > "${mock_libdir}/lib.sh" <<'EOF' +register_networkd_reloader() { + printf 'register_networkd_reloader\n' >> "$CALL_LOG" +} +debug() { + printf 'debug %s\n' "$*" >> "$CALL_LOG" +} +error() { + printf 'error %s\n' "$*" >> "$CALL_LOG" +} +setup_interface() { + printf 'setup_interface %s\n' "$*" >> "$CALL_LOG" + printf '1\n' +} +EOF + + run env \ + CALL_LOG="$call_log" \ + LIBDIR_OVERRIDE="$mock_libdir" \ + EC2_NET_UTILS_RUNTIME_ROOT_OVERRIDE="$runtime_root" \ + EC2_NET_UTILS_SYS_CLASS_NET_OVERRIDE="$sys_class_net" \ + EC2_NET_UTILS_UNIT_DIR_OVERRIDE="$unit_dir" \ + bash "${BATS_TEST_DIRNAME}/../bin/setup-policy-routes.sh" \ + "ens6" "refresh" + + [ "$status" -eq 0 ] + grep -Fx "register_networkd_reloader" "$call_log" + grep -Fx "debug Starting configuration refresh for ens6" "$call_log" + grep -Fx \ + "setup_interface ens6 00:11:22:33:44:55" \ + "$call_log" + [ -e "${runtime_root}/.policy-routes-reload-networkd" ] +} diff --git a/tests/config_generation.bats b/tests/config_generation.bats new file mode 100644 index 0000000..42244bf --- /dev/null +++ b/tests/config_generation.bats @@ -0,0 +1,245 @@ +#!/usr/bin/env bats +# shellcheck disable=SC2034,SC2154 # Globals are provided by test_helper/lib.sh. + +load test_helper + +@test "subnet_supports_ipv4 distinguishes routable and link-local addresses" { + local address + ip() { + printf '2: ens6 inet %s scope global ens6\n' "$address" + } + + address="10.0.0.5/24" + run subnet_supports_ipv4 "ens6" + [ "$status" -eq 0 ] + + address="169.254.10.5/16" + run subnet_supports_ipv4 "ens6" + [ "$status" -eq 1 ] +} + +@test "subnet_supports_ipv6 detects a global IPv6 address" { + ip() { + printf '2: ens6 inet6 2001:db8::5/64 scope global\n' + } + + run subnet_supports_ipv6 "ens6" + + [ "$status" -eq 0 ] +} + +@test "subnet_routes selects the IMDS key for each address family" { + get_and_validate_imds() { + printf '%s|%s\n' "$1" "$2" + } + + run subnet_routes "00:11:22:33:44:55" "ipv4" + [ "$status" -eq 0 ] + [ "$output" = \ + "00:11:22:33:44:55|subnet-ipv4-cidr-block" ] + + run subnet_routes "00:11:22:33:44:55" "ipv6" + [ "$status" -eq 0 ] + [ "$output" = \ + "00:11:22:33:44:55|subnet-ipv6-cidr-blocks" ] +} + +@test "create_ipv4_aliases writes sorted secondary addresses" { + local expected="${BATS_TEST_TMPDIR}/expected" + local config + + subnet_supports_ipv4() { + return 0 + } + get_and_validate_imds() { + printf '%s\n' "10.0.0.5" "10.0.0.20" "10.0.0.10" + } + + run create_ipv4_aliases "ens6" "00:11:22:33:44:55" + + [ "$status" -eq 0 ] + [ "$output" = "1" ] + config=$(find "$unitdir" -name ec2net_alias.conf -print -quit) + [ -n "$config" ] + cat > "$expected" <<'EOF' +[Address] +Address=10.0.0.10/32 +AddPrefixRoute=false +[Address] +Address=10.0.0.20/32 +AddPrefixRoute=false +EOF + diff -u "$expected" "$config" +} + +@test "create_ipv4_aliases removes stale aliases when only the primary remains" { + local config + get_and_validate_imds() { + printf '%s\n' "10.0.0.5" "10.0.0.10" + } + subnet_supports_ipv4() { + return 0 + } + create_ipv4_aliases "ens6" "00:11:22:33:44:55" >/dev/null + config=$(find "$unitdir" -name ec2net_alias.conf -print -quit) + [ -n "$config" ] + printf 'stale alias configuration\n' > "$config" + + get_and_validate_imds() { + printf '10.0.0.5\n' + } + + run create_ipv4_aliases "ens6" "00:11:22:33:44:55" + + [ "$status" -eq 0 ] + [ "$output" = "1" ] + [ ! -e "$config" ] +} + +@test "create_rules writes address and prefix policy rules using the interface table" { + local expected="${BATS_TEST_TMPDIR}/expected" + local config + ether="00:11:22:33:44:55" + + subnet_supports_ipv4() { + return 0 + } + get_and_validate_imds() { + printf '%s\n' "10.0.0.5" "10.0.0.10" + } + get_iface_imds() { + printf '10.0.1.0/24\n' + } + + run create_rules "ens6" 2 1 4 + + [ "$status" -eq 0 ] + [ "$output" = "1" ] + config=$(find "$unitdir" -name ec2net_policy_4.conf -print -quit) + [ -n "$config" ] + cat > "$expected" <<'EOF' +[RoutingPolicyRule] +From=10.0.0.5 +Priority=10102 +Table=10102 +[RoutingPolicyRule] +From=10.0.0.10 +Priority=10102 +Table=10102 +[RoutingPolicyRule] +From=10.0.1.0/24 +Priority=10102 +Table=10102 +EOF + diff -u "$expected" "$config" +} + +@test "create_rules preserves existing policy when required metadata is unavailable" { + local config + ether="00:11:22:33:44:55" + + subnet_supports_ipv4() { + return 0 + } + get_and_validate_imds() { + printf '10.0.0.5\n' + } + get_iface_imds() { + return 0 + } + create_rules "ens6" 2 1 4 >/dev/null + config=$(find "$unitdir" -name ec2net_policy_4.conf -print -quit) + [ -n "$config" ] + printf 'existing policy\n' > "$config" + + get_and_validate_imds() { + return 1 + } + + run create_rules "ens6" 2 1 4 + + [ "$status" -eq 0 ] + [ -z "$output" ] + [ "$(cat "$config")" = "existing policy" ] +} + +@test "create_if_overrides generates dual-stack routes with deterministic IDs" { + local cfgfile="${unitdir}/70-ens6.network" + local config="${cfgfile}.d/eni.conf" + + subnet_supports_ipv4() { + return 0 + } + subnet_supports_ipv6() { + return 0 + } + subnet_routes() { + case "$2" in + ipv4) + printf '10.0.0.0/24\n' + ;; + ipv6) + printf '2001:db8:1::/64\n' + ;; + esac + } + + run create_if_overrides \ + "ens6" 2 1 "00:11:22:33:44:55" "$cfgfile" + + [ "$status" -eq 0 ] + [ "$output" = "1" ] + [ "$(grep -c '^RouteMetric=614$' "$config")" -eq 2 ] + [ "$(grep -c '^Table=10102$' "$config")" -eq 4 ] + grep -Fx "Gateway=_ipv6ra" "$config" + grep -Fx "Gateway=_dhcp4" "$config" + grep -Fx "Destination=2001:db8:1::/64" "$config" + grep -Fx "Destination=10.0.0.0/24" "$config" +} + +@test "add_altnames adds missing ENI and device-number names" { + local ip_log="${BATS_TEST_TMPDIR}/ip.log" + get_iface_imds() { + printf 'eni-0123456789abcdef0\n' + } + ip() { + printf '%s\n' "$*" >> "$ip_log" + if [ "$1 $2" = "link show" ]; then + return 1 + fi + return 0 + } + + run add_altnames "ens6" "00:11:22:33:44:55" 2 1 + + [ "$status" -eq 0 ] + grep -Fx \ + "link property add dev ens6 altname eni-0123456789abcdef0" \ + "$ip_log" + grep -Fx \ + "link property add dev ens6 altname device-number-2.1" \ + "$ip_log" +} + +@test "create_interface_config links the base config and invokes its generators" { + local calls="${BATS_TEST_TMPDIR}/calls.log" + local config + create_if_overrides() { + printf 'create_if_overrides %s\n' "$*" >> "$calls" + printf '1\n' + } + add_altnames() { + printf 'add_altnames %s\n' "$*" >> "$calls" + } + + run create_interface_config "ens6" 2 1 "00:11:22:33:44:55" + + [ "$status" -eq 0 ] + [ "$output" = "1" ] + config=$(find "$unitdir" -maxdepth 1 -type l -name '*-ens6.network' -print -quit) + [ -n "$config" ] + [ -L "$config" ] + [ "$(readlink "$config")" = "/usr/lib/systemd/network/80-ec2.network" ] + grep -F "create_if_overrides ens6 2 1 00:11:22:33:44:55" "$calls" + grep -F "add_altnames ens6 00:11:22:33:44:55 2 1" "$calls" +} diff --git a/tests/imds.bats b/tests/imds.bats new file mode 100644 index 0000000..1d93802 --- /dev/null +++ b/tests/imds.bats @@ -0,0 +1,163 @@ +#!/usr/bin/env bats +# shellcheck disable=SC2154 # Globals are provided by test_helper/lib.sh. + +load test_helper + +@test "get_lowest_secondary_interface returns the second predictable interface" { + basename() { + printf '%s\n' "lo" "ens5" "ens6" + } + + run get_lowest_secondary_interface + + [ "$status" -eq 0 ] + [ "$output" = "ens6" ] +} + +@test "make_token_request passes the interface and token request options to curl" { + local curl_log="${BATS_TEST_TMPDIR}/curl.log" + curl() { + printf '%s\n' "$@" > "$curl_log" + printf 'test-token\n' + } + + run make_token_request "http://169.254.169.254/latest" "ens6" + + [ "$status" -eq 0 ] + [ "$output" = "test-token" ] + grep -Fx -- "--interface" "$curl_log" + grep -Fx -- "ens6" "$curl_log" + grep -Fx -- "X-aws-ec2-metadata-token-ttl-seconds: 60" "$curl_log" + grep -Fx -- "http://169.254.169.254/latest/api/token" "$curl_log" +} + +@test "get_meta returns metadata and sends authentication options to curl" { + local curl_log="${BATS_TEST_TMPDIR}/curl.log" + imds_endpoint="http://169.254.169.254/latest" + imds_token="test-token" + imds_interface="ens6" + + curl() { + printf '%s\n' "$@" > "$curl_log" + printf '10.0.0.42\n' + } + + run get_meta "local-ipv4" + + [ "$status" -eq 0 ] + [ "$output" = "10.0.0.42" ] + grep -Fx -- "--interface" "$curl_log" + grep -Fx -- "ens6" "$curl_log" + grep -Fx -- "X-aws-ec2-metadata-token:test-token" "$curl_log" + grep -Fx -- "http://169.254.169.254/latest/meta-data/local-ipv4" "$curl_log" +} + +@test "get_iface_imds builds the interface metadata path" { + local request_log="${BATS_TEST_TMPDIR}/request.log" + get_meta() { + printf '%s|%s\n' "$1" "$2" > "$request_log" + printf '10.0.0.0/24\n' + } + + run get_iface_imds \ + "00:11:22:33:44:55" "subnet-ipv4-cidr-block" 3 + + [ "$status" -eq 0 ] + [ "$output" = "10.0.0.0/24" ] + [ "$(cat "$request_log")" = \ + "network/interfaces/macs/00:11:22:33:44:55/subnet-ipv4-cidr-block|3" ] +} + +@test "info logs with info priority" { + local logger_log="${BATS_TEST_TMPDIR}/logger.log" + logger() { + printf '%s\n' "$@" > "$logger_log" + } + + run info "interface configured" + + [ "$status" -eq 0 ] + grep -Fx -- "--priority" "$logger_log" + grep -Fx -- "user.info" "$logger_log" + grep -Fx -- "--tag" "$logger_log" + grep -Fx -- "ec2net" "$logger_log" + grep -Fx -- "interface configured" "$logger_log" +} + +@test "get_meta fails before calling curl when token state is incomplete" { + local curl_marker="${BATS_TEST_TMPDIR}/curl-called" + local error_log="${BATS_TEST_TMPDIR}/error.log" + imds_endpoint="" + imds_token="" + imds_interface="" + + curl() { + touch "$curl_marker" + } + error() { + printf '%s\n' "$*" >> "$error_log" + } + + run get_meta "local-ipv4" + + [ "$status" -eq 1 ] + [ ! -e "$curl_marker" ] + grep -F "Unable to obtain IMDS token" "$error_log" +} + +@test "get_meta retries curl up to the requested attempt count" { + local curl_log="${BATS_TEST_TMPDIR}/curl.log" + imds_endpoint="http://169.254.169.254/latest" + imds_token="test-token" + imds_interface="$default_route" + + curl() { + printf 'call\n' >> "$curl_log" + return 22 + } + + run get_meta "local-hostname" 3 + + [ "$status" -eq 1 ] + [ "$(wc -l < "$curl_log")" -eq 3 ] +} + +@test "get_and_validate_imds rejects an empty metadata response" { + local error_log="${BATS_TEST_TMPDIR}/error.log" + get_iface_imds() { + return 0 + } + error() { + printf '%s\n' "$*" >> "$error_log" + } + + run get_and_validate_imds "00:11:22:33:44:55" "local-ipv4s" + + [ "$status" -eq 1 ] + [ -z "$output" ] + grep -F "local-ipv4s returned empty" "$error_log" +} + +@test "get_token falls back from the requested interface to the default route" { + local request_log="${BATS_TEST_TMPDIR}/requests.log" + date() { + if [ "$1" = "-d" ]; then + printf '100\n' + else + printf '99\n' + fi + } + make_token_request() { + printf '%s|%s\n' "$1" "${2:-}" >> "$request_log" + if [ -z "${2:-}" ]; then + printf 'test-token\n' + fi + } + + get_token "ens6" + + [ "$imds_token" = "test-token" ] + [ "$imds_endpoint" = "http://169.254.169.254/latest" ] + [ "$imds_interface" = "$default_route" ] + [ "$(wc -l < "$request_log")" -eq 2 ] +} diff --git a/tests/install_and_reload.bats b/tests/install_and_reload.bats new file mode 100644 index 0000000..3c090b3 --- /dev/null +++ b/tests/install_and_reload.bats @@ -0,0 +1,85 @@ +#!/usr/bin/env bats + +load test_helper + +@test "_install_and_reload installs a new non-empty file" { + local src="${BATS_TEST_TMPDIR}/config.new" + local dest="${BATS_TEST_TMPDIR}/config" + printf 'new configuration\n' > "$src" + + run _install_and_reload "$src" "$dest" + + [ "$status" -eq 0 ] + [ "$output" = "1" ] + [ ! -e "$src" ] + [ "$(cat "$dest")" = "new configuration" ] +} + +@test "_install_and_reload discards an unchanged work file" { + local src="${BATS_TEST_TMPDIR}/config.new" + local dest="${BATS_TEST_TMPDIR}/config" + printf 'same configuration\n' > "$src" + printf 'same configuration\n' > "$dest" + + run _install_and_reload "$src" "$dest" + + [ "$status" -eq 0 ] + [ "$output" = "0" ] + [ ! -e "$src" ] + [ "$(cat "$dest")" = "same configuration" ] +} + +@test "_install_and_reload replaces a changed destination" { + local src="${BATS_TEST_TMPDIR}/config.new" + local dest="${BATS_TEST_TMPDIR}/config" + printf 'new configuration\n' > "$src" + printf 'old configuration\n' > "$dest" + + run _install_and_reload "$src" "$dest" + + [ "$status" -eq 0 ] + [ "$output" = "1" ] + [ ! -e "$src" ] + [ "$(cat "$dest")" = "new configuration" ] +} + +@test "_install_and_reload preserves a destination when empty overwrite is disabled" { + local src="${BATS_TEST_TMPDIR}/config.new" + local dest="${BATS_TEST_TMPDIR}/config" + : > "$src" + printf 'existing configuration\n' > "$dest" + + run _install_and_reload "$src" "$dest" false + + [ "$status" -eq 0 ] + [ "$output" = "0" ] + [ ! -e "$src" ] + [ "$(cat "$dest")" = "existing configuration" ] +} + +@test "_install_and_reload removes a destination when empty overwrite is enabled" { + local src="${BATS_TEST_TMPDIR}/config.new" + local dest="${BATS_TEST_TMPDIR}/config" + : > "$src" + printf 'existing configuration\n' > "$dest" + + run _install_and_reload "$src" "$dest" true + + [ "$status" -eq 0 ] + [ "$output" = "1" ] + [ ! -e "$src" ] + [ ! -e "$dest" ] +} + +@test "_install_and_reload ignores a new empty file" { + local src="${BATS_TEST_TMPDIR}/config.new" + local dest="${BATS_TEST_TMPDIR}/config" + : > "$src" + + run _install_and_reload "$src" "$dest" + + [ "$status" -eq 0 ] + [ "$output" = "0" ] + [ ! -e "$src" ] + [ ! -e "$dest" ] +} diff --git a/tests/interface_identity.bats b/tests/interface_identity.bats new file mode 100644 index 0000000..af8aed9 --- /dev/null +++ b/tests/interface_identity.bats @@ -0,0 +1,151 @@ +#!/usr/bin/env bats +# shellcheck disable=SC2154 # Globals are provided by test_helper/lib.sh. + +load test_helper + +@test "_is_primary_interface compares against the top-level IMDS MAC" { + get_imds() { + printf '00:11:22:33:44:55\n' + } + + run _is_primary_interface "00:11:22:33:44:55" + [ "$status" -eq 0 ] + + run _is_primary_interface "00:11:22:33:44:66" + [ "$status" -eq 1 ] +} + +@test "_get_device_number returns zero immediately for the primary interface" { + local request_marker="${BATS_TEST_TMPDIR}/requested" + _is_primary_interface() { + return 0 + } + get_iface_imds() { + touch "$request_marker" + printf '9\n' + } + + run _get_device_number "ens5" "00:11:22:33:44:55" 0 + + [ "$status" -eq 0 ] + [ "$output" = "0" ] + [ ! -e "$request_marker" ] +} + +@test "_get_device_number retries an unpropagated zero value" { + local request_log="${BATS_TEST_TMPDIR}/requests.log" + _is_primary_interface() { + return 1 + } + sleep() { + : + } + get_iface_imds() { + printf 'call\n' >> "$request_log" + if [ "$(wc -l < "$request_log")" -lt 3 ]; then + printf '0\n' + else + printf '2\n' + fi + } + + run _get_device_number "ens6" "00:11:22:33:44:66" 0 + + [ "$status" -eq 0 ] + [ "$output" = "2" ] + [ "$(wc -l < "$request_log")" -eq 3 ] +} + +@test "_get_network_card records support after a propagated value appears" { + local request_log="${BATS_TEST_TMPDIR}/requests.log" + _is_primary_interface() { + return 1 + } + sleep() { + : + } + get_iface_imds() { + printf 'call\n' >> "$request_log" + if [ "$(wc -l < "$request_log")" -ge 2 ]; then + printf '1\n' + fi + } + + run _get_network_card "ens6" "00:11:22:33:44:66" + + [ "$status" -eq 0 ] + [ "$output" = "1" ] + [ "$(wc -l < "$request_log")" -eq 2 ] + [ -e "${runtimeroot}/.has-network-card" ] + [ ! -e "${runtimeroot}/.no-network-card" ] +} + +@test "_get_network_card records lack of support after exhausting retries" { + local request_log="${BATS_TEST_TMPDIR}/requests.log" + _is_primary_interface() { + return 1 + } + sleep() { + : + } + get_iface_imds() { + printf 'call\n' >> "$request_log" + } + + run _get_network_card "ens6" "00:11:22:33:44:66" + + [ "$status" -eq 0 ] + [ "$output" = "0" ] + [ "$(wc -l < "$request_log")" -eq 8 ] + [ -e "${runtimeroot}/.no-network-card" ] + [ ! -e "${runtimeroot}/.has-network-card" ] +} + +@test "setup_interface configures both policy families on a secondary interface" { + local calls="${BATS_TEST_TMPDIR}/calls.log" + get_token() { + printf 'get_token %s\n' "$*" >> "$calls" + } + _get_network_card() { + printf '1\n' + } + _get_device_number() { + printf '2\n' + } + create_interface_config() { + printf 'create_interface_config %s\n' "$*" >> "$calls" + printf '1\n' + } + _is_primary_interface() { + return 1 + } + create_rules() { + printf 'create_rules %s\n' "$*" >> "$calls" + printf '0\n' + } + create_ipv4_aliases() { + printf 'create_ipv4_aliases %s\n' "$*" >> "$calls" + printf '0\n' + } + date() { + if [ "$1" = "-d" ]; then + printf '100\n' + else + printf '99\n' + fi + } + + run setup_interface "ens6" "00:11:22:33:44:66" + + [ "$status" -eq 0 ] + [ "$output" = "1" ] + grep -Fx "get_token ens6" "$calls" + grep -Fx \ + "create_interface_config ens6 2 1 00:11:22:33:44:66" \ + "$calls" + grep -Fx "create_rules ens6 2 1 4" "$calls" + grep -Fx "create_rules ens6 2 1 6" "$calls" + grep -Fx \ + "create_ipv4_aliases ens6 00:11:22:33:44:66" \ + "$calls" +} diff --git a/tests/reloader.bats b/tests/reloader.bats new file mode 100644 index 0000000..a270362 --- /dev/null +++ b/tests/reloader.bats @@ -0,0 +1,78 @@ +#!/usr/bin/env bats +# shellcheck disable=SC2034,SC2154 # Globals are provided by test_helper/lib.sh. + +load test_helper + +@test "maybe_reload_networkd reloads after the final process exits" { + local networkctl_log="${BATS_TEST_TMPDIR}/networkctl.log" + iface="ens6" + mkdir -p "$lockdir" + printf '%s\n' "$$" > "${lockdir}/${iface}" + touch "$reload_flag" + + networkctl() { + printf '%s\n' "$*" > "$networkctl_log" + } + + run maybe_reload_networkd + + [ "$status" -eq 0 ] + [ ! -e "$lockdir" ] + [ ! -e "$reload_flag" ] + [ "$(cat "$networkctl_log")" = "reload" ] +} + +@test "maybe_reload_networkd defers while another process is registered" { + local debug_log="${BATS_TEST_TMPDIR}/debug.log" + local networkctl_marker="${BATS_TEST_TMPDIR}/networkctl-called" + iface="ens6" + mkdir -p "$lockdir" + printf '%s\n' "$$" > "${lockdir}/${iface}" + printf '12345\n' > "${lockdir}/ens7" + touch "$reload_flag" + + networkctl() { + touch "$networkctl_marker" + } + debug() { + printf '%s\n' "$*" > "$debug_log" + } + + run maybe_reload_networkd + + [ "$status" -eq 0 ] + [ -e "${lockdir}/ens7" ] + [ -e "$reload_flag" ] + [ ! -e "$networkctl_marker" ] + grep -F "Deferring networkd reload to another process" "$debug_log" +} + +# AmiTest source: ec2-net-utils-infinite-loop-fix +@test "register_networkd_reloader replaces a stale lock from a dead process" { + local debug_log="${BATS_TEST_TMPDIR}/debug.log" + local lockfile + iface="ens6" + lockfile="${lockdir}/${iface}" + mkdir -p "$lockdir" + printf '99999\n' > "$lockfile" + + kill() { + if [ "$1" = "-0" ] && [ "$2" = "99999" ]; then + return 1 + fi + return 0 + } + debug() { + printf '%s\n' "$*" >> "$debug_log" + } + maybe_reload_networkd() { + : + } + + run register_networkd_reloader + + [ "$status" -eq 0 ] + grep -F "Removing stale lock from dead process 99999 for ens6" "$debug_log" + [ -f "$lockfile" ] + [ "$(cat "$lockfile")" != "99999" ] +} diff --git a/tests/test_helper.bash b/tests/test_helper.bash new file mode 100644 index 0000000..6408249 --- /dev/null +++ b/tests/test_helper.bash @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2034 # Globals are consumed by sourced functions/tests. + +setup() { + # shellcheck source=../lib/lib.sh + source "${BATS_TEST_DIRNAME}/../lib/lib.sh" + + unitdir="${BATS_TEST_TMPDIR}/run/systemd/network" + runtimeroot="${BATS_TEST_TMPDIR}/run/amazon-ec2-net-utils" + lockdir="${runtimeroot}/setup-policy-routes" + reload_flag="${runtimeroot}/.policy-routes-reload-networkd" + + imds_endpoint="" + imds_token="" + imds_interface="" + ether="" + + mkdir -p "$unitdir" "$runtimeroot" + unset EC2_IF_INITIAL_SETUP + + # Keep test output free of syslog traffic by default. Individual tests can + # override error(), debug(), or logger() when logging is under test. + logger() { + : + } +}