diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..29f1e44 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +name: test + +on: + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + # bats lives in test/lib as a submodule; lint itself does not need it, but + # checking out submodules keeps every job consistent and cheap. + - uses: actions/checkout@v7 + with: + submodules: recursive + - name: Install shellcheck + run: sudo apt-get update && sudo apt-get install -y shellcheck + - name: Lint + run: make lint + + unit-smoke: + runs-on: ubuntu-latest + steps: + # REQUIRED: bats-core/support/assert are git submodules under test/lib. + # Without submodules: recursive the runner binary is absent and the suite + # cannot start. + - uses: actions/checkout@v7 + with: + submodules: recursive + # ubuntu-latest ships GNU bash 5 as the default shell, which is all the + # unit + smoke tiers require. No systemd, no docker, no bats install + # (it is vendored). + - name: Unit + smoke tiers + run: make test-unit test-smoke diff --git a/.gitignore b/.gitignore index e153401..fd05e0f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,8 @@ eggs/ .eggs/ lib/ lib64/ +# but not the vendored bats-core test framework (submodules) +!test/lib/ parts/ sdist/ var/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..fc9a5f0 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "test/lib/bats-core"] + path = test/lib/bats-core + url = https://github.com/bats-core/bats-core.git +[submodule "test/lib/bats-support"] + path = test/lib/bats-support + url = https://github.com/bats-core/bats-support.git +[submodule "test/lib/bats-assert"] + path = test/lib/bats-assert + url = https://github.com/bats-core/bats-assert.git diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..7074884 --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,10 @@ +# Shared shellcheck config for main.sh and the test suite. +# Picked up automatically by `make lint`, CI, and editor integrations. + +# Modern Bash idioms — keep the codebase on the current conventions: +enable=require-double-brackets # prefer [[ ]] over [ ] (SC2292) +enable=deprecate-which # prefer `command -v` over `which` + +# `data` (api_call) and `SSM_BIN_NAME` (detect_ssm_service) are globals set for +# other functions / external readers, not unused assignments. +disable=SC2034 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7e5c748 --- /dev/null +++ b/Makefile @@ -0,0 +1,35 @@ +# main.sh test harness — see test/README.md for the contract. +# bats-core and its helper libs are vendored as git submodules under test/lib/; +# nothing is installed at runtime. Run `git submodule update --init --recursive` +# after a fresh clone. + +BATS := ./test/lib/bats-core/bin/bats +TIERS := test/unit test/smoke + +.DEFAULT_GOAL := help + +.PHONY: help test test-unit test-smoke lint + +help: ## List the available targets + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "} {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' + +test: ## Run every tier (unit + smoke), scoped to the tier dirs + $(BATS) --recursive $(TIERS) + +test-unit: ## Run the unit tier (pure functions, no I/O) + $(BATS) --recursive test/unit + +test-smoke: ## Run the smoke tier (subprocess CLI-contract) + $(BATS) --recursive test/smoke + +SHELL_SOURCES := main.sh test/mocks/bin/* test/helpers/*.bash + +lint: ## shellcheck correctness + modern-idiom gate (config in .shellcheckrc) + # General correctness (warnings and up); SC2034 is disabled in .shellcheckrc. + shellcheck --severity=warning main.sh + shellcheck test/mocks/bin/* + shellcheck test/helpers/*.bash + # Idiom gate: enforce [[ ]] over [ ] (SC2292) and command substitution over + # backticks (SC2006) across every shell source, regardless of severity. + shellcheck --include=SC2292,SC2006 $(SHELL_SOURCES) diff --git a/README.md b/README.md index f2b2243..73d03ad 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,14 @@ - [3.0 Setup](#30-setup) - [3.1 Environment](#31-environment) - [3.2 Registration](#32-registration) - - [3.2.1 Get credentials from StackGuardian](#331-get-credentials-from-stackguardian) - - [3.2.2 Run the script for registration](#332-run-the-script-for-registration) - - [3.3 De-registration](#34-de-registration) + - [3.2.1 Get credentials from StackGuardian](#321-get-credentials-from-stackguardian) + - [3.2.2 Run the script for registration](#322-run-the-script-for-registration) + - [3.3 De-registration](#33-de-registration) - [3.4 Restart](#34-restart) -- [Other options](#other-options) +- [System diagnostics](#system-diagnostics) +- [Managing `cgroupsv2`](#managing-cgroupsv2) +- [Troubleshooting](#troubleshooting) +- [Development](#development) ## 1.0 Introduction @@ -20,7 +23,7 @@ registering external (self-hosted) instances to the StackGuardian platform. Configuration is very simple, get credentials from StackGuardian platform, and run `main.sh` script with credentials. -Check [Setup](#setup) for more details. +Check [Setup](#30-setup) for more details. ## 2.0 How it works @@ -41,7 +44,7 @@ Only, _task definition_ will live on _AWS ECS_. Setup is very simple. We tried to make it as automated as possible. All you have to do is run `main.sh` with wanted option that you want to execute: -[Registration](#registration) or [De-registration](#de-registration), and +[Registration](#32-registration) or [De-registration](#33-de-registration), and provided credentials from _StackGuardian_ platform. > For more details the `main.sh` script has integrated _help_ menu: @@ -114,12 +117,12 @@ But, to achieve similar experience it is enough to [`deregister`](#33-de-registr We included 2 commands for easier system diagnostics and management. These should help you keep your system clean and debug in case of errors. -> INFO: Any of following actions keep state in a file at `/tmp/diagnostic.json`. +> INFO: Any of following actions keep state in a file at `/var/lib/sg-runner/diagnostic.json`. With any command you can provide `--debug` flag. With this, you will get more output while running commands. -> INFO: All logs are being kept at `/tmp/sg_runner.log`. +> INFO: All logs are being kept at `/var/log/sg_runner.log`. ### Health check @@ -158,7 +161,7 @@ export CGROUPSV2_PREVIEW=true and then ``` -./main.sh cgropusv2 disable +./main.sh cgroupsv2 disable ``` > Reboot is required after such action. @@ -174,7 +177,23 @@ To revert you can just run: - StackGuardian uses AWS SSM to setup connection between SG control plane and runners, you can diagnose SSM client using `ssm-cli get-diagnostics --output table` - If the registration was successful but you can't see Ping Status and IP Address for the Runner on StackGuardian Platform inside the Runner Group's -> Runner Instances tab please re-register runner using the following command: - ```bash - ./main.sh deregister --sg-node-token "TOKEN" --organization "ORG" --runner-group "RUNNER_GROUP" && \ - ./main.sh register --sg-node-token "TOKEN" --organization "ORG" --runner-group "RUNNER_GROUP" - ``` + ```bash + ./main.sh deregister --sg-node-token "TOKEN" --organization "ORG" --runner-group "RUNNER_GROUP" && \ + ./main.sh register --sg-node-token "TOKEN" --organization "ORG" --runner-group "RUNNER_GROUP" + ``` + +## Development + +`main.sh` has an automated test suite (unit + smoke) built with +[bats-core](https://github.com/bats-core/bats-core), vendored under `test/lib/` +as git submodules — nothing is downloaded at runtime. + +```sh +git submodule update --init --recursive # once, after a fresh clone +make test # run the unit + smoke tiers +make lint # shellcheck: correctness + modern-idiom gate +``` + +See [`test/README.md`](test/README.md) for the test harness contract, the mock +framework, and how to add tests. Every pull request against `main` runs `lint` +and the test suite in CI. diff --git a/main.sh b/main.sh index 7719288..0b73d05 100755 --- a/main.sh +++ b/main.sh @@ -16,7 +16,8 @@ NO_PROXY="169.254.169.254,169.254.170.2,/var/run/docker.sock" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly SCRIPT_DIR -readonly LOG_FILE="/var/log/sg_runner.log" +LOG_FILE="${LOG_FILE:=/var/log/sg_runner.log}" +readonly LOG_FILE # static # readonly COMMANDS=("jq" "crontab") @@ -24,8 +25,16 @@ readonly COMMANDS=("jq") readonly CONTAINER_ORCHESTRATORS=("docker") readonly SG_DOCKER_NETWORK="sg-net" +# Filesystem locations (overridable for testing; default to production paths) +ECS_CONFIG_DIR="${ECS_CONFIG_DIR:=/etc/ecs}" +ECS_LOG_DIR="${ECS_LOG_DIR:=/var/log/ecs}" +ECS_DATA_DIR="${ECS_DATA_DIR:=/var/lib/ecs/data}" +REGISTRATION_DIR="${REGISTRATION_DIR:=/var/log/registration}" +readonly ECS_CONFIG_DIR ECS_LOG_DIR ECS_DATA_DIR REGISTRATION_DIR + # diagnostics -readonly SG_DIAGNOSTIC_DIR="/var/lib/sg-runner" +SG_DIAGNOSTIC_DIR="${SG_DIAGNOSTIC_DIR:=/var/lib/sg-runner}" +readonly SG_DIAGNOSTIC_DIR readonly SG_DIAGNOSTIC_FILE="${SG_DIAGNOSTIC_DIR}/diagnostic.json" readonly SG_DIAGNOSTIC_TMP_FILE="${SG_DIAGNOSTIC_DIR}/diagnostic.json.tmp" @@ -247,10 +256,10 @@ print_details() { #{{{ #}}}: print_details save_registration_details() { #{{{ - mkdir -p /var/log/registration + mkdir -p "$REGISTRATION_DIR" print_details print_details | sed 's/\x1B\[[0-9;]*[JKmsu]//g' \ - >>/var/log/registration/"registration_details_$(date +'%Y-%m-%dT%H-%M-%S%z').txt" + >>"$REGISTRATION_DIR/registration_details_$(date +'%Y-%m-%dT%H-%M-%S%z').txt" } #}}}: save_registration_details @@ -309,7 +318,7 @@ EOF if [[ -z "$status_code" ]]; then err "Unknown status code." exit 1 - elif [ "$status_code" != "200" ] && [ "$status_code" != "201" ] && [ "$status_code" != "100" ]; then + elif [[ "$status_code" != "200" && "$status_code" != "201" && "$status_code" != "100" ]]; then return 1 else return 0 @@ -613,8 +622,8 @@ iptables_ensure() { #{{{ #}}}: iptables_ensure configure_local_data() { #{{{ - mkdir -p /var/log/ecs /etc/ecs /var/lib/ecs/data /var/log/registration/ - rm -rf /etc/ecs/ecs.config >/dev/null + mkdir -p "$ECS_LOG_DIR" "$ECS_CONFIG_DIR" "$ECS_DATA_DIR" "$REGISTRATION_DIR" + rm -rf "$ECS_CONFIG_DIR/ecs.config" >/dev/null spinner_wait "Configuring local data.." @@ -626,7 +635,7 @@ configure_local_data() { #{{{ ECS_INSTANCE_ATTRIBUTES="{\"sg_organization\": \"${ORGANIZATION_NAME}\",\"sg_runner_id\": \"${RUNNER_ID}\", \"sg_runner_group_id\": \"${RUNNER_GROUP_ID}\"}" fi - cat >/etc/ecs/ecs.config <"$ECS_CONFIG_DIR/ecs.config" <>/etc/ecs/ecs.config <>"$ECS_CONFIG_DIR/ecs.config" </dev/null) - if [ -n "$imdsv2_token" ]; then + if [[ -n "$imdsv2_token" ]]; then attached_iam_role=$(curl --max-time 10 -fSsLk --proto "https" -H "X-aws-ec2-metadata-token: $imdsv2_token" "http://169.254.169.254/latest/meta-data/iam/security-credentials/" 2>/dev/null) else attached_iam_role=$(curl --max-time 10 -fSsLk "http://169.254.169.254/latest/meta-data/iam/security-credentials/" 2>/dev/null) fi - if [ -n "$attached_iam_role" ]; then + if [[ -n "$attached_iam_role" ]]; then debug "Response:" "$attached_iam_role" info "Found an IAM role attached to the instance" "$attached_iam_role" fi @@ -1248,7 +1259,11 @@ main() { #{{{ #}}}: Preflight + main -trap cleanup SIGINT -trap exit_help EXIT +# Only wire traps and run main when executed directly. When sourced (e.g. by a +# test harness), expose the functions without side effects. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + trap cleanup SIGINT + trap exit_help EXIT -main "$@" + main "$@" +fi diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..ea414c1 --- /dev/null +++ b/test/README.md @@ -0,0 +1,182 @@ +# `main.sh` test suite + +A [bats-core](https://github.com/bats-core/bats-core) suite for `main.sh`, the +StackGuardian private-runner registration CLI. bats-core and its helper +libraries are vendored under `test/lib/` — nothing is downloaded at runtime. + +## Layout + +``` +test/ + lib/ vendored bats-core, bats-support, bats-assert (do not edit) + helpers/ + load.bash single entry point every test sources + mocks.bash assertions over the mock call log + mocks/bin/ stub executables shadowing every external command + unit/ pure-function tests (one function, no I/O) + smoke/ CLI-contract tests (main.sh run as a subprocess) + fixtures/ canned input files +``` + +## The seam contract + +`main.sh` is built to be sourced safely: + +- **Side-effect-free sourcing.** `main.sh` only runs `main "$@"` under + `[[ "${BASH_SOURCE[0]}" == "${0}" ]]`. `source main.sh` therefore defines all + functions and runs nothing — tests call functions directly. +- **Path overrides via env.** Every path it writes to is overridable: + `LOG_FILE`, `SG_DIAGNOSTIC_DIR` (`SG_DIAGNOSTIC_FILE` / + `SG_DIAGNOSTIC_TMP_FILE` derive from it), `ECS_CONFIG_DIR`, `ECS_LOG_DIR`, + `ECS_DATA_DIR`, `REGISTRATION_DIR`. `load.bash` points all of these at a + per-test temp dir, so a test never touches real system paths. +- **Root bypass.** `SG_SKIP_ROOT_CHECK=true` makes `is_root()` succeed without + root. `load.bash` sets it. +- **API + debug.** `SG_BASE_API` overrides the API base; `LOG_DEBUG=true` + enables debug logging. +- **External commands are stubbed.** `load.bash` prepends `test/mocks/bin` to + `PATH`, so every `systemctl`, `docker`, `curl`, ... resolves to a stub that + records its call instead of touching the host. + +## Running + +bats-core and its helper libs are **git submodules** under `test/lib/`. After a +fresh clone: + +```sh +git submodule update --init --recursive +``` + +The `Makefile` at the repo root wraps the canonical commands: + +```sh +make test # every tier (unit + smoke) +make test-unit # one tier +make test-smoke +make lint # shellcheck: correctness + modern-idiom gate +``` + +Or invoke the vendored runner directly from the repo root: + +```sh +BATS=./test/lib/bats-core/bin/bats + +# one file +$BATS test/unit/harness.bats + +# a whole tier +$BATS --recursive test/unit +$BATS --recursive test/smoke + +# everything — scope to the tier dirs, NOT `--recursive test/` +$BATS --recursive test/unit test/smoke +``` + +Scope to the tier dirs. `--recursive test/` descends into the vendored +libraries' own bats suites under `test/lib/` and errors out. + +Requires GNU bash 5+ first on `PATH` (Homebrew `/opt/homebrew/bin/bash` on +macOS). The vendored `bats` runs under `/usr/bin/env bash`. + +## Linting & conventions + +`make lint` (also a CI job) runs `shellcheck` two ways over `main.sh`, the mock +stubs, and the bash helpers: + +1. **Correctness** — the default warning-and-up checks. +2. **Modern-idiom gate** — enforces current Bash conventions regardless of + severity: `[[ ]]` over `[ ]` (SC2292) and `$(...)` over backticks (SC2006). + +Rules live in `.shellcheckrc` at the repo root (`enable=require-double-brackets`, +`enable=deprecate-which`, and `disable=SC2034` for the intentionally-exported +globals), so editors and CI apply the same conventions. `.bats` files are not +shellchecked — their `@test` syntax isn't valid standalone Bash. + +## Writing a test + +Every test file starts the same way: + +```bash +setup() { + load "${BATS_TEST_DIRNAME}/../helpers/load.bash" +} + +@test "what it does" { + # 1. tweak any MOCK_* / SG_* env vars for this case (optional) + export MOCK_SYSTEMCTL_IS_ACTIVE="inactive" + + # 2. source main.sh AFTER the env tweaks + load_main + + # 3. call the function. Use `run` for functions that may call die/exit. + run some_function arg1 arg2 + + # 4. assert on $status / $output and on recorded mock calls + assert_success + assert_called systemctl "is-active" +} +``` + +Order matters: set env, then `load_main`, then exercise. Use `run` whenever the +function under test may call `die` (which exits) so the non-zero exit is +captured instead of aborting the test. + +## Mocks + +Each file in `test/mocks/bin/` shadows one external command. Every stub appends +one line per invocation to `$MOCK_CALL_LOG` as `" "`, emits +canned stdout controlled by an env var, and exits with a controllable code. + +Set the override env vars **before** calling the function under test. Use +`reset_mocks` between phases of one test when you need a clean log (e.g. two +`curl` calls with different responses). + +| Command | stdout override (default) | exit override (default) | +| ----------- | -------------------------------------------------------------------------------------- | --------------------------- | +| `systemctl` | `MOCK_SYSTEMCTL_IS_ACTIVE` (`active`), `MOCK_SYSTEMCTL_IS_ENABLED` (`enabled`) | `MOCK_SYSTEMCTL_EXIT` (`0`) | +| `docker` | `MOCK_DOCKER_PS_OUTPUT` (empty), `MOCK_DOCKER_INSPECT_OUTPUT` (`healthy`), `MOCK_DOCKER_SYSTEM_OUTPUT` (`Total reclaimed space: 0B`) | `MOCK_DOCKER_EXIT` (`0`) | +| `curl` | `MOCK_CURL_OUTPUT` (empty) | `MOCK_CURL_EXIT` (`0`) | +| `crontab` | `MOCK_CRONTAB_LIST` (empty; used by `crontab -l`) | `MOCK_CRONTAB_EXIT` (`0`) | +| `iptables` | (no stdout); `MOCK_IPTABLES_CHECK_EXIT` (`1`) is the exit for `-C` rule checks | `MOCK_IPTABLES_EXIT` (`0`) | +| `ip` | `MOCK_IP_ROUTE_OUTPUT` (default route, field 9 = `10.0.0.1`) | `MOCK_IP_EXIT` (`0`) | +| `free` | `MOCK_FREE_OUTPUT` (a minimal `free -h` table) | `MOCK_FREE_EXIT` (`0`) | +| `nproc` | `MOCK_NPROC_OUTPUT` (`4`) | `MOCK_NPROC_EXIT` (`0`) | +| `uptime` | `MOCK_UPTIME_OUTPUT` (a typical `uptime` line with load average) | `MOCK_UPTIME_EXIT` (`0`) | +| `top` | `MOCK_TOP_OUTPUT` (a `%Cpu(s)` summary line) | `MOCK_TOP_EXIT` (`0`) | +| `df` | `MOCK_DF_OUTPUT` (a `df` table ending in a `total` line) | `MOCK_DF_EXIT` (`0`) | +| `sysctl` | (no stdout) | `MOCK_SYSCTL_EXIT` (`0`) | +| `reboot` | (no stdout; never reboots) | `MOCK_REBOOT_EXIT` (`0`) | +| `grubby` | (no stdout) | `MOCK_GRUBBY_EXIT` (`0`) | + +### Assertion helpers (`mocks.bash`, loaded for you by `load.bash`) + +- `assert_called [substring]` — fail unless `` ran at least once + (and, if given, an invocation contained ``). +- `refute_called ` — fail if `` ran at all. +- `mock_calls ` — echo every recorded invocation of ``. +- `reset_mocks` — truncate `$MOCK_CALL_LOG`. + +bats-support / bats-assert are also loaded, so `assert_success`, +`assert_failure`, `assert_output`, `assert_line`, etc. are available. + +## Tiers + +- **unit/** — one pure function, no orchestration. Source `main.sh`, call the + function, assert on return/output. `harness.bats` lives here as the canary. +- **smoke/** — run `main.sh` as a subprocess and assert its CLI contract (help, + command validation, required-argument enforcement) — the surface that exits + before `preflight()`, so it needs no systemd, docker, or root. + +## Caveats / known limitations + +- `crontab` is invoked two ways in `main.sh`: bare `crontab` (stubbed via + `PATH`) and absolute `/usr/bin/crontab` (the install step). The absolute path + bypasses the stub. Tests that need to observe the install call should assert + on the generated crontab temp file rather than on `assert_called crontab`, + or skip that step. +- Core text tools (`grep`, `sed`, `awk`, `cat`, `tail`, `cut`, `tr`, `df`'s + pipeline consumers) are **not** stubbed — they run for real against the + per-test temp dir. Only host-affecting / network commands are mocked. `tail` + and `grep` were intentionally left un-stubbed: `main.sh` uses them as plain + text filters over files we already control, so real binaries give correct, + simpler behavior than a stub would. diff --git a/test/fixtures/.gitkeep b/test/fixtures/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/api/100_continue.http b/test/fixtures/api/100_continue.http new file mode 100644 index 0000000..16ac907 --- /dev/null +++ b/test/fixtures/api/100_continue.http @@ -0,0 +1,4 @@ +HTTP/1.1 100 Continue +Content-Type: application/json + +{"msg":"continue","data":{"ok":true}} diff --git a/test/fixtures/api/200_no_status.http b/test/fixtures/api/200_no_status.http new file mode 100644 index 0000000..00acd49 --- /dev/null +++ b/test/fixtures/api/200_no_status.http @@ -0,0 +1,3 @@ +Content-Type: application/json + +{"msg":"no status line","data":{"id":"nostatus"}} diff --git a/test/fixtures/api/200_ok.http b/test/fixtures/api/200_ok.http new file mode 100644 index 0000000..95beb0c --- /dev/null +++ b/test/fixtures/api/200_ok.http @@ -0,0 +1,5 @@ +HTTP/1.1 200 OK +Content-Type: application/json +Date: Wed, 18 Jun 2026 00:00:00 GMT + +{"msg":"registration ok","data":{"RegistrationMetadata":["meta-token"]}} diff --git a/test/fixtures/api/201_created.http b/test/fixtures/api/201_created.http new file mode 100644 index 0000000..f1b9e41 --- /dev/null +++ b/test/fixtures/api/201_created.http @@ -0,0 +1,4 @@ +HTTP/1.1 201 Created +Content-Type: application/json + +{"msg":"created","data":{"id":"abc"}} diff --git a/test/fixtures/api/400_bad_request.http b/test/fixtures/api/400_bad_request.http new file mode 100644 index 0000000..773ecb7 --- /dev/null +++ b/test/fixtures/api/400_bad_request.http @@ -0,0 +1,4 @@ +HTTP/1.1 400 Bad Request +Content-Type: application/json + +{"msg":"bad request","data":"Unknown error"} diff --git a/test/fixtures/api/403_forbidden.http b/test/fixtures/api/403_forbidden.http new file mode 100644 index 0000000..25d3b16 --- /dev/null +++ b/test/fixtures/api/403_forbidden.http @@ -0,0 +1,4 @@ +HTTP/1.1 403 Forbidden +Content-Type: application/json + +{"message":"forbidden token","data":"Unknown error"} diff --git a/test/fixtures/api/500_server_error.http b/test/fixtures/api/500_server_error.http new file mode 100644 index 0000000..a55e94f --- /dev/null +++ b/test/fixtures/api/500_server_error.http @@ -0,0 +1,4 @@ +HTTP/1.1 500 Internal Server Error +Content-Type: application/json + +{"msg":"server boom","data":"Unknown error"} diff --git a/test/fixtures/api/proxy_then_200.http b/test/fixtures/api/proxy_then_200.http new file mode 100644 index 0000000..1bf079b --- /dev/null +++ b/test/fixtures/api/proxy_then_200.http @@ -0,0 +1,6 @@ +HTTP/1.1 200 Connection established + +HTTP/1.1 200 OK +Content-Type: application/json + +{"msg":"real ok","data":{"id":"xyz"}} diff --git a/test/fixtures/api/proxy_then_403.http b/test/fixtures/api/proxy_then_403.http new file mode 100644 index 0000000..afda225 --- /dev/null +++ b/test/fixtures/api/proxy_then_403.http @@ -0,0 +1,6 @@ +HTTP/1.1 200 Connection established + +HTTP/1.1 403 Forbidden +Content-Type: application/json + +{"message":"real forbidden","data":"Unknown error"} diff --git a/test/fixtures/ecs/ecs.config.v4.golden b/test/fixtures/ecs/ecs.config.v4.golden new file mode 100644 index 0000000..7e249c0 --- /dev/null +++ b/test/fixtures/ecs/ecs.config.v4.golden @@ -0,0 +1,17 @@ +ECS_CLUSTER=sg-cluster +AWS_DEFAULT_REGION=us-east-1 +ECS_INSTANCE_ATTRIBUTES={"sg_organization": "demo-org","sg_runner_id": "runner-1", "sg_runner_group_id": "rg-1", "sg_runner_group_signature": "sig-abc"} +ECS_LOGLEVEL=info +ECS_DISABLE_PRIVILEGED=false +ECS_ENABLE_UNTRACKED_IMAGE_CLEANUP=true +ECS_ENGINE_TASK_CLEANUP_WAIT_DURATION=24h +ECS_IMAGE_CLEANUP_INTERVAL=24h +ECS_IMAGE_MINIMUM_CLEANUP_AGE=1h +NON_ECS_IMAGE_MINIMUM_CLEANUP_AGE=1h +ECS_TASK_METADATA_RPS_LIMIT=300,400 +AWS_EC2_METADATA_DISABLED=true +ECS_LOGFILE=/log/ecs-agent.log +ECS_DATADIR=/data/ +ECS_ENABLE_TASK_IAM_ROLE=true +ECS_ENABLE_TASK_IAM_ROLE_NETWORK_HOST=true +ECS_EXTERNAL=true diff --git a/test/helpers/load.bash b/test/helpers/load.bash new file mode 100644 index 0000000..feee4df --- /dev/null +++ b/test/helpers/load.bash @@ -0,0 +1,82 @@ +# shellcheck shell=bash +# +# load.bash - single entry point every test sources. +# +# Usage (top of every .bats file): +# load "${BATS_TEST_DIRNAME}/../helpers/load.bash" +# +# It: +# - Locates the repo root and main.sh independent of CWD. +# - Redirects every overridable path in main.sh into a per-test temp dir. +# - Sets SG_SKIP_ROOT_CHECK=true so is_root() succeeds without root. +# - Prepends the mock bin dir to PATH and creates an empty MOCK_CALL_LOG. +# - Loads bats-support + bats-assert from the vendored copies under test/lib. +# - Defines load_main, which sources main.sh on demand. + +# Resolve the directory holding THIS file, following symlinks, without relying +# on CWD. BASH_SOURCE[0] is the path to load.bash itself. +_load_self="${BASH_SOURCE[0]}" +while [[ -h "${_load_self}" ]]; do + _dir="$(cd -P "$(dirname "${_load_self}")" >/dev/null 2>&1 && pwd)" + _load_self="$(readlink "${_load_self}")" + [[ "${_load_self}" != /* ]] && _load_self="${_dir}/${_load_self}" +done + +# test/helpers/load.bash -> repo root is two levels up. +SG_TEST_HELPERS_DIR="$(cd -P "$(dirname "${_load_self}")" >/dev/null 2>&1 && pwd)" +SG_TEST_DIR="$(cd -P "${SG_TEST_HELPERS_DIR}/.." >/dev/null 2>&1 && pwd)" +SG_REPO_ROOT="$(cd -P "${SG_TEST_DIR}/.." >/dev/null 2>&1 && pwd)" +SG_MAIN_SH="${SG_REPO_ROOT}/main.sh" +export SG_TEST_HELPERS_DIR SG_TEST_DIR SG_REPO_ROOT SG_MAIN_SH +unset _load_self _dir + +# Per-test scratch dir. bats sets BATS_TEST_TMPDIR per test; fall back to a +# fresh mktemp dir when sourced outside a running test. +if [[ -n "${BATS_TEST_TMPDIR:-}" ]]; then + SG_TEST_TMPDIR="${BATS_TEST_TMPDIR}" +else + SG_TEST_TMPDIR="$(mktemp -d "${TMPDIR:-/tmp}/sg-runner-test.XXXXXX")" +fi +export SG_TEST_TMPDIR + +# Redirect every path main.sh lets us override into the scratch dir, so a test +# never touches real system paths. main.sh derives SG_DIAGNOSTIC_FILE / +# SG_DIAGNOSTIC_TMP_FILE from SG_DIAGNOSTIC_DIR, so we only set the dir. +export LOG_FILE="${SG_TEST_TMPDIR}/sg_runner.log" +export SG_DIAGNOSTIC_DIR="${SG_TEST_TMPDIR}/sg-runner" +export ECS_CONFIG_DIR="${SG_TEST_TMPDIR}/etc-ecs" +export ECS_LOG_DIR="${SG_TEST_TMPDIR}/var-log-ecs" +export ECS_DATA_DIR="${SG_TEST_TMPDIR}/var-lib-ecs-data" +export REGISTRATION_DIR="${SG_TEST_TMPDIR}/registration" + +# Bypass the root requirement. +export SG_SKIP_ROOT_CHECK="true" + +# Where mock stubs record their invocations. Created empty here so assertions +# work even before any mock runs. +export MOCK_CALL_LOG="${SG_TEST_TMPDIR}/mock_calls.log" +: >"${MOCK_CALL_LOG}" + +# Make the mock stubs win over the real binaries. +PATH="${SG_TEST_DIR}/mocks/bin:${PATH}" +export PATH + +# Vendored bats helper libraries. +load "${SG_TEST_DIR}/lib/bats-support/load.bash" +load "${SG_TEST_DIR}/lib/bats-assert/load.bash" + +# Assertion helpers for the mock call log. +load "${SG_TEST_HELPERS_DIR}/mocks.bash" + +# Source main.sh, exposing all of its functions in the current shell. Call this +# AFTER any per-test env tweaks (e.g. flipping a MOCK_* override). +# +# main.sh runs under `set -o pipefail`; sourcing it must not abort the test, so +# we guard the source and surface a clear failure if it errors. +load_main() { + # shellcheck source=/dev/null + if ! source "${SG_MAIN_SH}"; then + echo "load_main: failed to source ${SG_MAIN_SH}" >&2 + return 1 + fi +} diff --git a/test/helpers/mocks.bash b/test/helpers/mocks.bash new file mode 100644 index 0000000..7b3226e --- /dev/null +++ b/test/helpers/mocks.bash @@ -0,0 +1,58 @@ +# shellcheck shell=bash +# +# mocks.bash - assertions over the mock call log. +# +# Every stub in test/mocks/bin appends one line per invocation to +# $MOCK_CALL_LOG in the form: " " +# These helpers read that file. load.bash sources this file for you. + +# mock_calls - print every recorded invocation of (one per line). +mock_calls() { + local cmd="$1" + grep -E "^${cmd}( |$)" "${MOCK_CALL_LOG}" 2>/dev/null || true + return 0 +} + +# assert_called [substring] +# Passes if was invoked at least once. If [substring] is given, at +# least one invocation of must contain it. +assert_called() { + local cmd="$1" + local needle="${2:-}" + local calls + calls="$(mock_calls "${cmd}")" + + if [[ -z "${calls}" ]]; then + batslib_print_kv_single 8 "command" "${cmd}" \ + | batslib_decorate "command was not called" \ + | fail + return 1 + fi + + if [[ -n "${needle}" ]] && ! grep -qF -- "${needle}" <<<"${calls}"; then + { batslib_print_kv_single 9 "command" "${cmd}" "substring" "${needle}" + batslib_print_kv_single_or_multi 9 "calls" "${calls}" + } | batslib_decorate "command not called with substring" \ + | fail + return 1 + fi +} + +# refute_called - passes only if was never invoked. +refute_called() { + local cmd="$1" + local calls + calls="$(mock_calls "${cmd}")" + if [[ -n "${calls}" ]]; then + batslib_print_kv_single_or_multi 6 "calls" "${calls}" \ + | batslib_decorate "command was called but should not have been" \ + | fail + return 1 + fi +} + +# reset_mocks - truncate the call log (e.g. between phases of one test). +reset_mocks() { + : >"${MOCK_CALL_LOG}" + return 0 +} diff --git a/test/lib/bats-assert b/test/lib/bats-assert new file mode 160000 index 0000000..697471b --- /dev/null +++ b/test/lib/bats-assert @@ -0,0 +1 @@ +Subproject commit 697471b7a89d3ab38571f38c6c7c4b460d1f5e35 diff --git a/test/lib/bats-core b/test/lib/bats-core new file mode 160000 index 0000000..5a7db7a --- /dev/null +++ b/test/lib/bats-core @@ -0,0 +1 @@ +Subproject commit 5a7db7a98951d9d89b3b5e7800037e655a93345f diff --git a/test/lib/bats-support b/test/lib/bats-support new file mode 160000 index 0000000..0954abb --- /dev/null +++ b/test/lib/bats-support @@ -0,0 +1 @@ +Subproject commit 0954abb9925cad550424cebca2b99255d4eabe96 diff --git a/test/mocks/bin/crontab b/test/mocks/bin/crontab new file mode 100755 index 0000000..7240199 --- /dev/null +++ b/test/mocks/bin/crontab @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Mock: crontab +# Records the call. `crontab -l` lists the current table; everything else +# (installing a new table from a file argument) just succeeds. +# $MOCK_CRONTAB_LIST (default: empty) -> stdout for `crontab -l` +# $MOCK_CRONTAB_EXIT (default: 0) +printf '%s\n' "crontab $*" >>"${MOCK_CALL_LOG}" + +if [[ "$1" == "-l" ]]; then + printf '%s' "${MOCK_CRONTAB_LIST:-}" + [[ -n "${MOCK_CRONTAB_LIST:-}" ]] && echo +fi + +exit "${MOCK_CRONTAB_EXIT:-0}" diff --git a/test/mocks/bin/curl b/test/mocks/bin/curl new file mode 100755 index 0000000..d396619 --- /dev/null +++ b/test/mocks/bin/curl @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Mock: curl +# Records the call, then emits canned output. main.sh uses curl for: +# - public IP (ifconfig.me) +# - the StackGuardian register/deregister API (returns an HTTP response blob) +# - downloading the ECS install script +# - IMDSv2 token + IAM role lookups +# One knob covers stdout; tests that need to distinguish callers can set it per +# phase with reset_mocks between calls. +# $MOCK_CURL_OUTPUT (default: empty) +# $MOCK_CURL_EXIT (default: 0) +printf '%s\n' "curl $*" >>"${MOCK_CALL_LOG}" + +printf '%s' "${MOCK_CURL_OUTPUT:-}" +[[ -n "${MOCK_CURL_OUTPUT:-}" ]] && echo + +exit "${MOCK_CURL_EXIT:-0}" diff --git a/test/mocks/bin/df b/test/mocks/bin/df new file mode 100755 index 0000000..3440ebc --- /dev/null +++ b/test/mocks/bin/df @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Mock: df +# Records the call. main.sh runs `df -h --total /var | awk '/^total/ ...'`, so +# the default output carries a `total` line with a size + use% the awk parses. +# $MOCK_DF_OUTPUT (default: a df table ending in a `total` line) +# $MOCK_DF_EXIT (default: 0) +printf '%s\n' "df $*" >>"${MOCK_CALL_LOG}" + +if [[ -n "${MOCK_DF_OUTPUT:-}" ]]; then + printf '%s\n' "${MOCK_DF_OUTPUT}" +else + cat <<'EOF' +Filesystem Size Used Avail Use% Mounted on +/dev/root 194G 20G 174G 11% / +total 194G 20G 174G 11% +EOF +fi + +exit "${MOCK_DF_EXIT:-0}" diff --git a/test/mocks/bin/docker b/test/mocks/bin/docker new file mode 100755 index 0000000..955fe1e --- /dev/null +++ b/test/mocks/bin/docker @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Mock: docker (also used via $CONTAINER_ORCHESTRATOR) +# Records the call, then answers the subcommands main.sh uses: +# ps -> $MOCK_DOCKER_PS_OUTPUT (default: empty) +# inspect -> $MOCK_DOCKER_INSPECT_OUTPUT (default: healthy) +# system -> $MOCK_DOCKER_SYSTEM_OUTPUT (default: "Total reclaimed space: 0B") +# Exit code override: $MOCK_DOCKER_EXIT (default: 0). +printf '%s\n' "docker $*" >>"${MOCK_CALL_LOG}" + +case "$1" in + ps) + printf '%s' "${MOCK_DOCKER_PS_OUTPUT:-}" + [[ -n "${MOCK_DOCKER_PS_OUTPUT:-}" ]] && echo + ;; + inspect) + echo "${MOCK_DOCKER_INSPECT_OUTPUT:-healthy}" + ;; + system) + echo "${MOCK_DOCKER_SYSTEM_OUTPUT:-Total reclaimed space: 0B}" + ;; +esac + +exit "${MOCK_DOCKER_EXIT:-0}" diff --git a/test/mocks/bin/free b/test/mocks/bin/free new file mode 100755 index 0000000..59e12cc --- /dev/null +++ b/test/mocks/bin/free @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Mock: free +# Records the call. main.sh runs `free -h | awk '/^Mem:/ ...'`. Default output +# carries a Mem: line so the pipeline produces a value. +# $MOCK_FREE_OUTPUT (default: a minimal `free -h` table) +# $MOCK_FREE_EXIT (default: 0) +printf '%s\n' "free $*" >>"${MOCK_CALL_LOG}" + +if [[ -n "${MOCK_FREE_OUTPUT:-}" ]]; then + printf '%s\n' "${MOCK_FREE_OUTPUT}" +else + cat <<'EOF' + total used free shared buff/cache available +Mem: 7.7Gi 1.2Gi 5.0Gi 10Mi 1.5Gi 6.2Gi +Swap: 0B 0B 0B +EOF +fi + +exit "${MOCK_FREE_EXIT:-0}" diff --git a/test/mocks/bin/grubby b/test/mocks/bin/grubby new file mode 100755 index 0000000..ab21e98 --- /dev/null +++ b/test/mocks/bin/grubby @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Mock: grubby +# Records the call. main.sh probes for grubby via `type grubby` and, when +# present, calls it to update the kernel cmdline; just succeed. +# $MOCK_GRUBBY_EXIT (default: 0) +printf '%s\n' "grubby $*" >>"${MOCK_CALL_LOG}" +exit "${MOCK_GRUBBY_EXIT:-0}" diff --git a/test/mocks/bin/ip b/test/mocks/bin/ip new file mode 100755 index 0000000..9a44818 --- /dev/null +++ b/test/mocks/bin/ip @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Mock: ip +# Records the call. main.sh extracts the node IP with: +# ip route | grep default | cut -d" " -f9 +# so the default route line below is shaped to put 10.0.0.1 in field 9 +# (default via dev proto

src 10.0.0.1 metric ). +# $MOCK_IP_ROUTE_OUTPUT (default: a default route whose field 9 is 10.0.0.1) +# $MOCK_IP_EXIT (default: 0) +printf '%s\n' "ip $*" >>"${MOCK_CALL_LOG}" + +if [[ "$1" == "route" ]]; then + echo "${MOCK_IP_ROUTE_OUTPUT:-default via 10.0.0.254 dev eth0 proto dhcp src 10.0.0.1 metric 100}" +fi + +exit "${MOCK_IP_EXIT:-0}" diff --git a/test/mocks/bin/iptables b/test/mocks/bin/iptables new file mode 100755 index 0000000..c923bf3 --- /dev/null +++ b/test/mocks/bin/iptables @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Mock: iptables +# Records the call. main.sh uses `-C` (check) to test for a rule, then inserts. +# The check exit code is controllable so tests can drive both branches: +# $MOCK_IPTABLES_CHECK_EXIT (default: 1 -> "rule absent", so an insert follows) +# Non-check invocations exit with $MOCK_IPTABLES_EXIT (default: 0). +printf '%s\n' "iptables $*" >>"${MOCK_CALL_LOG}" + +for arg in "$@"; do + if [[ "${arg}" == "-C" ]]; then + exit "${MOCK_IPTABLES_CHECK_EXIT:-1}" + fi +done + +exit "${MOCK_IPTABLES_EXIT:-0}" diff --git a/test/mocks/bin/nproc b/test/mocks/bin/nproc new file mode 100755 index 0000000..73e485d --- /dev/null +++ b/test/mocks/bin/nproc @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Mock: nproc +# Records the call, prints a CPU count. +# $MOCK_NPROC_OUTPUT (default: 4) +# $MOCK_NPROC_EXIT (default: 0) +printf '%s\n' "nproc $*" >>"${MOCK_CALL_LOG}" +echo "${MOCK_NPROC_OUTPUT:-4}" +exit "${MOCK_NPROC_EXIT:-0}" diff --git a/test/mocks/bin/reboot b/test/mocks/bin/reboot new file mode 100755 index 0000000..324f467 --- /dev/null +++ b/test/mocks/bin/reboot @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Mock: reboot +# Records the call and returns immediately. Never actually reboots, so the +# cgroupsv2 path is safe to exercise under test. +# $MOCK_REBOOT_EXIT (default: 0) +printf '%s\n' "reboot $*" >>"${MOCK_CALL_LOG}" +exit "${MOCK_REBOOT_EXIT:-0}" diff --git a/test/mocks/bin/sysctl b/test/mocks/bin/sysctl new file mode 100755 index 0000000..f1054c8 --- /dev/null +++ b/test/mocks/bin/sysctl @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Mock: sysctl +# Records the call. main.sh only writes values (`sysctl -w ...`); just succeed. +# $MOCK_SYSCTL_EXIT (default: 0) +printf '%s\n' "sysctl $*" >>"${MOCK_CALL_LOG}" +exit "${MOCK_SYSCTL_EXIT:-0}" diff --git a/test/mocks/bin/systemctl b/test/mocks/bin/systemctl new file mode 100755 index 0000000..c1d0b30 --- /dev/null +++ b/test/mocks/bin/systemctl @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Mock: systemctl +# Records the call, then answers the read-only subcommands main.sh queries. +# is-active -> $MOCK_SYSTEMCTL_IS_ACTIVE (default: active) +# is-enabled -> $MOCK_SYSTEMCTL_IS_ENABLED (default: enabled) +# All other subcommands (status, restart, reload-or-restart, stop, daemon-reload) +# just succeed. Exit code override: $MOCK_SYSTEMCTL_EXIT (default: 0). +printf '%s\n' "systemctl $*" >>"${MOCK_CALL_LOG}" + +case "$1" in + is-active) + echo "${MOCK_SYSTEMCTL_IS_ACTIVE:-active}" + ;; + is-enabled) + echo "${MOCK_SYSTEMCTL_IS_ENABLED:-enabled}" + ;; +esac + +exit "${MOCK_SYSTEMCTL_EXIT:-0}" diff --git a/test/mocks/bin/top b/test/mocks/bin/top new file mode 100755 index 0000000..6c93c86 --- /dev/null +++ b/test/mocks/bin/top @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Mock: top +# Records the call. main.sh runs `top -bn1 | grep "Cpu(s)" | awk ...`, so the +# default output carries a Linux-style Cpu(s) line the pipeline can parse. +# $MOCK_TOP_OUTPUT (default: a single Cpu(s) summary line) +# $MOCK_TOP_EXIT (default: 0) +printf '%s\n' "top $*" >>"${MOCK_CALL_LOG}" +echo "${MOCK_TOP_OUTPUT:-%Cpu(s): 3.0 us, 1.0 sy, 0.0 ni, 95.0 id, 0.5 wa, 0.0 hi, 0.5 si, 0.0 st}" +exit "${MOCK_TOP_EXIT:-0}" diff --git a/test/mocks/bin/uptime b/test/mocks/bin/uptime new file mode 100755 index 0000000..837a280 --- /dev/null +++ b/test/mocks/bin/uptime @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Mock: uptime +# Records the call. main.sh parses both the leading uptime fields and the +# trailing "load average:" segment, so the default output carries both. +# $MOCK_UPTIME_OUTPUT (default: a typical `uptime` line) +# $MOCK_UPTIME_EXIT (default: 0) +printf '%s\n' "uptime $*" >>"${MOCK_CALL_LOG}" +echo "${MOCK_UPTIME_OUTPUT:- 12:00:00 up 3 days, 4:05, 1 user, load average: 0.10, 0.20, 0.30}" +exit "${MOCK_UPTIME_EXIT:-0}" diff --git a/test/smoke/.gitkeep b/test/smoke/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/smoke/command_validation.bats b/test/smoke/command_validation.bats new file mode 100644 index 0000000..8f5da95 --- /dev/null +++ b/test/smoke/command_validation.bats @@ -0,0 +1,28 @@ +#!/usr/bin/env bats +# +# Smoke tests for command validation in init_args_are_valid, invoked as a +# subprocess. init_args_are_valid runs in main() right after is_root and well +# before preflight, so these invalid-command cases die before the systemd +# check, even on a non-systemd host. + +setup() { + load "${BATS_TEST_DIRNAME}/../helpers/load.bash" +} + +@test "an unknown command fails as invalid" { + run bash "$SG_MAIN_SH" bogus + assert_failure + assert_output --partial "is invalid" +} + +@test "cgroupsv2 without enable|disable fails listing the choices" { + run bash "$SG_MAIN_SH" cgroupsv2 + assert_failure + assert_output --partial "enable, disable" +} + +@test "cgroupsv2 with an unknown subcommand fails" { + run bash "$SG_MAIN_SH" cgroupsv2 frobnicate + assert_failure + assert_output --partial "enable, disable" +} diff --git a/test/smoke/help.bats b/test/smoke/help.bats new file mode 100644 index 0000000..8078a65 --- /dev/null +++ b/test/smoke/help.bats @@ -0,0 +1,42 @@ +#!/usr/bin/env bats +# +# Smoke tests for the help / no-args path of main.sh, invoked as a subprocess. +# +# main() handles help before any other work: +# [[ "${*}" =~ --help || $# -lt 1 ]] && show_help && exit 0 +# so --help anywhere in the args, and the no-args case, print help and exit 0 +# without ever reaching is_root, init_args_are_valid, or preflight. + +setup() { + load "${BATS_TEST_DIRNAME}/../helpers/load.bash" +} + +@test "--help exits 0 and prints the help banner" { + run bash "$SG_MAIN_SH" --help + assert_success + assert_output --partial "sg-runner" + assert_output --partial "Available commands" +} + +@test "--help lists every documented command" { + run bash "$SG_MAIN_SH" --help + assert_success + assert_output --partial "register" + assert_output --partial "deregister" + assert_output --partial "status" + assert_output --partial "prune" + assert_output --partial "clean" +} + +@test "no args prints help and exits 0" { + run bash "$SG_MAIN_SH" + assert_success + assert_output --partial "sg-runner" + assert_output --partial "Available commands" +} + +@test "--help anywhere in the args short-circuits to help" { + run bash "$SG_MAIN_SH" register --help + assert_success + assert_output --partial "Available commands" +} diff --git a/test/smoke/required_args.bats b/test/smoke/required_args.bats new file mode 100644 index 0000000..eeeaa69 --- /dev/null +++ b/test/smoke/required_args.bats @@ -0,0 +1,44 @@ +#!/usr/bin/env bats +# +# Smoke tests for the required-flag checks in init_args_are_valid, invoked as a +# subprocess. register and deregister both require --sg-node-token, +# --organization and --runner-group; init_args_are_valid dies if any is absent. +# +# This runs before preflight in main(), so a register/deregister that is +# MISSING a required flag fails with the required-args message rather than the +# systemd message -- the assertions below confirm that boundary holds. + +setup() { + load "${BATS_TEST_DIRNAME}/../helpers/load.bash" +} + +@test "register with no flags fails listing the three required flags" { + run bash "$SG_MAIN_SH" register + assert_failure + assert_output --partial "--sg-node-token, --organization, --runner-group" +} + +@test "register missing --runner-group fails at init_args_are_valid, not preflight" { + run bash "$SG_MAIN_SH" register --sg-node-token t --organization o + assert_failure + # The required-args check runs before preflight, so we see that message ... + assert_output --partial "--sg-node-token, --organization, --runner-group" + # ... and NOT the systemd die from preflight. + refute_output --partial "systemd-based" +} + +@test "deregister missing --sg-node-token fails for the required flags" { + run bash "$SG_MAIN_SH" deregister --organization o --runner-group g + assert_failure + assert_output --partial "--sg-node-token, --organization, --runner-group" + refute_output --partial "systemd-based" +} + +@test "DOCUMENTING: fully-valid subcommands reach preflight (not covered here)" { + # A register with all required flags passes init_args_are_valid and proceeds + # to preflight, which hard-dies on any non-systemd host ([[ ! -d + # /run/systemd/system ]]). On macOS that always fires, so a fully-valid + # subcommand cannot be exercised end to end by this host-only suite. + # Likewise status/prune/info/clean pass validation and reach preflight. + skip "valid subcommands reach preflight; not exercised by the host-only suite" +} diff --git a/test/unit/api_call.bats b/test/unit/api_call.bats new file mode 100644 index 0000000..a1cb8cc --- /dev/null +++ b/test/unit/api_call.bats @@ -0,0 +1,160 @@ +#!/usr/bin/env bats +# +# Unit tests for api_call: status-code parsing, body extraction, and the +# proxy double-status edge case. api_call sets globals (response/status_code/ +# message/data) so we call it DIRECTLY (not via `run`) to inspect them, and use +# `run` only for the paths that exit (empty response / missing status line). + +setup() { + load "${BATS_TEST_DIRNAME}/../helpers/load.bash" + export SG_NODE_TOKEN="test-token" +} + +fixture() { + cat "${BATS_TEST_DIRNAME}/../fixtures/api/$1" +} + +@test "api_call 200 parses status_code, message and data" { + export MOCK_CURL_OUTPUT="$(fixture 200_ok.http)" + load_main + + api_call "POST" "https://api.example/register/" + assert_equal "$status_code" "200" + assert_equal "$message" "registration ok" + assert_equal "$(echo "$data" | jq -r '.RegistrationMetadata[0]')" "meta-token" +} + +@test "api_call 200 returns 0" { + export MOCK_CURL_OUTPUT="$(fixture 200_ok.http)" + load_main + + run api_call "POST" "https://api.example/register/" + assert_success +} + +@test "api_call 201 returns 0 with status_code 201" { + export MOCK_CURL_OUTPUT="$(fixture 201_created.http)" + load_main + + api_call "POST" "https://api.example/x/" + assert_equal "$status_code" "201" + run api_call "POST" "https://api.example/x/" + assert_success +} + +@test "api_call 100 returns 0 with status_code 100" { + export MOCK_CURL_OUTPUT="$(fixture 100_continue.http)" + load_main + + api_call "POST" "https://api.example/x/" + assert_equal "$status_code" "100" + run api_call "POST" "https://api.example/x/" + assert_success +} + +@test "api_call 400 returns 1 (non-exit) with status_code 400" { + export MOCK_CURL_OUTPUT="$(fixture 400_bad_request.http)" + load_main + + run api_call "POST" "https://api.example/x/" + assert_failure 1 + + # Direct call to read the globals; `|| true` keeps the non-zero return + # (expected for 4xx) from aborting under bats' set -e. Not a subshell, so the + # globals it sets remain visible. + api_call "POST" "https://api.example/x/" || true + assert_equal "$status_code" "400" + assert_equal "$message" "bad request" +} + +@test "api_call 403 returns 1 and reads .message when .msg absent" { + export MOCK_CURL_OUTPUT="$(fixture 403_forbidden.http)" + load_main + + run api_call "POST" "https://api.example/x/" + assert_failure 1 + + api_call "POST" "https://api.example/x/" || true + assert_equal "$status_code" "403" + assert_equal "$message" "forbidden token" +} + +@test "api_call 500 returns 1 with status_code 500" { + export MOCK_CURL_OUTPUT="$(fixture 500_server_error.http)" + load_main + + run api_call "POST" "https://api.example/x/" + assert_failure 1 + + api_call "POST" "https://api.example/x/" || true + assert_equal "$status_code" "500" +} + +@test "api_call proxy double-200: last status code wins, returns 0" { + export MOCK_CURL_OUTPUT="$(fixture proxy_then_200.http)" + load_main + + api_call "POST" "https://api.example/x/" + assert_equal "$status_code" "200" + assert_equal "$message" "real ok" + + run api_call "POST" "https://api.example/x/" + assert_success +} + +@test "api_call proxy 200 then real 403: status_code is 403, returns 1" { + export MOCK_CURL_OUTPUT="$(fixture proxy_then_403.http)" + load_main + + api_call "POST" "https://api.example/x/" || true + assert_equal "$status_code" "403" + assert_equal "$message" "real forbidden" + + run api_call "POST" "https://api.example/x/" + assert_failure 1 +} + +@test "api_call empty response calls err and exits" { + export MOCK_CURL_OUTPUT="" + load_main + + run api_call "POST" "https://api.example/x/" + assert_failure + assert_output --partial "Empty response" +} + +@test "api_call with no status line exits with Unknown status code" { + export MOCK_CURL_OUTPUT="$(fixture 200_no_status.http)" + load_main + + run api_call "POST" "https://api.example/x/" + assert_failure + assert_output --partial "Unknown status code" +} + +@test "api_call without payload does NOT pass -d to curl" { + export MOCK_CURL_OUTPUT="$(fixture 200_ok.http)" + load_main + + api_call "POST" "https://api.example/x/" + assert_called curl "-X POST" + refute_output_contains_d +} + +# Helper: assert no curl invocation carried a -d payload flag. +refute_output_contains_d() { + local calls + calls="$(mock_calls curl)" + if grep -qE -- '(^| )-d( |$)' <<<"$calls"; then + fail "expected no -d flag in curl calls, got: ${calls}" + fi +} + +@test "api_call with payload passes -d and -X POST to curl" { + export MOCK_CURL_OUTPUT="$(fixture 201_created.http)" + load_main + + api_call "POST" "https://api.example/x/" '{"k":"v"}' + assert_called curl "-X POST" + assert_called curl "-d {\"k\":\"v\"}" +} diff --git a/test/unit/cgroupsv2.bats b/test/unit/cgroupsv2.bats new file mode 100644 index 0000000..e70c619 --- /dev/null +++ b/test/unit/cgroupsv2.bats @@ -0,0 +1,28 @@ +#!/usr/bin/env bats +# +# Best-effort unit tests for cgroupsv2. The function prompts via `read -p`, then +# (grubby-present branch) runs `grubby --args=systemd.unified_cgroup_hierarchy=N` +# and `reboot`, then `exit 0`. grubby and reboot are mocked. We feed `Y` on +# stdin to pass the confirmation, and use `run` because the function exits. + +setup() { + load "${BATS_TEST_DIRNAME}/../helpers/load.bash" +} + +@test "enable: grubby gets unified_cgroup_hierarchy=1 and reboot is invoked" { + load_main + + run cgroupsv2 enable <<<"Y" + assert_success + assert_called grubby "systemd.unified_cgroup_hierarchy=1" + assert_called reboot +} + +@test "disable: grubby gets unified_cgroup_hierarchy=0 and reboot is invoked" { + load_main + + run cgroupsv2 disable <<<"Y" + assert_success + assert_called grubby "systemd.unified_cgroup_hierarchy=0" + assert_called reboot +} diff --git a/test/unit/check_args.bats b/test/unit/check_args.bats new file mode 100644 index 0000000..5cd0638 --- /dev/null +++ b/test/unit/check_args.bats @@ -0,0 +1,82 @@ +#!/usr/bin/env bats +# +# Unit tests for the small argument/variable guards: +# check_arg_value - rejects --flag-looking and empty values +# check_sg_args - requires the three SG identity globals +# check_variable_value - rejects an empty named variable + +setup() { + load "${BATS_TEST_DIRNAME}/../helpers/load.bash" + load_main +} + +# --- check_arg_value ------------------------------------------------------- + +@test "check_arg_value accepts a normal value" { + run check_arg_value "--organization" "my-org" + assert_success +} + +@test "check_arg_value rejects a value that looks like a flag" { + run check_arg_value "--organization" "--runner-group" + assert_failure + assert_output --partial "has invalid value" +} + +@test "check_arg_value rejects an empty value" { + run check_arg_value "--organization" "" + assert_failure + assert_output --partial "can't be empty" +} + +# --- check_sg_args --------------------------------------------------------- + +@test "check_sg_args succeeds when all three identity globals are set" { + SG_NODE_TOKEN="token" + ORGANIZATION_ID="org" + RUNNER_GROUP_ID="group" + run check_sg_args + assert_success +} + +@test "check_sg_args dies when SG_NODE_TOKEN is empty" { + SG_NODE_TOKEN="" + ORGANIZATION_ID="org" + RUNNER_GROUP_ID="group" + run check_sg_args + assert_failure + assert_output --partial "--sg-node-token, --organization, --runner-group" +} + +@test "check_sg_args dies when ORGANIZATION_ID is empty" { + SG_NODE_TOKEN="token" + ORGANIZATION_ID="" + RUNNER_GROUP_ID="group" + run check_sg_args + assert_failure + assert_output --partial "are required" +} + +@test "check_sg_args dies when RUNNER_GROUP_ID is empty" { + SG_NODE_TOKEN="token" + ORGANIZATION_ID="org" + RUNNER_GROUP_ID="" + run check_sg_args + assert_failure + assert_output --partial "are required" +} + +# --- check_variable_value -------------------------------------------------- + +@test "check_variable_value succeeds when the named var is set" { + MY_VAR="something" + run check_variable_value MY_VAR + assert_success +} + +@test "check_variable_value dies when the named var is empty" { + MY_VAR="" + run check_variable_value MY_VAR + assert_failure + assert_output --partial "Variable can't be empty" +} diff --git a/test/unit/configure_local_data.bats b/test/unit/configure_local_data.bats new file mode 100644 index 0000000..02dbb3d --- /dev/null +++ b/test/unit/configure_local_data.bats @@ -0,0 +1,100 @@ +#!/usr/bin/env bats +# +# Unit tests for configure_local_data: it writes ${ECS_CONFIG_DIR}/ecs.config +# from a handful of globals. The file is fully deterministic from those inputs +# (no timestamps), so we both diff a V4 golden file and assert the conditional +# branches: the V4-only sg_runner_group_signature attribute, and the optional +# HTTP_PROXY block. + +setup() { + load "${BATS_TEST_DIRNAME}/../helpers/load.bash" + export ECS_CLUSTER="sg-cluster" + export LOCAL_AWS_DEFAULT_REGION="us-east-1" + export ORGANIZATION_NAME="demo-org" + export RUNNER_ID="runner-1" + export RUNNER_GROUP_ID="rg-1" + export SG_RUNNER_GROUP_SIGNATURE="sig-abc" +} + +# main.sh assigns NO_PROXY at source time (top-level default), so an env export +# before load_main is clobbered. Set it AFTER load_main when a test needs a +# known value. + +config_file() { echo "${ECS_CONFIG_DIR}/ecs.config"; } + +@test "V4 config matches the golden file (signature included, no proxy)" { + export RUNNER_GROUP_DOC_VERSION="V4" + export HTTP_PROXY="" + load_main + + configure_local_data + + run diff "${BATS_TEST_DIRNAME}/../fixtures/ecs/ecs.config.v4.golden" "$(config_file)" + assert_success +} + +@test "V4 includes sg_runner_group_signature in ECS_INSTANCE_ATTRIBUTES" { + export RUNNER_GROUP_DOC_VERSION="V4" + export HTTP_PROXY="" + load_main + + configure_local_data + + run grep '^ECS_INSTANCE_ATTRIBUTES=' "$(config_file)" + assert_output --partial '"sg_runner_group_signature": "sig-abc"' +} + +@test "non-V4 omits sg_runner_group_signature" { + export RUNNER_GROUP_DOC_VERSION="V3" + export HTTP_PROXY="" + load_main + + configure_local_data + + run grep '^ECS_INSTANCE_ATTRIBUTES=' "$(config_file)" + refute_output --partial "sg_runner_group_signature" + assert_output --partial '"sg_organization": "demo-org"' + assert_output --partial '"sg_runner_group_id": "rg-1"' +} + +@test "core ECS settings are always present" { + export RUNNER_GROUP_DOC_VERSION="V4" + export HTTP_PROXY="" + load_main + + configure_local_data + local f + f="$(config_file)" + + assert_equal "$(grep -c '^ECS_CLUSTER=sg-cluster$' "$f")" "1" + assert_equal "$(grep -c '^AWS_DEFAULT_REGION=us-east-1$' "$f")" "1" + assert_equal "$(grep -c '^ECS_EXTERNAL=true$' "$f")" "1" +} + +@test "HTTP_PROXY set: appends the proxy block" { + export RUNNER_GROUP_DOC_VERSION="V4" + export HTTP_PROXY="proxy.local:3128" + load_main + NO_PROXY="localhost,127.0.0.1" + + configure_local_data + local f + f="$(config_file)" + + assert_equal "$(grep -c '^HTTP_PROXY=proxy.local:3128$' "$f")" "1" + assert_equal "$(grep -c '^HTTPS_PROXY=proxy.local:3128$' "$f")" "1" + assert_equal "$(grep -c '^NO_PROXY=localhost,127.0.0.1$' "$f")" "1" +} + +@test "HTTP_PROXY unset: no proxy block appended" { + export RUNNER_GROUP_DOC_VERSION="V4" + export HTTP_PROXY="" + load_main + + configure_local_data + local f + f="$(config_file)" + + refute [ -n "$(grep '^HTTP_PROXY=' "$f")" ] + refute [ -n "$(grep '^HTTPS_PROXY=' "$f")" ] +} diff --git a/test/unit/harness.bats b/test/unit/harness.bats new file mode 100644 index 0000000..6869745 --- /dev/null +++ b/test/unit/harness.bats @@ -0,0 +1,51 @@ +#!/usr/bin/env bats +# +# Canary test for the test harness itself. Proves: +# (a) main.sh sources cleanly via load_main, +# (b) a pure function behaves (validate_runner_id accept/reject), +# (c) a mock records its invocation and assert_called sees it. + +setup() { + load "${BATS_TEST_DIRNAME}/../helpers/load.bash" +} + +@test "main.sh sources cleanly via load_main" { + run load_main + assert_success +} + +@test "load_main exposes functions in the current shell" { + load_main + declare -F validate_runner_id >/dev/null + declare -F is_root >/dev/null +} + +@test "validate_runner_id accepts a well-formed id" { + load_main + run validate_runner_id good-id_1 + assert_success +} + +@test "validate_runner_id rejects an id with shell metacharacters" { + load_main + run validate_runner_id 'bad;id' + assert_failure +} + +@test "invoking a mock records to MOCK_CALL_LOG and assert_called sees it" { + run systemctl is-active foo + assert_success + assert_called systemctl + assert_called systemctl "is-active foo" +} + +@test "refute_called passes for a command that was never invoked" { + refute_called docker +} + +@test "reset_mocks clears the call log" { + systemctl is-active foo + assert_called systemctl + reset_mocks + refute_called systemctl +} diff --git a/test/unit/init_args_are_valid.bats b/test/unit/init_args_are_valid.bats new file mode 100644 index 0000000..0f7b06f --- /dev/null +++ b/test/unit/init_args_are_valid.bats @@ -0,0 +1,81 @@ +#!/usr/bin/env bats +# +# Unit tests for init_args_are_valid - the first-pass gate on the subcommand +# and its required companion flags, run before parse_arguments. + +setup() { + load "${BATS_TEST_DIRNAME}/../helpers/load.bash" + load_main +} + +@test "init_args_are_valid rejects an unknown subcommand" { + run init_args_are_valid bogus + assert_failure + assert_output --partial "is invalid" +} + +@test "init_args_are_valid accepts status with no extra args" { + run init_args_are_valid status + assert_success +} + +@test "init_args_are_valid accepts info with no extra args" { + run init_args_are_valid info + assert_success +} + +@test "init_args_are_valid accepts prune with no extra args" { + run init_args_are_valid prune + assert_success +} + +@test "init_args_are_valid accepts clean with no extra args" { + run init_args_are_valid clean + assert_success +} + +@test "init_args_are_valid accepts cgroupsv2 enable" { + run init_args_are_valid cgroupsv2 enable + assert_success +} + +@test "init_args_are_valid accepts cgroupsv2 disable" { + run init_args_are_valid cgroupsv2 disable + assert_success +} + +@test "init_args_are_valid dies on cgroupsv2 without enable/disable" { + run init_args_are_valid cgroupsv2 + assert_failure + assert_output --partial "enable, disable" +} + +@test "init_args_are_valid accepts register with all three required flags" { + run init_args_are_valid register \ + --sg-node-token tok --organization org --runner-group grp + assert_success +} + +@test "init_args_are_valid dies on register missing --runner-group" { + run init_args_are_valid register --sg-node-token tok --organization org + assert_failure + assert_output --partial "--sg-node-token, --organization, --runner-group" +} + +@test "init_args_are_valid dies on register with no flags" { + run init_args_are_valid register + assert_failure + assert_output --partial "are required" +} + +@test "init_args_are_valid accepts deregister with all three required flags" { + run init_args_are_valid deregister \ + --sg-node-token tok --organization org --runner-group grp + assert_success +} + +@test "init_args_are_valid dies on deregister missing --organization" { + run init_args_are_valid deregister --sg-node-token tok --runner-group grp + assert_failure + assert_output --partial "are required" +} diff --git a/test/unit/iptables_ensure.bats b/test/unit/iptables_ensure.bats new file mode 100644 index 0000000..e64812d --- /dev/null +++ b/test/unit/iptables_ensure.bats @@ -0,0 +1,61 @@ +#!/usr/bin/env bats +# +# Unit tests for iptables_ensure: an idempotent insert/append. It first runs a +# `-C` check; if the rule is absent (check exits non-zero) it runs the op +# (-A / -I), otherwise it does nothing further. Covers the -t TABLE branch. + +setup() { + load "${BATS_TEST_DIRNAME}/../helpers/load.bash" +} + +@test "rule absent (-C fails): runs the -I insert with the args" { + export MOCK_IPTABLES_CHECK_EXIT=1 + load_main + + iptables_ensure -I DOCKER-USER -i br-abc -d 169.254.169.254 -j DROP + + assert_called iptables "-C DOCKER-USER -i br-abc -d 169.254.169.254 -j DROP" + assert_called iptables "-I DOCKER-USER -i br-abc -d 169.254.169.254 -j DROP" +} + +@test "rule absent (-C fails): runs the -A append with the args" { + export MOCK_IPTABLES_CHECK_EXIT=1 + load_main + + iptables_ensure -A FORWARD -j ACCEPT + + assert_called iptables "-C FORWARD -j ACCEPT" + assert_called iptables "-A FORWARD -j ACCEPT" +} + +@test "rule present (-C succeeds): does NOT add, only the check runs" { + export MOCK_IPTABLES_CHECK_EXIT=0 + load_main + + iptables_ensure -I DOCKER-USER -i br-abc -j DROP + + assert_called iptables "-C DOCKER-USER -i br-abc -j DROP" + run mock_calls iptables + refute_line --partial "-I DOCKER-USER" +} + +@test "-t nat table arg is threaded into both check and op" { + export MOCK_IPTABLES_CHECK_EXIT=1 + load_main + + iptables_ensure -A -t nat PREROUTING -p tcp -j DNAT + + assert_called iptables "-t nat -C PREROUTING -p tcp -j DNAT" + assert_called iptables "-t nat -A PREROUTING -p tcp -j DNAT" +} + +@test "-t nat rule present: only the check runs, no append" { + export MOCK_IPTABLES_CHECK_EXIT=0 + load_main + + iptables_ensure -A -t nat OUTPUT -j REDIRECT + + assert_called iptables "-t nat -C OUTPUT -j REDIRECT" + run mock_calls iptables + refute_line --partial "-t nat -A OUTPUT" +} diff --git a/test/unit/parse_arguments.bats b/test/unit/parse_arguments.bats new file mode 100644 index 0000000..7b9f057 --- /dev/null +++ b/test/unit/parse_arguments.bats @@ -0,0 +1,86 @@ +#!/usr/bin/env bats +# +# Unit tests for parse_arguments - the option loop that populates the SG +# identity globals, proxy settings, and behavior flags. +# +# parse_arguments sets GLOBALS as a side effect. `run` would execute it in a +# subshell where those assignments are invisible, so success cases call it +# directly and then assert the global. Dying cases use `run` to capture exit. + +setup() { + load "${BATS_TEST_DIRNAME}/../helpers/load.bash" + load_main +} + +@test "parse_arguments sets SG_NODE_TOKEN from --sg-node-token" { + parse_arguments --sg-node-token my-token + assert_equal "$SG_NODE_TOKEN" "my-token" +} + +@test "parse_arguments sets ORGANIZATION_ID from --organization" { + parse_arguments --organization my-org + assert_equal "$ORGANIZATION_ID" "my-org" +} + +@test "parse_arguments sets RUNNER_GROUP_ID from --runner-group" { + parse_arguments --runner-group my-group + assert_equal "$RUNNER_GROUP_ID" "my-group" +} + +@test "parse_arguments sets all three identity globals together" { + parse_arguments \ + --sg-node-token tok --organization org --runner-group grp + assert_equal "$SG_NODE_TOKEN" "tok" + assert_equal "$ORGANIZATION_ID" "org" + assert_equal "$RUNNER_GROUP_ID" "grp" +} + +@test "parse_arguments sets HTTP_PROXY from a valid --http-proxy" { + parse_arguments --http-proxy host:80 + assert_equal "$HTTP_PROXY" "host:80" +} + +@test "parse_arguments dies on a malformed --http-proxy" { + run parse_arguments --http-proxy "bad;cmd" + assert_failure + assert_output --partial "Invalid proxy format" +} + +@test "parse_arguments appends --no-proxy to the default NO_PROXY" { + # The default NO_PROXY already carries the ECS/SSM bypass addresses; a + # user-supplied value must be appended (comma-joined), not replace them. + local before="$NO_PROXY" + parse_arguments --no-proxy 10.1.2.3 + assert_equal "$NO_PROXY" "${before},10.1.2.3" +} + +@test "parse_arguments -f sets FORCE_PASS" { + parse_arguments -f + assert_equal "$FORCE_PASS" "true" +} + +@test "parse_arguments --force sets FORCE_PASS" { + parse_arguments --force + assert_equal "$FORCE_PASS" "true" +} + +@test "parse_arguments --no-clean-on-fail sets NO_CLEAN_ON_FAIL" { + parse_arguments --no-clean-on-fail + assert_equal "$NO_CLEAN_ON_FAIL" "true" +} + +@test "parse_arguments --debug sets LOG_DEBUG" { + parse_arguments --debug + assert_equal "$LOG_DEBUG" "true" +} + +@test "parse_arguments dies on an unknown argument" { + run parse_arguments --bogus + assert_failure + assert_output --partial "Invalid argument" +} + +@test "parse_arguments returns cleanly on an empty arg list" { + run parse_arguments + assert_success +} diff --git a/test/unit/patch_json.bats b/test/unit/patch_json.bats new file mode 100644 index 0000000..0cd5b84 --- /dev/null +++ b/test/unit/patch_json.bats @@ -0,0 +1,66 @@ +#!/usr/bin/env bats +# +# Unit tests for patch_json: deep-merges a patch object into a JSON file via +# `jq -s '.[0] * .[1]'`. New file (no existing) -> just the patch; existing +# file -> merged; conflicting key -> patch wins. + +setup() { + load "${BATS_TEST_DIRNAME}/../helpers/load.bash" + # patch_json opens with a bare `debug` call. Under bats' `set -e`, a non-debug + # run makes that call return 1 and abort the function before the jq merge + # (main.sh itself runs without `set -e`, so this only bites under test). + # Enabling debug makes `debug` return 0, exercising the real merge path. + export LOG_DEBUG="true" +} + +@test "patch_json on a non-existent file writes just the patch" { + load_main + local file="${SG_TEST_TMPDIR}/new.json" + + patch_json "$file" '{"a":1,"b":{"c":2}}' + + assert_equal "$(jq -r '.a' "$file")" "1" + assert_equal "$(jq -r '.b.c' "$file")" "2" +} + +@test "patch_json on an empty file writes just the patch" { + load_main + local file="${SG_TEST_TMPDIR}/empty.json" + : >"$file" + + patch_json "$file" '{"x":"y"}' + + assert_equal "$(jq -r '.x' "$file")" "y" +} + +@test "patch_json merges new keys into an existing file" { + load_main + local file="${SG_TEST_TMPDIR}/existing.json" + echo '{"keep":"me"}' >"$file" + + patch_json "$file" '{"added":"value"}' + + assert_equal "$(jq -r '.keep' "$file")" "me" + assert_equal "$(jq -r '.added' "$file")" "value" +} + +@test "patch_json deep-merges nested objects" { + load_main + local file="${SG_TEST_TMPDIR}/nested.json" + echo '{"proxies":{"default":{"httpProxy":"old"}}}' >"$file" + + patch_json "$file" '{"proxies":{"default":{"httpsProxy":"new"}}}' + + assert_equal "$(jq -r '.proxies.default.httpProxy' "$file")" "old" + assert_equal "$(jq -r '.proxies.default.httpsProxy' "$file")" "new" +} + +@test "patch_json overwrites a conflicting key with the patch value" { + load_main + local file="${SG_TEST_TMPDIR}/conflict.json" + echo '{"k":"original"}' >"$file" + + patch_json "$file" '{"k":"replaced"}' + + assert_equal "$(jq -r '.k' "$file")" "replaced" +} diff --git a/test/unit/predicates.bats b/test/unit/predicates.bats new file mode 100644 index 0000000..c956435 --- /dev/null +++ b/test/unit/predicates.bats @@ -0,0 +1,78 @@ +#!/usr/bin/env bats +# +# Unit tests for the boolean predicates: is_debug, force_exec, no_clean_on_fail. +# is_debug is case-insensitive (LOG_DEBUG lowercased); force_exec and +# no_clean_on_fail require an exact `true`. + +setup() { + load "${BATS_TEST_DIRNAME}/../helpers/load.bash" +} + +# --- is_debug ------------------------------------------------------------- + +@test "is_debug true when LOG_DEBUG=true" { + export LOG_DEBUG="true" + load_main + run is_debug + assert_success +} + +@test "is_debug true when LOG_DEBUG=TRUE (case-insensitive)" { + export LOG_DEBUG="TRUE" + load_main + run is_debug + assert_success +} + +@test "is_debug false when LOG_DEBUG=false" { + export LOG_DEBUG="false" + load_main + run is_debug + assert_failure +} + +@test "is_debug false when LOG_DEBUG unset/empty" { + export LOG_DEBUG="" + load_main + run is_debug + assert_failure +} + +# --- force_exec ----------------------------------------------------------- + +@test "force_exec true when FORCE_PASS=true" { + export FORCE_PASS="true" + load_main + run force_exec + assert_success +} + +@test "force_exec false when FORCE_PASS=TRUE (exact match only)" { + export FORCE_PASS="TRUE" + load_main + run force_exec + assert_failure +} + +@test "force_exec false when FORCE_PASS=false" { + export FORCE_PASS="false" + load_main + run force_exec + assert_failure +} + +# --- no_clean_on_fail ----------------------------------------------------- + +@test "no_clean_on_fail true when NO_CLEAN_ON_FAIL=true" { + export NO_CLEAN_ON_FAIL="true" + load_main + run no_clean_on_fail + assert_success +} + +@test "no_clean_on_fail false when NO_CLEAN_ON_FAIL=false" { + export NO_CLEAN_ON_FAIL="false" + load_main + run no_clean_on_fail + assert_failure +} diff --git a/test/unit/update_diagnostic.bats b/test/unit/update_diagnostic.bats new file mode 100644 index 0000000..48c0bc9 --- /dev/null +++ b/test/unit/update_diagnostic.bats @@ -0,0 +1,61 @@ +#!/usr/bin/env bats +# +# Unit tests for init_diagnostic_dir + update_diagnostic. init_diagnostic_dir +# creates SG_DIAGNOSTIC_FILE as `{}` under the temp dir; update_diagnostic sets +# a (possibly nested) key via jq and atomically replaces the file. + +setup() { + load "${BATS_TEST_DIRNAME}/../helpers/load.bash" +} + +@test "init_diagnostic_dir creates the dir and an empty JSON object" { + load_main + + init_diagnostic_dir + + assert [ -d "$SG_DIAGNOSTIC_DIR" ] + assert [ -f "$SG_DIAGNOSTIC_FILE" ] + assert_equal "$(cat "$SG_DIAGNOSTIC_FILE")" "{}" +} + +@test "update_diagnostic sets a nested key" { + load_main + init_diagnostic_dir + + update_diagnostic "health.docker" "active" + + assert_equal "$(jq -r '.health.docker' "$SG_DIAGNOSTIC_FILE")" "active" +} + +@test "update_diagnostic accumulates multiple updates" { + load_main + init_diagnostic_dir + + update_diagnostic "health.docker" "active" + update_diagnostic "health.ecs" "inactive" + update_diagnostic "system.last_check" "now" + + assert_equal "$(jq -r '.health.docker' "$SG_DIAGNOSTIC_FILE")" "active" + assert_equal "$(jq -r '.health.ecs' "$SG_DIAGNOSTIC_FILE")" "inactive" + assert_equal "$(jq -r '.system.last_check' "$SG_DIAGNOSTIC_FILE")" "now" +} + +@test "update_diagnostic overwrites an existing key" { + load_main + init_diagnostic_dir + + update_diagnostic "health.docker" "active" + update_diagnostic "health.docker" "inactive" + + assert_equal "$(jq -r '.health.docker' "$SG_DIAGNOSTIC_FILE")" "inactive" +} + +@test "init_diagnostic_dir does not clobber an existing diagnostic file" { + load_main + init_diagnostic_dir + update_diagnostic "health.docker" "active" + + init_diagnostic_dir + + assert_equal "$(jq -r '.health.docker' "$SG_DIAGNOSTIC_FILE")" "active" +} diff --git a/test/unit/validate_proxy_format.bats b/test/unit/validate_proxy_format.bats new file mode 100644 index 0000000..c3155dc --- /dev/null +++ b/test/unit/validate_proxy_format.bats @@ -0,0 +1,66 @@ +#!/usr/bin/env bats +# +# Unit tests for validate_proxy_format - rejects anything that is not a bare +# hostname:port or IP:port, to block command injection through the proxy arg. + +setup() { + load "${BATS_TEST_DIRNAME}/../helpers/load.bash" + load_main +} + +@test "validate_proxy_format accepts hostname:port" { + run validate_proxy_format "host:8080" + assert_success +} + +@test "validate_proxy_format accepts IP:port" { + run validate_proxy_format "10.0.0.1:3128" + assert_success +} + +@test "validate_proxy_format accepts dotted hostname:port" { + run validate_proxy_format "my-proxy.example.com:80" + assert_success +} + +@test "validate_proxy_format rejects host with no port" { + run validate_proxy_format "host" + assert_failure + assert_output --partial "Invalid proxy format" +} + +@test "validate_proxy_format rejects trailing colon with no port" { + run validate_proxy_format "host:" + assert_failure + assert_output --partial "Invalid proxy format" +} + +@test "validate_proxy_format rejects non-numeric port" { + run validate_proxy_format "host:abc" + assert_failure + assert_output --partial "Invalid proxy format" +} + +@test "validate_proxy_format rejects shell metacharacters in host" { + run validate_proxy_format "bad;cmd:80" + assert_failure + assert_output --partial "Invalid proxy format" +} + +@test "validate_proxy_format rejects scheme prefix" { + run validate_proxy_format "http://host:80" + assert_failure + assert_output --partial "Invalid proxy format" +} + +@test "validate_proxy_format rejects double port" { + run validate_proxy_format "host:80:90" + assert_failure + assert_output --partial "Invalid proxy format" +} + +@test "validate_proxy_format rejects empty input" { + run validate_proxy_format "" + assert_failure + assert_output --partial "Invalid proxy format" +} diff --git a/test/unit/validate_runner_id.bats b/test/unit/validate_runner_id.bats new file mode 100644 index 0000000..d674a07 --- /dev/null +++ b/test/unit/validate_runner_id.bats @@ -0,0 +1,50 @@ +#!/usr/bin/env bats +# +# Unit tests for validate_runner_id - allows only [A-Za-z0-9_-], to block +# injection through the runner id. + +setup() { + load "${BATS_TEST_DIRNAME}/../helpers/load.bash" + load_main +} + +@test "validate_runner_id accepts alphanumerics, hyphen, underscore" { + run validate_runner_id "abc-123_DEF" + assert_success +} + +@test "validate_runner_id rejects shell metacharacters" { + run validate_runner_id "bad;id" + assert_failure + assert_output --partial "Invalid RUNNER_ID format" +} + +@test "validate_runner_id rejects whitespace" { + run validate_runner_id "a b" + assert_failure + assert_output --partial "Invalid RUNNER_ID format" +} + +@test "validate_runner_id rejects command substitution" { + run validate_runner_id 'id$(x)' + assert_failure + assert_output --partial "Invalid RUNNER_ID format" +} + +@test "validate_runner_id rejects path traversal" { + run validate_runner_id "../x" + assert_failure + assert_output --partial "Invalid RUNNER_ID format" +} + +@test "validate_runner_id rejects empty input" { + run validate_runner_id "" + assert_failure + assert_output --partial "Invalid RUNNER_ID format" +} + +@test "validate_runner_id rejects forward slash" { + run validate_runner_id "a/b" + assert_failure + assert_output --partial "Invalid RUNNER_ID format" +}