From 01b9e2d4f74433574549712090b2990b3b8082b6 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 06:29:52 -0700 Subject: [PATCH 01/20] Add CI with unit and integration tests for the install and management scripts A GitHub Actions workflow runs shellcheck, bats unit tests for proxy-agent-manager.sh, Pester unit tests for proxy-agent-manager.ps1 under Windows PowerShell 5.1, and integration tests that run the installer and management script against a real Docker daemon: Linux containers on Ubuntu, Windows containers on Windows Server 2022 and 2025. The unit tests run the scripts as black boxes with docker and the registry lookup stubbed. The integration tests use a stub agent image that only writes the heartbeat file, plus a small local server that stands in for the registry tag listing and the management script download, so they need no Fivetran credentials. To make that possible the scripts read three environment variables, PROXY_AGENT_IMAGE, PROXY_AGENT_REGISTRY_TAGS_URL and PROXY_AGENT_SCRIPT_URL, which override the image repository, registry URL and management script URL. Without them behavior is unchanged. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 62 ++++++++ README.md | 28 ++++ install.ps1 | 6 +- install.sh | 6 +- proxy-agent-manager.ps1 | 6 +- proxy-agent-manager.sh | 6 +- tests/bash/manager.bats | 151 ++++++++++++++++++ tests/integration/fixtures/config.json | 5 + tests/integration/linux.sh | 128 +++++++++++++++ tests/integration/registry_server.py | 46 ++++++ tests/integration/windows.ps1 | 155 ++++++++++++++++++ tests/pwsh/Manager.Tests.ps1 | 212 +++++++++++++++++++++++++ tests/stub/Dockerfile.linux | 7 + tests/stub/Dockerfile.windows | 8 + tests/stub/heartbeat.ps1 | 14 ++ tests/stub/heartbeat.sh | 16 ++ 16 files changed, 848 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/bash/manager.bats create mode 100644 tests/integration/fixtures/config.json create mode 100755 tests/integration/linux.sh create mode 100644 tests/integration/registry_server.py create mode 100644 tests/integration/windows.ps1 create mode 100644 tests/pwsh/Manager.Tests.ps1 create mode 100644 tests/stub/Dockerfile.linux create mode 100644 tests/stub/Dockerfile.windows create mode 100644 tests/stub/heartbeat.ps1 create mode 100755 tests/stub/heartbeat.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..99a9fc8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + shellcheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: shellcheck install.sh proxy-agent-manager.sh tests/integration/linux.sh tests/stub/heartbeat.sh + + bats: + name: Unit tests (bash) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: sudo apt-get update && sudo apt-get install -y bats + - run: bats tests/bash + + pester: + name: Unit tests (PowerShell 5.1, ${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Run Pester + # Windows PowerShell 5.1 is what customers run the scripts with, so the + # tests run there rather than under pwsh. + shell: powershell + run: | + if (-not (Get-Module -ListAvailable Pester | Where-Object Version -ge '5.0')) { + Install-Module Pester -MinimumVersion 5.0 -Force -SkipPublisherCheck -Scope CurrentUser + } + Import-Module Pester -MinimumVersion 5.0 + Invoke-Pester -Path tests/pwsh -CI + + integration-linux: + name: Integration (Linux containers) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: tests/integration/linux.sh + + integration-windows: + name: Integration (Windows containers, ${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - name: Run integration test + shell: powershell + run: tests/integration/windows.ps1 diff --git a/README.md b/README.md index 4e3527c..55123bc 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,34 @@ Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser **Volume mount issues** — Docker Desktop for Windows translates Windows paths in volume mounts automatically. If you see an empty config inside the container, ensure the install directory path does not contain special characters. +## Development + +CI (`.github/workflows/ci.yml`) runs on every pull request: + +- `shellcheck` on the shell scripts. +- Unit tests for the management scripts: `tests/bash/manager.bats` (bats) and `tests/pwsh/Manager.Tests.ps1` (Pester 5 under Windows PowerShell 5.1). Both run the script with stubbed `docker` and registry calls, so they need no Docker daemon. +- Integration tests that run the installer and management script against a real Docker daemon: `tests/integration/linux.sh` on Ubuntu, and `tests/integration/windows.ps1` on Windows Server 2022 and 2025 with Windows containers. They use a stub agent image (`tests/stub/`) that only writes the heartbeat file, and a local stand-in for the registry and the script download (`tests/integration/registry_server.py`), so they need no Fivetran credentials. + +To run them locally: + +```bash +bats tests/bash +tests/integration/linux.sh # needs Docker +``` + +```powershell +Invoke-Pester -Path tests/pwsh +tests\integration\windows.ps1 # needs Docker in Windows-containers mode +``` + +The scripts read these environment variables so the tests can point them at the stub image and local server. They are not meant for production use: + +| Variable | Read by | Replaces | +|----------|---------|----------| +| `PROXY_AGENT_IMAGE` | management scripts | the agent image repository | +| `PROXY_AGENT_REGISTRY_TAGS_URL` | installers and management scripts | the registry tag listing URL | +| `PROXY_AGENT_SCRIPT_URL` | installers | the management script download URL | + ## License This project is licensed under the [Apache License 2.0](./LICENSE). diff --git a/install.ps1 b/install.ps1 index a646fb4..bfa5d7a 100644 --- a/install.ps1 +++ b/install.ps1 @@ -29,8 +29,10 @@ $MIN_RECOMMENDED_DISK_SPACE_MB = 2048 # for a new base image on upgrade. Sized from the 1.2.9 images (fivetran/engineering#330090); revisit when the image changes. $MIN_RECOMMENDED_WINDOWS_DISK_SPACE_MB = 12288 $AGENT_SCRIPT = 'proxy-agent-manager.ps1' -$AGENT_SCRIPT_URL = 'https://raw.githubusercontent.com/fivetran/proxy_agent/main/proxy-agent-manager.ps1' -$REGISTRY_TAGS_URL = 'https://us-docker.pkg.dev/v2/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent/tags/list' +# PROXY_AGENT_SCRIPT_URL and PROXY_AGENT_REGISTRY_TAGS_URL are overridable so the +# test suite can install the checked-out management script against a stub registry. +$AGENT_SCRIPT_URL = if ($env:PROXY_AGENT_SCRIPT_URL) { $env:PROXY_AGENT_SCRIPT_URL } else { 'https://raw.githubusercontent.com/fivetran/proxy_agent/main/proxy-agent-manager.ps1' } +$REGISTRY_TAGS_URL = if ($env:PROXY_AGENT_REGISTRY_TAGS_URL) { $env:PROXY_AGENT_REGISTRY_TAGS_URL } else { 'https://us-docker.pkg.dev/v2/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent/tags/list' } $DEFAULT_FIVETRAN_API_URL = 'https://api.fivetran.com' $SETTINGS_FILE = 'settings.ps1' diff --git a/install.sh b/install.sh index a275e18..eec8fb2 100755 --- a/install.sh +++ b/install.sh @@ -50,8 +50,10 @@ MIN_RECOMMENDED_CONTAINER_MEMORY_MB=5120 # proxy agent container should get at HOST_RESERVED_MEMORY_MB=1024 # leave at least this much RAM for the host on Linux MIN_RECOMMENDED_DISK_SPACE_MB=2048 AGENT_SCRIPT="proxy-agent-manager.sh" -AGENT_SCRIPT_URL="https://raw.githubusercontent.com/fivetran/proxy_agent/main/proxy-agent-manager.sh" -REGISTRY_TAGS_URL="https://us-docker.pkg.dev/v2/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent/tags/list" +# PROXY_AGENT_SCRIPT_URL and PROXY_AGENT_REGISTRY_TAGS_URL are overridable so the +# test suite can install the checked-out management script against a stub registry. +AGENT_SCRIPT_URL="${PROXY_AGENT_SCRIPT_URL:-https://raw.githubusercontent.com/fivetran/proxy_agent/main/proxy-agent-manager.sh}" +REGISTRY_TAGS_URL="${PROXY_AGENT_REGISTRY_TAGS_URL:-https://us-docker.pkg.dev/v2/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent/tags/list}" SETTINGS_FILE="settings.sh" WARNINGS=() diff --git a/proxy-agent-manager.ps1 b/proxy-agent-manager.ps1 index 93474a1..f4ddedb 100644 --- a/proxy-agent-manager.ps1 +++ b/proxy-agent-manager.ps1 @@ -7,7 +7,9 @@ param( $ErrorActionPreference = 'Stop' $BASE_DIR = $PSScriptRoot -$IMAGE = 'us-docker.pkg.dev/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent' +# PROXY_AGENT_IMAGE and PROXY_AGENT_REGISTRY_TAGS_URL are overridable so the +# test suite can run against a stub image and registry. +$IMAGE = if ($env:PROXY_AGENT_IMAGE) { $env:PROXY_AGENT_IMAGE } else { 'us-docker.pkg.dev/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent' } $CONFIG_FILE = Join-Path $BASE_DIR 'config\config.json' $VERSION_FILE = Join-Path $BASE_DIR 'version' $LOGFILE = Join-Path $BASE_DIR 'logs\proxy-agent-manager.log' @@ -171,7 +173,7 @@ function Get-LatestVersion { $registryHost = $IMAGE.Split('/')[0] $repositoryPath = $IMAGE.Substring($registryHost.Length + 1) - $registryUrl = "https://$registryHost/v2/$repositoryPath/tags/list" + $registryUrl = if ($env:PROXY_AGENT_REGISTRY_TAGS_URL) { $env:PROXY_AGENT_REGISTRY_TAGS_URL } else { "https://$registryHost/v2/$repositoryPath/tags/list" } try { $tagsJson = Invoke-RestMethod -Uri $registryUrl -Method Get diff --git a/proxy-agent-manager.sh b/proxy-agent-manager.sh index 5a703f1..8439eb1 100755 --- a/proxy-agent-manager.sh +++ b/proxy-agent-manager.sh @@ -8,7 +8,9 @@ set -euo pipefail BASE_DIR="$(cd "$(dirname "$0")" && pwd)" -IMAGE="us-docker.pkg.dev/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent" +# PROXY_AGENT_IMAGE and PROXY_AGENT_REGISTRY_TAGS_URL are overridable so the +# test suite can run against a stub image and registry. +IMAGE="${PROXY_AGENT_IMAGE:-us-docker.pkg.dev/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent}" CONFIG_FILE="$BASE_DIR/config/config.json" VERSION_FILE="$BASE_DIR/version" SETTINGS_FILE="$BASE_DIR/settings.sh" @@ -62,7 +64,7 @@ MEMORY_ALLOCATION_MB="${MEMORY_ALLOCATION_MB:-5120}" get_latest_version() { local registry_host="${IMAGE%%/*}" local repository_path="${IMAGE#*/}" - local registry_url="https://${registry_host}/v2/${repository_path}/tags/list" + local registry_url="${PROXY_AGENT_REGISTRY_TAGS_URL:-https://${registry_host}/v2/${repository_path}/tags/list}" local tags_json tags_json=$(curl -s -f "$registry_url") || { echo "ERROR: Unable to query image registry for latest version" >&2 diff --git a/tests/bash/manager.bats b/tests/bash/manager.bats new file mode 100644 index 0000000..f8d6ca0 --- /dev/null +++ b/tests/bash/manager.bats @@ -0,0 +1,151 @@ +#!/usr/bin/env bats +# +# Unit tests for proxy-agent-manager.sh. The script is run as a black box with +# stub `docker` and `curl` commands on PATH, so no Docker daemon is needed. +# + +REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" +IMAGE="us-docker.pkg.dev/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent" + +setup() { + AGENT_DIR="$(mktemp -d)" + STUB_BIN="$AGENT_DIR/bin" + mkdir -p "$STUB_BIN" "$AGENT_DIR/config" + cp "$REPO_ROOT/proxy-agent-manager.sh" "$AGENT_DIR/" + echo '{"agent_id": "abc123"}' > "$AGENT_DIR/config/config.json" + echo "1.2.9-ubuntu-26.04" > "$AGENT_DIR/version" + + # State shared with the stubs. DOCKER_LAST_IMAGE exists while a container + # "runs" and holds the image it was started from. + export DOCKER_LOG="$AGENT_DIR/docker.log" + export DOCKER_LAST_IMAGE="$AGENT_DIR/docker.image" + export DOCKER_UNHEALTHY_TAG="" + export STUB_TAGS='["1.2.9-ubuntu-26.04","1.2.10-ubuntu-26.04","1.2.11","1.2.10-windows-ltsc2022"]' + + cat > "$STUB_BIN/docker" <<'EOF' +#!/usr/bin/env bash +echo "$*" >> "$DOCKER_LOG" +case "$1" in + run) + for arg in "$@"; do + case "$arg" in *proxy-agent:*) echo "$arg" > "$DOCKER_LAST_IMAGE" ;; esac + done + ;; + inspect) + [ -f "$DOCKER_LAST_IMAGE" ] || exit 1 + if [ "$2" = "-f" ]; then + case "$(cat "$DOCKER_LAST_IMAGE")" in + *"${DOCKER_UNHEALTHY_TAG:-__none__}"*) echo unhealthy ;; + *) echo healthy ;; + esac + fi + ;; + stop|rm) + rm -f "$DOCKER_LAST_IMAGE" + ;; + ps) + echo "NAMES IMAGE STATUS" + ;; +esac +EOF + cat > "$STUB_BIN/curl" <<'EOF' +#!/usr/bin/env bash +echo "{\"tags\": $STUB_TAGS}" +EOF + chmod +x "$STUB_BIN/docker" "$STUB_BIN/curl" + export PATH="$STUB_BIN:$PATH" +} + +teardown() { + rm -rf "$AGENT_DIR" +} + +manager() { + "$AGENT_DIR/proxy-agent-manager.sh" "$@" +} + +last_image() { + cat "$DOCKER_LAST_IMAGE" +} + +@test "start runs the pinned Ubuntu image with the default memory limit" { + run manager start + [ "$status" -eq 0 ] + [ "$(last_image)" = "$IMAGE:1.2.9-ubuntu-26.04" ] + grep -q -- "--memory=5120m" "$DOCKER_LOG" +} + +@test "start honors MEMORY_ALLOCATION_MB from settings.sh" { + echo "MEMORY_ALLOCATION_MB=2048" > "$AGENT_DIR/settings.sh" + run manager start + [ "$status" -eq 0 ] + grep -q -- "--memory=2048m" "$DOCKER_LOG" +} + +@test "start mounts the config read-only and labels the container with the agent id" { + run manager start + [ "$status" -eq 0 ] + grep -q -- "$AGENT_DIR/config/config.json:/config/config.json:ro" "$DOCKER_LOG" + grep -q -- "--name proxy-agent-abc123" "$DOCKER_LOG" + grep -q -- "proxy_agent_id=abc123" "$DOCKER_LOG" +} + +@test "start fails when the config file is missing" { + rm "$AGENT_DIR/config/config.json" + run manager start + [ "$status" -eq 1 ] + [[ "$output" == *"Config file not found"* ]] +} + +@test "start fails when the version file is empty" { + : > "$AGENT_DIR/version" + run manager start + [ "$status" -eq 1 ] + [[ "$output" == *"is empty"* ]] +} + +@test "upgrade picks the newest Ubuntu tag and ignores bare and Windows tags" { + run manager upgrade + [ "$status" -eq 0 ] + [ "$(last_image)" = "$IMAGE:1.2.10-ubuntu-26.04" ] + [[ "$(cat "$AGENT_DIR/version")" == 1.2.10* ]] +} + +@test "upgrade is a no-op when already on the latest version" { + export STUB_TAGS='["1.2.9-ubuntu-26.04","1.2.11"]' + run manager upgrade + [ "$status" -eq 0 ] + [[ "$output" == *"Already running the latest version"* ]] + ! grep -q "^run" "$DOCKER_LOG" +} + +@test "upgrade rolls back when the new container is unhealthy" { + export DOCKER_UNHEALTHY_TAG="1.2.10" + run manager upgrade + [ "$status" -eq 0 ] + [[ "$output" == *"rolling back"* ]] + [ "$(last_image)" = "$IMAGE:1.2.9-ubuntu-26.04" ] + [[ "$(cat "$AGENT_DIR/version")" == 1.2.9* ]] +} + +@test "upgrade rejects an invalid version file entry" { + echo "2.0" > "$AGENT_DIR/version" + run manager upgrade + [ "$status" -eq 1 ] + [[ "$output" == *"Invalid"* ]] +} + +@test "stop removes the running container" { + manager start + run manager stop + [ "$status" -eq 0 ] + [ ! -f "$DOCKER_LAST_IMAGE" ] + grep -q "^stop proxy-agent-abc123" "$DOCKER_LOG" + grep -q "^rm proxy-agent-abc123" "$DOCKER_LOG" +} + +@test "unknown command prints usage" { + run manager bogus + [ "$status" -eq 1 ] + [[ "$output" == *"Usage:"* ]] +} diff --git a/tests/integration/fixtures/config.json b/tests/integration/fixtures/config.json new file mode 100644 index 0000000..0ed8e98 --- /dev/null +++ b/tests/integration/fixtures/config.json @@ -0,0 +1,5 @@ +{ + "agent_id": "test-agent", + "orchestrator_host": "orchestrator.invalid", + "orchestrator_port": 443 +} diff --git a/tests/integration/linux.sh b/tests/integration/linux.sh new file mode 100755 index 0000000..ddff03a --- /dev/null +++ b/tests/integration/linux.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# +# Integration test for install.sh and proxy-agent-manager.sh on a real Docker +# daemon. Uses a stub agent image and a local stand-in for the registry and +# the script download, so it needs no Fivetran credentials or network access. +# +# Usage: tests/integration/linux.sh +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +PORT="${PORT:-8181}" +STUB_IMAGE="proxy-agent-stub" +SUFFIX="-ubuntu-26.04" +OLD_VERSION="1.2.3" +NEW_VERSION="1.2.4" +AGENT_ID="test-agent" # matches fixtures/config.json +CONTAINER="proxy-agent-${AGENT_ID}" +INSTALL_DIR="$(mktemp -d)/fivetran-proxy-agent" +SERVER_PID="" + +cleanup() { + if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true + rm -rf "$(dirname "$INSTALL_DIR")" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +current_image() { + docker inspect -f '{{.Config.Image}}' "$CONTAINER" +} + +assert_image() { + local actual + actual=$(current_image) + [ "$actual" = "$1" ] || fail "expected image $1, got $actual" +} + +assert_healthy() { + local status + status=$(docker inspect -f '{{.State.Health.Status}}' "$CONTAINER") + [ "$status" = "healthy" ] || fail "expected healthy container, got $status" +} + +assert_version_file() { + local content + content=$(cat "$INSTALL_DIR/version") + [[ "$content" == "$1"* ]] || fail "expected version file to start with $1, got '$content'" +} + +echo "== Building stub image" +docker build -q -f "$REPO_ROOT/tests/stub/Dockerfile.linux" -t "$STUB_IMAGE:$OLD_VERSION$SUFFIX" "$REPO_ROOT/tests/stub" +# The bare tags let the rollback step work whether the management script pins +# the plain version or the full image tag. +for tag in "$OLD_VERSION" "$NEW_VERSION" "$NEW_VERSION$SUFFIX"; do + docker tag "$STUB_IMAGE:$OLD_VERSION$SUFFIX" "$STUB_IMAGE:$tag" +done + +echo "== Starting stub registry on port $PORT" +python3 "$REPO_ROOT/tests/integration/registry_server.py" --port "$PORT" --root "$REPO_ROOT" \ + --tags "$OLD_VERSION,$OLD_VERSION$SUFFIX,$NEW_VERSION,$NEW_VERSION$SUFFIX,$NEW_VERSION-windows-ltsc2022" & +SERVER_PID=$! +for _ in $(seq 1 40); do + curl -sf "http://127.0.0.1:$PORT/proxy-agent-manager.sh" >/dev/null && break + sleep 0.25 +done +curl -sf "http://127.0.0.1:$PORT/proxy-agent-manager.sh" >/dev/null || fail "stub registry did not start" + +export RUNTIME=docker +export MEMORY_ALLOCATION_MB=256 +export PROXY_AGENT_IMAGE="$STUB_IMAGE" +export PROXY_AGENT_REGISTRY_TAGS_URL="http://127.0.0.1:$PORT/v2/stub/tags/list" +export PROXY_AGENT_SCRIPT_URL="http://127.0.0.1:$PORT/proxy-agent-manager.sh" + +echo "== Installing" +"$REPO_ROOT/install.sh" "$REPO_ROOT/tests/integration/fixtures/config.json" --install-dir "$INSTALL_DIR" +assert_image "$STUB_IMAGE:$NEW_VERSION$SUFFIX" +assert_healthy +assert_version_file "$NEW_VERSION" +grep -qx "MEMORY_ALLOCATION_MB=256" "$INSTALL_DIR/settings.sh" || fail "settings.sh does not record the memory allocation" +[ "$(docker inspect -f '{{.HostConfig.Memory}}' "$CONTAINER")" = "$((256 * 1024 * 1024))" ] || fail "container memory limit not applied" +config_mode=$(stat -c %a "$INSTALL_DIR/config/config.json" 2>/dev/null || stat -f %Lp "$INSTALL_DIR/config/config.json") +[ "$config_mode" = "600" ] || fail "config.json is not mode 600 (got $config_mode)" +cmp -s "$REPO_ROOT/tests/integration/fixtures/config.json" "$INSTALL_DIR/config/config.json" || fail "config.json was not copied verbatim" + +MANAGER="$INSTALL_DIR/proxy-agent-manager.sh" + +echo "== status" +"$MANAGER" status | grep -q "$CONTAINER" || fail "status does not list the container" + +echo "== Rolling back to $OLD_VERSION via the version file" +echo "$OLD_VERSION" > "$INSTALL_DIR/version" +"$MANAGER" start +case "$(current_image)" in + "$STUB_IMAGE:$OLD_VERSION"|"$STUB_IMAGE:$OLD_VERSION$SUFFIX") ;; + *) fail "expected rollback to $OLD_VERSION, got $(current_image)" ;; +esac +assert_healthy + +echo "== Upgrading" +"$MANAGER" upgrade +assert_image "$STUB_IMAGE:$NEW_VERSION$SUFFIX" +assert_healthy +assert_version_file "$NEW_VERSION" + +echo "== Upgrading again is a no-op" +"$MANAGER" upgrade | grep -q "Already running the latest version" || fail "second upgrade did not report already latest" +assert_image "$STUB_IMAGE:$NEW_VERSION$SUFFIX" + +echo "== restart" +"$MANAGER" restart +assert_healthy + +echo "== stop" +"$MANAGER" stop +if docker inspect "$CONTAINER" >/dev/null 2>&1; then + fail "container still exists after stop" +fi + +echo "PASS" diff --git a/tests/integration/registry_server.py b/tests/integration/registry_server.py new file mode 100644 index 0000000..1405719 --- /dev/null +++ b/tests/integration/registry_server.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Stand-in for the two remote endpoints the installers call. + +Serves ``/v2//tags/list`` as a container registry tag listing with +the tags given on the command line, and every other path as a static file from +``--root`` (the repository checkout), which is how the installers download the +management script under test instead of the one on GitHub's main branch. +""" +import argparse +import json +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", type=int, default=8181) + parser.add_argument("--root", required=True, help="directory to serve static files from") + parser.add_argument("--tags", required=True, help="comma-separated image tags to list") + args = parser.parse_args() + tags = args.tags.split(",") + + class Handler(SimpleHTTPRequestHandler): + def __init__(self, *handler_args, **handler_kwargs): + super().__init__(*handler_args, directory=args.root, **handler_kwargs) + + def do_GET(self): + if self.path.startswith("/v2/") and self.path.endswith("/tags/list"): + body = json.dumps({"name": "stub", "tags": tags}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + super().do_GET() + + def log_message(self, fmt, *log_args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler) + print(f"stub registry listening on http://127.0.0.1:{args.port}", flush=True) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/tests/integration/windows.ps1 b/tests/integration/windows.ps1 new file mode 100644 index 0000000..8cfac49 --- /dev/null +++ b/tests/integration/windows.ps1 @@ -0,0 +1,155 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS +Integration test for install.ps1 and proxy-agent-manager.ps1 with Windows +containers on a real Docker daemon. + +.DESCRIPTION +Uses a stub agent image built for the host's LTSC version and a local stand-in +for the registry and the script download, so it needs no Fivetran credentials +or network access beyond pulling the servercore base image. +#> +param( + [int]$Port = 8181 +) + +$ErrorActionPreference = 'Stop' + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$StubImage = 'proxy-agent-stub' +$OldVersion = '1.2.3' +$NewVersion = '1.2.4' +$AgentId = 'test-agent' # matches fixtures\config.json +$Container = "proxy-agent-$AgentId" +$TempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "proxy-agent-test-$PID" +$InstallDir = Join-Path $TempRoot 'fivetran-proxy-agent' +$Server = $null + +function Fail { + param([string]$Message) + throw "FAIL: $Message" +} + +function Get-LtscVersion { + $build = [int](Get-CimInstance -ClassName Win32_OperatingSystem).BuildNumber + switch ($build) { + 17763 { return 'ltsc2019' } + 20348 { return 'ltsc2022' } + 26100 { return 'ltsc2025' } + } + Fail "Unsupported Windows build $build; the stub image needs a matching servercore tag" +} + +function Get-CurrentImage { + return (docker inspect -f '{{.Config.Image}}' $Container).Trim() +} + +function Assert-Image { + param([string]$Expected) + $actual = Get-CurrentImage + if ($actual -ne $Expected) { Fail "expected image $Expected, got $actual" } +} + +function Assert-Healthy { + $status = (docker inspect -f '{{.State.Health.Status}}' $Container).Trim() + if ($status -ne 'healthy') { Fail "expected healthy container, got $status" } +} + +function Assert-VersionFile { + param([string]$Prefix) + $content = (Get-Content -LiteralPath (Join-Path $InstallDir 'version') -Raw).Trim() + if (-not $content.StartsWith($Prefix)) { Fail "expected version file to start with $Prefix, got '$content'" } +} + +function Invoke-Manager { + param([string]$Command) + $output = & (Join-Path $InstallDir 'proxy-agent-manager.ps1') $Command *>&1 | Out-String + Write-Host $output + if ($LASTEXITCODE -ne 0) { Fail "manager $Command exited with $LASTEXITCODE" } + return $output +} + +try { + $ltsc = Get-LtscVersion + $oldTag = "$OldVersion-windows-$ltsc" + $newTag = "$NewVersion-windows-$ltsc" + + Write-Host "== Building stub image for $ltsc" + docker build -q --build-arg "BASE_TAG=$ltsc" -f (Join-Path $RepoRoot 'tests\stub\Dockerfile.windows') -t "${StubImage}:$oldTag" (Join-Path $RepoRoot 'tests\stub') | Out-Host + if ($LASTEXITCODE -ne 0) { Fail 'stub image build failed' } + docker tag "${StubImage}:$oldTag" "${StubImage}:$newTag" + + Write-Host "== Starting stub registry on port $Port" + $tags = "$oldTag,$newTag,$NewVersion-ubuntu-26.04,$NewVersion,$NewVersion-windows-ltsc2016" + $Server = Start-Process -FilePath python -PassThru -NoNewWindow -ArgumentList @( + (Join-Path $RepoRoot 'tests\integration\registry_server.py'), + '--port', $Port, '--root', $RepoRoot, '--tags', $tags + ) + $ready = $false + foreach ($_ in 1..40) { + try { + $null = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/proxy-agent-manager.ps1" -UseBasicParsing -TimeoutSec 2 + $ready = $true + break + } catch { + Start-Sleep -Milliseconds 250 + } + } + if (-not $ready) { Fail 'stub registry did not start' } + + $env:RUNTIME = 'docker' + $env:MEMORY_ALLOCATION_MB = '512' + $env:PROXY_AGENT_IMAGE = $StubImage + $env:PROXY_AGENT_REGISTRY_TAGS_URL = "http://127.0.0.1:$Port/v2/stub/tags/list" + $env:PROXY_AGENT_SCRIPT_URL = "http://127.0.0.1:$Port/proxy-agent-manager.ps1" + + Write-Host '== Installing' + & (Join-Path $RepoRoot 'install.ps1') (Join-Path $RepoRoot 'tests\integration\fixtures\config.json') -InstallDir $InstallDir + if ($LASTEXITCODE -ne 0) { Fail "install.ps1 exited with $LASTEXITCODE" } + Assert-Image "${StubImage}:$newTag" + Assert-Healthy + Assert-VersionFile $NewVersion + $settings = Get-Content -LiteralPath (Join-Path $InstallDir 'settings.ps1') -Raw + if ($settings -notmatch '\$MEMORY_ALLOCATION_MB = 512') { Fail 'settings.ps1 does not record the memory allocation' } + $memory = [int64](docker inspect -f '{{.HostConfig.Memory}}' $Container).Trim() + if ($memory -ne 512MB) { Fail "container memory limit not applied (got $memory)" } + $expectedConfig = Get-Content -LiteralPath (Join-Path $RepoRoot 'tests\integration\fixtures\config.json') -Raw + $actualConfig = Get-Content -LiteralPath (Join-Path $InstallDir 'config\config.json') -Raw + if ($actualConfig -ne $expectedConfig) { Fail 'config.json was not copied verbatim' } + + Write-Host '== status' + $status = Invoke-Manager status + if ($status -notmatch [regex]::Escape($Container)) { Fail 'status does not list the container' } + + Write-Host "== Rolling back to $OldVersion via the version file" + Set-Content -LiteralPath (Join-Path $InstallDir 'version') -Value $OldVersion -Encoding ascii + $null = Invoke-Manager start + Assert-Image "${StubImage}:$oldTag" + Assert-Healthy + + Write-Host '== Upgrading' + $null = Invoke-Manager upgrade + Assert-Image "${StubImage}:$newTag" + Assert-Healthy + Assert-VersionFile $NewVersion + + Write-Host '== Upgrading again is a no-op' + $output = Invoke-Manager upgrade + if ($output -notmatch 'Already running the latest version') { Fail 'second upgrade did not report already latest' } + Assert-Image "${StubImage}:$newTag" + + Write-Host '== restart' + $null = Invoke-Manager restart + Assert-Healthy + + Write-Host '== stop' + $null = Invoke-Manager stop + docker inspect $Container 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { Fail 'container still exists after stop' } + + Write-Host 'PASS' +} finally { + if ($Server -and -not $Server.HasExited) { Stop-Process -Id $Server.Id -Force } + docker rm -f $Container 2>&1 | Out-Null + if (Test-Path -LiteralPath $TempRoot) { Remove-Item -LiteralPath $TempRoot -Recurse -Force } +} diff --git a/tests/pwsh/Manager.Tests.ps1 b/tests/pwsh/Manager.Tests.ps1 new file mode 100644 index 0000000..cfdc688 --- /dev/null +++ b/tests/pwsh/Manager.Tests.ps1 @@ -0,0 +1,212 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } +<# +.SYNOPSIS +Unit tests for proxy-agent-manager.ps1. + +.DESCRIPTION +The script is run as a black box with docker, Invoke-RestMethod and +Get-CimInstance mocked, so no Docker daemon is needed. Mocks defined here are +visible to the invoked script because it runs in a child scope. +#> + +BeforeAll { + $script:RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + $script:Image = 'us-docker.pkg.dev/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent' + + # Creates an installation directory with the management script, a config + # file and the given version file content, and returns the manager's path. + function New-AgentDir { + param( + [string]$Version = '1.2.9-ubuntu-26.04', + [string]$Settings + ) + $dir = Join-Path ([System.IO.Path]::GetTempPath()) ("proxy-agent-test-" + [guid]::NewGuid()) + $null = New-Item -ItemType Directory -Force -Path (Join-Path $dir 'config') + Copy-Item (Join-Path $script:RepoRoot 'proxy-agent-manager.ps1') $dir + Set-Content -LiteralPath (Join-Path $dir 'config\config.json') -Value '{"agent_id": "abc123"}' -Encoding ascii + Set-Content -LiteralPath (Join-Path $dir 'version') -Value $Version -Encoding ascii + if ($Settings) { + Set-Content -LiteralPath (Join-Path $dir 'settings.ps1') -Value $Settings -Encoding ascii + } + $script:AgentDir = $dir + return Join-Path $dir 'proxy-agent-manager.ps1' + } + + function Get-VersionFile { + return (Get-Content -LiteralPath (Join-Path $script:AgentDir 'version') -Raw).Trim() + } + + # Runs the manager, capturing every output stream as one string. + function Invoke-Manager { + param([string]$Manager, [string]$Command) + return (& $Manager $Command *>&1 | Out-String) + } +} + +BeforeEach { + # Mock state. LastImage is $null while no container "runs" and otherwise + # holds the image the container was started from. + $script:OsType = 'linux' + $script:LastImage = $null + $script:UnhealthyTag = $null + $script:DockerCalls = @() + $script:Tags = @('1.2.9-ubuntu-26.04', '1.2.10-ubuntu-26.04', '1.2.11', '1.2.10-windows-ltsc2022', '1.2.10-windows-ltsc2025') + + Mock docker { + $script:DockerCalls += ,@($args) + $global:LASTEXITCODE = 0 + switch ($args[0]) { + 'info' { return $script:OsType } + 'run' { + $script:LastImage = $args | Where-Object { $_ -like "*proxy-agent:*" } | Select-Object -First 1 + return 'container-id' + } + 'inspect' { + if (-not $script:LastImage) { + $global:LASTEXITCODE = 1 + return + } + if ($args[1] -eq '-f') { + if ($script:UnhealthyTag -and $script:LastImage -like "*$($script:UnhealthyTag)*") { return 'unhealthy' } + return 'healthy' + } + } + { $_ -in 'stop', 'rm' } { $script:LastImage = $null } + 'ps' { return 'NAMES IMAGE STATUS' } + } + } + Mock Invoke-RestMethod { return [pscustomobject]@{ tags = $script:Tags } } + if (Get-Command Get-CimInstance -ErrorAction SilentlyContinue) { + Mock Get-CimInstance { return [pscustomobject]@{ ProductType = 3; BuildNumber = 20348; Caption = 'Windows Server 2022' } } + } +} + +AfterEach { + if ($script:AgentDir -and (Test-Path -LiteralPath $script:AgentDir)) { + Remove-Item -LiteralPath $script:AgentDir -Recurse -Force + } +} + +Describe 'proxy-agent-manager.ps1 with Linux containers' { + It 'starts the pinned Ubuntu image with the default memory limit' { + $manager = New-AgentDir + $null = Invoke-Manager $manager start + $LASTEXITCODE | Should -Be 0 + $script:LastImage | Should -Be "$($script:Image):1.2.9-ubuntu-26.04" + ($script:DockerCalls | Where-Object { $_[0] -eq 'run' })[0] | Should -Contain '--memory=5120m' + } + + It 'honors MEMORY_ALLOCATION_MB from settings.ps1' { + $manager = New-AgentDir -Settings '$MEMORY_ALLOCATION_MB = 2048' + $null = Invoke-Manager $manager start + ($script:DockerCalls | Where-Object { $_[0] -eq 'run' })[0] | Should -Contain '--memory=2048m' + } + + It 'labels the container with the agent id' { + $manager = New-AgentDir + $null = Invoke-Manager $manager start + $runArgs = ($script:DockerCalls | Where-Object { $_[0] -eq 'run' })[0] + $runArgs | Should -Contain 'proxy-agent-abc123' + $runArgs | Should -Contain 'proxy_agent_id=abc123' + } + + It 'fails when the config file is missing' { + $manager = New-AgentDir + Remove-Item -LiteralPath (Join-Path $script:AgentDir 'config\config.json') + $output = Invoke-Manager $manager start + $LASTEXITCODE | Should -Be 1 + $output | Should -Match 'Config file not found' + } + + It 'fails when the version file is empty' { + $manager = New-AgentDir -Version '' + $output = Invoke-Manager $manager start + $LASTEXITCODE | Should -Be 1 + $output | Should -Match 'is empty' + } + + It 'refuses a Windows image tag' { + $manager = New-AgentDir -Version '1.2.9-windows-ltsc2022' + { & $manager start } | Should -Throw '*Windows containers*' + } + + It 'upgrades to the newest Ubuntu tag and ignores bare and Windows tags' { + $manager = New-AgentDir + $null = Invoke-Manager $manager upgrade + $LASTEXITCODE | Should -Be 0 + $script:LastImage | Should -Be "$($script:Image):1.2.10-ubuntu-26.04" + Get-VersionFile | Should -BeLike '1.2.10*' + } + + It 'does nothing on upgrade when already on the latest version' { + $script:Tags = @('1.2.9-ubuntu-26.04', '1.2.11') + $manager = New-AgentDir + $output = Invoke-Manager $manager upgrade + $LASTEXITCODE | Should -Be 0 + $output | Should -Match 'Already running the latest version' + $script:DockerCalls | Where-Object { $_[0] -eq 'run' } | Should -BeNullOrEmpty + } + + It 'rolls back when the upgraded container is unhealthy' { + $script:UnhealthyTag = '1.2.10' + $manager = New-AgentDir + $output = Invoke-Manager $manager upgrade + $output | Should -Match 'rolling back' + $script:LastImage | Should -Be "$($script:Image):1.2.9-ubuntu-26.04" + Get-VersionFile | Should -BeLike '1.2.9*' + } + + It 'rejects an invalid version file entry on upgrade' { + $manager = New-AgentDir -Version '2.0' + { & $manager upgrade } | Should -Throw '*Invalid*' + } + + It 'removes the running container on stop' { + $manager = New-AgentDir + $null = Invoke-Manager $manager start + $null = Invoke-Manager $manager stop + $LASTEXITCODE | Should -Be 0 + $script:LastImage | Should -BeNullOrEmpty + ($script:DockerCalls | Where-Object { $_[0] -eq 'rm' })[0] | Should -Contain 'proxy-agent-abc123' + } +} + +Describe 'proxy-agent-manager.ps1 with Windows containers' -Skip:(-not (Get-Command Get-CimInstance -ErrorAction SilentlyContinue)) { + BeforeEach { + $script:OsType = 'windows' + } + + It 'starts the image for the detected LTSC version from a plain version' { + $manager = New-AgentDir -Version '1.2.9' + $null = Invoke-Manager $manager start + $LASTEXITCODE | Should -Be 0 + $script:LastImage | Should -Be "$($script:Image):1.2.9-windows-ltsc2022" + } + + It 'starts a pinned Windows image tag as is' { + $manager = New-AgentDir -Version '1.2.9-windows-ltsc2025' + $null = Invoke-Manager $manager start + $script:LastImage | Should -Be "$($script:Image):1.2.9-windows-ltsc2025" + } + + It 'refuses an Ubuntu image tag' { + $manager = New-AgentDir -Version '1.2.9-ubuntu-26.04' + { & $manager start } | Should -Throw '*Linux containers*' + } + + It 'upgrades to the newest tag for the same LTSC version only' { + $manager = New-AgentDir -Version '1.2.9-windows-ltsc2022' + $null = Invoke-Manager $manager upgrade + $LASTEXITCODE | Should -Be 0 + $script:LastImage | Should -Be "$($script:Image):1.2.10-windows-ltsc2022" + Get-VersionFile | Should -BeLike '1.2.10*' + } + + It 'mounts the config directory and uses the Windows health check' { + $manager = New-AgentDir -Version '1.2.9' + $null = Invoke-Manager $manager start + $runArgs = ($script:DockerCalls | Where-Object { $_[0] -eq 'run' })[0] + ($runArgs | Where-Object { $_ -like '*:C:/config:ro' }) | Should -Not -BeNullOrEmpty + ($runArgs | Where-Object { $_ -like 'powershell.exe -NoProfile -EncodedCommand *' }) | Should -Not -BeNullOrEmpty + } +} diff --git a/tests/stub/Dockerfile.linux b/tests/stub/Dockerfile.linux new file mode 100644 index 0000000..f66774a --- /dev/null +++ b/tests/stub/Dockerfile.linux @@ -0,0 +1,7 @@ +# Stub proxy agent image for the integration tests. It only writes the heartbeat +# file the management script's health check reads, so the tests never need +# Fivetran credentials or network access. +FROM alpine:3.20 +COPY heartbeat.sh /heartbeat.sh +RUN chmod +x /heartbeat.sh +ENTRYPOINT ["/heartbeat.sh"] diff --git a/tests/stub/Dockerfile.windows b/tests/stub/Dockerfile.windows new file mode 100644 index 0000000..53e23a6 --- /dev/null +++ b/tests/stub/Dockerfile.windows @@ -0,0 +1,8 @@ +# Stub proxy agent image for the Windows-container integration tests. It only +# writes the heartbeat file the management script's health check reads. +# servercore is required because the health check runs powershell.exe inside +# the container. BASE_TAG must match the host's LTSC version. +ARG BASE_TAG=ltsc2022 +FROM mcr.microsoft.com/windows/servercore:${BASE_TAG} +COPY heartbeat.ps1 C:/heartbeat.ps1 +ENTRYPOINT ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "C:/heartbeat.ps1"] diff --git a/tests/stub/heartbeat.ps1 b/tests/stub/heartbeat.ps1 new file mode 100644 index 0000000..0bfdf3f --- /dev/null +++ b/tests/stub/heartbeat.ps1 @@ -0,0 +1,14 @@ +# Stub proxy agent: writes the heartbeat file the management script's health +# check reads, then idles. Any arguments (the real agent's -i ) are +# ignored. +$path = $env:HEARTBEAT_PATH +if (-not $path) { throw 'HEARTBEAT_PATH must be set' } +$expiry = if ($env:HEARTBEAT_EXPIRY_SECONDS) { [int]$env:HEARTBEAT_EXPIRY_SECONDS } else { 30 } + +Write-Host "stub proxy agent started with args: $args" +$null = New-Item -ItemType Directory -Force -Path (Split-Path -Parent $path) +while ($true) { + $expireAt = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $expiry + Set-Content -LiteralPath $path -Value "HEARTBEAT_EXPIRE_AT=$expireAt" -Encoding ascii + Start-Sleep -Seconds 5 +} diff --git a/tests/stub/heartbeat.sh b/tests/stub/heartbeat.sh new file mode 100755 index 0000000..7a3341a --- /dev/null +++ b/tests/stub/heartbeat.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# +# Stub proxy agent: writes the heartbeat file the management script's health +# check reads, then idles. Any arguments (the real agent's -i ) are +# ignored. +# +set -eu + +path="${HEARTBEAT_PATH:?HEARTBEAT_PATH must be set}" +expiry="${HEARTBEAT_EXPIRY_SECONDS:-30}" + +echo "stub proxy agent started with args: $*" +while true; do + echo "HEARTBEAT_EXPIRE_AT=$(( $(date +%s) + expiry ))" > "$path" + sleep 5 +done From 6a932cd49e5f6a45189d728cf0d1ce8af412314f Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 06:42:18 -0700 Subject: [PATCH 02/20] Fix first CI run: Pester layout, registry URL override, lint Pester 5 does not allow BeforeEach/AfterEach at file root, so the tests now live in one Describe with a Context per container mode. Get-LatestVersion in proxy-agent-manager.ps1 derived the registry host from the image name before checking PROXY_AGENT_REGISTRY_TAGS_URL, so an image name without a slash threw from Substring. The override is now checked first. shellcheck runs at warning severity and the one info-level finding in install.sh is rewritten as an if. PSScriptAnalyzer is added as a lint job, with Write-Host and ShouldProcess rules excluded via PSScriptAnalyzerSettings.psd1. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 14 +- PSScriptAnalyzerSettings.psd1 | 9 ++ README.md | 2 +- install.sh | 5 +- proxy-agent-manager.ps1 | 10 +- tests/integration/windows.ps1 | 1 + tests/pwsh/Manager.Tests.ps1 | 286 +++++++++++++++++----------------- 7 files changed, 180 insertions(+), 147 deletions(-) create mode 100644 PSScriptAnalyzerSettings.psd1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99a9fc8..4ca0dd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,19 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - run: shellcheck install.sh proxy-agent-manager.sh tests/integration/linux.sh tests/stub/heartbeat.sh + - run: shellcheck --severity=warning install.sh proxy-agent-manager.sh tests/integration/linux.sh tests/stub/heartbeat.sh + + psscriptanalyzer: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run PSScriptAnalyzer + shell: pwsh + run: | + Install-Module PSScriptAnalyzer -Force -Scope CurrentUser + $results = Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./PSScriptAnalyzerSettings.psd1 + $results | Format-Table -AutoSize | Out-String -Width 200 | Write-Host + if ($results) { exit 1 } bats: name: Unit tests (bash) diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..6df5094 --- /dev/null +++ b/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,9 @@ +@{ + Severity = @('Error', 'Warning') + ExcludeRules = @( + # The scripts are interactive installers; Write-Host is the intended output channel. + 'PSAvoidUsingWriteHost', + # Start/Stop-ProxyAgent are internal helpers of a CLI that already takes an explicit command. + 'PSUseShouldProcessForStateChangingFunctions' + ) +} diff --git a/README.md b/README.md index 55123bc..1cb0d6f 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser CI (`.github/workflows/ci.yml`) runs on every pull request: -- `shellcheck` on the shell scripts. +- `shellcheck` on the shell scripts and PSScriptAnalyzer (settings in `PSScriptAnalyzerSettings.psd1`) on the PowerShell scripts. - Unit tests for the management scripts: `tests/bash/manager.bats` (bats) and `tests/pwsh/Manager.Tests.ps1` (Pester 5 under Windows PowerShell 5.1). Both run the script with stubbed `docker` and registry calls, so they need no Docker daemon. - Integration tests that run the installer and management script against a real Docker daemon: `tests/integration/linux.sh` on Ubuntu, and `tests/integration/windows.ps1` on Windows Server 2022 and 2025 with Windows containers. They use a stub agent image (`tests/stub/`) that only writes the heartbeat file, and a local stand-in for the registry and the script download (`tests/integration/registry_server.py`), so they need no Fivetran credentials. diff --git a/install.sh b/install.sh index eec8fb2..c140b37 100755 --- a/install.sh +++ b/install.sh @@ -277,8 +277,9 @@ main() { local agent_id auth_token agent_id=$(extract_json_string "agent_id" "$config_path") auth_token=$(extract_json_string "auth_token" "$config_path") - [ -n "$agent_id" ] && [ -n "$auth_token" ] \ - || die "Could not extract credentials from WebSocket config" + if [ -z "$agent_id" ] || [ -z "$auth_token" ]; then + die "Could not extract credentials from WebSocket config" + fi TOKEN=$(printf '%s:%s' "$agent_id" "$auth_token" | base64 | tr -d '\n') config_path="" echo "Detected WebSocket agent configuration. Fetching updated config from Fivetran..." diff --git a/proxy-agent-manager.ps1 b/proxy-agent-manager.ps1 index f4ddedb..0c4546a 100644 --- a/proxy-agent-manager.ps1 +++ b/proxy-agent-manager.ps1 @@ -171,9 +171,13 @@ function Get-LatestVersion { [string]$WindowsVersion ) - $registryHost = $IMAGE.Split('/')[0] - $repositoryPath = $IMAGE.Substring($registryHost.Length + 1) - $registryUrl = if ($env:PROXY_AGENT_REGISTRY_TAGS_URL) { $env:PROXY_AGENT_REGISTRY_TAGS_URL } else { "https://$registryHost/v2/$repositoryPath/tags/list" } + if ($env:PROXY_AGENT_REGISTRY_TAGS_URL) { + $registryUrl = $env:PROXY_AGENT_REGISTRY_TAGS_URL + } else { + $registryHost = $IMAGE.Split('/')[0] + $repositoryPath = $IMAGE.Substring($registryHost.Length + 1) + $registryUrl = "https://$registryHost/v2/$repositoryPath/tags/list" + } try { $tagsJson = Invoke-RestMethod -Uri $registryUrl -Method Get diff --git a/tests/integration/windows.ps1 b/tests/integration/windows.ps1 index 8cfac49..a5c6c91 100644 --- a/tests/integration/windows.ps1 +++ b/tests/integration/windows.ps1 @@ -70,6 +70,7 @@ function Invoke-Manager { } try { + $null = New-Item -ItemType Directory -Force -Path $TempRoot $ltsc = Get-LtscVersion $oldTag = "$OldVersion-windows-$ltsc" $newTag = "$NewVersion-windows-$ltsc" diff --git a/tests/pwsh/Manager.Tests.ps1 b/tests/pwsh/Manager.Tests.ps1 index cfdc688..fbe2d49 100644 --- a/tests/pwsh/Manager.Tests.ps1 +++ b/tests/pwsh/Manager.Tests.ps1 @@ -41,172 +41,178 @@ BeforeAll { param([string]$Manager, [string]$Command) return (& $Manager $Command *>&1 | Out-String) } + + function Get-DockerRunArgs { + return ($script:DockerCalls | Where-Object { $_[0] -eq 'run' })[0] + } } -BeforeEach { - # Mock state. LastImage is $null while no container "runs" and otherwise - # holds the image the container was started from. - $script:OsType = 'linux' - $script:LastImage = $null - $script:UnhealthyTag = $null - $script:DockerCalls = @() - $script:Tags = @('1.2.9-ubuntu-26.04', '1.2.10-ubuntu-26.04', '1.2.11', '1.2.10-windows-ltsc2022', '1.2.10-windows-ltsc2025') - - Mock docker { - $script:DockerCalls += ,@($args) - $global:LASTEXITCODE = 0 - switch ($args[0]) { - 'info' { return $script:OsType } - 'run' { - $script:LastImage = $args | Where-Object { $_ -like "*proxy-agent:*" } | Select-Object -First 1 - return 'container-id' - } - 'inspect' { - if (-not $script:LastImage) { - $global:LASTEXITCODE = 1 - return +Describe 'proxy-agent-manager.ps1' { + BeforeEach { + # Mock state. LastImage is $null while no container "runs" and + # otherwise holds the image the container was started from. + $script:OsType = 'linux' + $script:LastImage = $null + $script:UnhealthyTag = $null + $script:DockerCalls = @() + $script:Tags = @('1.2.9-ubuntu-26.04', '1.2.10-ubuntu-26.04', '1.2.11', '1.2.10-windows-ltsc2022', '1.2.10-windows-ltsc2025') + + Mock docker { + $script:DockerCalls += ,@($args) + $global:LASTEXITCODE = 0 + switch ($args[0]) { + 'info' { return $script:OsType } + 'run' { + $script:LastImage = $args | Where-Object { $_ -like "*proxy-agent:*" } | Select-Object -First 1 + return 'container-id' } - if ($args[1] -eq '-f') { - if ($script:UnhealthyTag -and $script:LastImage -like "*$($script:UnhealthyTag)*") { return 'unhealthy' } - return 'healthy' + 'inspect' { + if (-not $script:LastImage) { + $global:LASTEXITCODE = 1 + return + } + if ($args[1] -eq '-f') { + if ($script:UnhealthyTag -and $script:LastImage -like "*$($script:UnhealthyTag)*") { return 'unhealthy' } + return 'healthy' + } } + { $_ -in 'stop', 'rm' } { $script:LastImage = $null } + 'ps' { return 'NAMES IMAGE STATUS' } } - { $_ -in 'stop', 'rm' } { $script:LastImage = $null } - 'ps' { return 'NAMES IMAGE STATUS' } + } + Mock Invoke-RestMethod { return [pscustomobject]@{ tags = $script:Tags } } + if (Get-Command Get-CimInstance -ErrorAction SilentlyContinue) { + Mock Get-CimInstance { return [pscustomobject]@{ ProductType = 3; BuildNumber = 20348; Caption = 'Windows Server 2022' } } } } - Mock Invoke-RestMethod { return [pscustomobject]@{ tags = $script:Tags } } - if (Get-Command Get-CimInstance -ErrorAction SilentlyContinue) { - Mock Get-CimInstance { return [pscustomobject]@{ ProductType = 3; BuildNumber = 20348; Caption = 'Windows Server 2022' } } - } -} -AfterEach { - if ($script:AgentDir -and (Test-Path -LiteralPath $script:AgentDir)) { - Remove-Item -LiteralPath $script:AgentDir -Recurse -Force + AfterEach { + if ($script:AgentDir -and (Test-Path -LiteralPath $script:AgentDir)) { + Remove-Item -LiteralPath $script:AgentDir -Recurse -Force + } } -} -Describe 'proxy-agent-manager.ps1 with Linux containers' { - It 'starts the pinned Ubuntu image with the default memory limit' { - $manager = New-AgentDir - $null = Invoke-Manager $manager start - $LASTEXITCODE | Should -Be 0 - $script:LastImage | Should -Be "$($script:Image):1.2.9-ubuntu-26.04" - ($script:DockerCalls | Where-Object { $_[0] -eq 'run' })[0] | Should -Contain '--memory=5120m' - } + Context 'with Linux containers' { + It 'starts the pinned Ubuntu image with the default memory limit' { + $manager = New-AgentDir + $null = Invoke-Manager $manager start + $LASTEXITCODE | Should -Be 0 + $script:LastImage | Should -Be "$($script:Image):1.2.9-ubuntu-26.04" + Get-DockerRunArgs | Should -Contain '--memory=5120m' + } - It 'honors MEMORY_ALLOCATION_MB from settings.ps1' { - $manager = New-AgentDir -Settings '$MEMORY_ALLOCATION_MB = 2048' - $null = Invoke-Manager $manager start - ($script:DockerCalls | Where-Object { $_[0] -eq 'run' })[0] | Should -Contain '--memory=2048m' - } + It 'honors MEMORY_ALLOCATION_MB from settings.ps1' { + $manager = New-AgentDir -Settings '$MEMORY_ALLOCATION_MB = 2048' + $null = Invoke-Manager $manager start + Get-DockerRunArgs | Should -Contain '--memory=2048m' + } - It 'labels the container with the agent id' { - $manager = New-AgentDir - $null = Invoke-Manager $manager start - $runArgs = ($script:DockerCalls | Where-Object { $_[0] -eq 'run' })[0] - $runArgs | Should -Contain 'proxy-agent-abc123' - $runArgs | Should -Contain 'proxy_agent_id=abc123' - } + It 'labels the container with the agent id' { + $manager = New-AgentDir + $null = Invoke-Manager $manager start + $runArgs = Get-DockerRunArgs + $runArgs | Should -Contain 'proxy-agent-abc123' + $runArgs | Should -Contain 'proxy_agent_id=abc123' + } - It 'fails when the config file is missing' { - $manager = New-AgentDir - Remove-Item -LiteralPath (Join-Path $script:AgentDir 'config\config.json') - $output = Invoke-Manager $manager start - $LASTEXITCODE | Should -Be 1 - $output | Should -Match 'Config file not found' - } + It 'fails when the config file is missing' { + $manager = New-AgentDir + Remove-Item -LiteralPath (Join-Path $script:AgentDir 'config\config.json') + $output = Invoke-Manager $manager start + $LASTEXITCODE | Should -Be 1 + $output | Should -Match 'Config file not found' + } - It 'fails when the version file is empty' { - $manager = New-AgentDir -Version '' - $output = Invoke-Manager $manager start - $LASTEXITCODE | Should -Be 1 - $output | Should -Match 'is empty' - } + It 'fails when the version file is empty' { + $manager = New-AgentDir -Version '' + $output = Invoke-Manager $manager start + $LASTEXITCODE | Should -Be 1 + $output | Should -Match 'is empty' + } - It 'refuses a Windows image tag' { - $manager = New-AgentDir -Version '1.2.9-windows-ltsc2022' - { & $manager start } | Should -Throw '*Windows containers*' - } + It 'refuses a Windows image tag' { + $manager = New-AgentDir -Version '1.2.9-windows-ltsc2022' + { & $manager start } | Should -Throw '*Windows containers*' + } - It 'upgrades to the newest Ubuntu tag and ignores bare and Windows tags' { - $manager = New-AgentDir - $null = Invoke-Manager $manager upgrade - $LASTEXITCODE | Should -Be 0 - $script:LastImage | Should -Be "$($script:Image):1.2.10-ubuntu-26.04" - Get-VersionFile | Should -BeLike '1.2.10*' - } + It 'upgrades to the newest Ubuntu tag and ignores bare and Windows tags' { + $manager = New-AgentDir + $null = Invoke-Manager $manager upgrade + $LASTEXITCODE | Should -Be 0 + $script:LastImage | Should -Be "$($script:Image):1.2.10-ubuntu-26.04" + Get-VersionFile | Should -BeLike '1.2.10*' + } - It 'does nothing on upgrade when already on the latest version' { - $script:Tags = @('1.2.9-ubuntu-26.04', '1.2.11') - $manager = New-AgentDir - $output = Invoke-Manager $manager upgrade - $LASTEXITCODE | Should -Be 0 - $output | Should -Match 'Already running the latest version' - $script:DockerCalls | Where-Object { $_[0] -eq 'run' } | Should -BeNullOrEmpty - } + It 'does nothing on upgrade when already on the latest version' { + $script:Tags = @('1.2.9-ubuntu-26.04', '1.2.11') + $manager = New-AgentDir + $output = Invoke-Manager $manager upgrade + $LASTEXITCODE | Should -Be 0 + $output | Should -Match 'Already running the latest version' + $script:DockerCalls | Where-Object { $_[0] -eq 'run' } | Should -BeNullOrEmpty + } - It 'rolls back when the upgraded container is unhealthy' { - $script:UnhealthyTag = '1.2.10' - $manager = New-AgentDir - $output = Invoke-Manager $manager upgrade - $output | Should -Match 'rolling back' - $script:LastImage | Should -Be "$($script:Image):1.2.9-ubuntu-26.04" - Get-VersionFile | Should -BeLike '1.2.9*' - } + It 'rolls back when the upgraded container is unhealthy' { + $script:UnhealthyTag = '1.2.10' + $manager = New-AgentDir + $output = Invoke-Manager $manager upgrade + $output | Should -Match 'rolling back' + $script:LastImage | Should -Be "$($script:Image):1.2.9-ubuntu-26.04" + Get-VersionFile | Should -BeLike '1.2.9*' + } - It 'rejects an invalid version file entry on upgrade' { - $manager = New-AgentDir -Version '2.0' - { & $manager upgrade } | Should -Throw '*Invalid*' - } + It 'rejects an invalid version file entry on upgrade' { + $manager = New-AgentDir -Version '2.0' + { & $manager upgrade } | Should -Throw '*Invalid*' + } - It 'removes the running container on stop' { - $manager = New-AgentDir - $null = Invoke-Manager $manager start - $null = Invoke-Manager $manager stop - $LASTEXITCODE | Should -Be 0 - $script:LastImage | Should -BeNullOrEmpty - ($script:DockerCalls | Where-Object { $_[0] -eq 'rm' })[0] | Should -Contain 'proxy-agent-abc123' + It 'removes the running container on stop' { + $manager = New-AgentDir + $null = Invoke-Manager $manager start + $null = Invoke-Manager $manager stop + $LASTEXITCODE | Should -Be 0 + $script:LastImage | Should -BeNullOrEmpty + ($script:DockerCalls | Where-Object { $_[0] -eq 'rm' })[0] | Should -Contain 'proxy-agent-abc123' + } } -} -Describe 'proxy-agent-manager.ps1 with Windows containers' -Skip:(-not (Get-Command Get-CimInstance -ErrorAction SilentlyContinue)) { - BeforeEach { - $script:OsType = 'windows' - } + Context 'with Windows containers' -Skip:(-not (Get-Command Get-CimInstance -ErrorAction SilentlyContinue)) { + BeforeEach { + $script:OsType = 'windows' + } - It 'starts the image for the detected LTSC version from a plain version' { - $manager = New-AgentDir -Version '1.2.9' - $null = Invoke-Manager $manager start - $LASTEXITCODE | Should -Be 0 - $script:LastImage | Should -Be "$($script:Image):1.2.9-windows-ltsc2022" - } + It 'starts the image for the detected LTSC version from a plain version' { + $manager = New-AgentDir -Version '1.2.9' + $null = Invoke-Manager $manager start + $LASTEXITCODE | Should -Be 0 + $script:LastImage | Should -Be "$($script:Image):1.2.9-windows-ltsc2022" + } - It 'starts a pinned Windows image tag as is' { - $manager = New-AgentDir -Version '1.2.9-windows-ltsc2025' - $null = Invoke-Manager $manager start - $script:LastImage | Should -Be "$($script:Image):1.2.9-windows-ltsc2025" - } + It 'starts a pinned Windows image tag as is' { + $manager = New-AgentDir -Version '1.2.9-windows-ltsc2025' + $null = Invoke-Manager $manager start + $script:LastImage | Should -Be "$($script:Image):1.2.9-windows-ltsc2025" + } - It 'refuses an Ubuntu image tag' { - $manager = New-AgentDir -Version '1.2.9-ubuntu-26.04' - { & $manager start } | Should -Throw '*Linux containers*' - } + It 'refuses an Ubuntu image tag' { + $manager = New-AgentDir -Version '1.2.9-ubuntu-26.04' + { & $manager start } | Should -Throw '*Linux containers*' + } - It 'upgrades to the newest tag for the same LTSC version only' { - $manager = New-AgentDir -Version '1.2.9-windows-ltsc2022' - $null = Invoke-Manager $manager upgrade - $LASTEXITCODE | Should -Be 0 - $script:LastImage | Should -Be "$($script:Image):1.2.10-windows-ltsc2022" - Get-VersionFile | Should -BeLike '1.2.10*' - } + It 'upgrades to the newest tag for the same LTSC version only' { + $manager = New-AgentDir -Version '1.2.9-windows-ltsc2022' + $null = Invoke-Manager $manager upgrade + $LASTEXITCODE | Should -Be 0 + $script:LastImage | Should -Be "$($script:Image):1.2.10-windows-ltsc2022" + Get-VersionFile | Should -BeLike '1.2.10*' + } - It 'mounts the config directory and uses the Windows health check' { - $manager = New-AgentDir -Version '1.2.9' - $null = Invoke-Manager $manager start - $runArgs = ($script:DockerCalls | Where-Object { $_[0] -eq 'run' })[0] - ($runArgs | Where-Object { $_ -like '*:C:/config:ro' }) | Should -Not -BeNullOrEmpty - ($runArgs | Where-Object { $_ -like 'powershell.exe -NoProfile -EncodedCommand *' }) | Should -Not -BeNullOrEmpty + It 'mounts the config directory and uses the Windows health check' { + $manager = New-AgentDir -Version '1.2.9' + $null = Invoke-Manager $manager start + $runArgs = Get-DockerRunArgs + ($runArgs | Where-Object { $_ -like '*:C:/config:ro' }) | Should -Not -BeNullOrEmpty + ($runArgs | Where-Object { $_ -like 'powershell.exe -NoProfile -EncodedCommand *' }) | Should -Not -BeNullOrEmpty + } } } From 6a7d8767371cd8e1c1a345890451ae1403c59ba0 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 06:52:45 -0700 Subject: [PATCH 03/20] Fix second CI run: stub docker as functions, lint findings, UTF-8 BOM The Pester tests replace docker, Invoke-RestMethod and Get-CimInstance with plain functions defined in BeforeAll instead of Pester mocks, which did not expose the call arguments to the stub. The Windows integration test checks for the stopped container with docker ps rather than docker inspect, whose stderr became a terminating error under ErrorActionPreference Stop. install.ps1 and proxy-agent-manager.ps1 get a UTF-8 BOM, which Windows PowerShell 5.1 needs to read their non-ASCII comments correctly, and the remaining PSScriptAnalyzer findings are either fixed or excluded with a reason in PSScriptAnalyzerSettings.psd1. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 4 +- PSScriptAnalyzerSettings.psd1 | 8 +++- install.ps1 | 2 +- proxy-agent-manager.ps1 | 2 +- tests/integration/windows.ps1 | 6 +-- tests/pwsh/Manager.Tests.ps1 | 73 ++++++++++++++++++++--------------- 6 files changed, 56 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ca0dd1..e853a94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,9 @@ jobs: - name: Run PSScriptAnalyzer shell: pwsh run: | - Install-Module PSScriptAnalyzer -Force -Scope CurrentUser + if (-not (Get-Module -ListAvailable PSScriptAnalyzer)) { + Install-Module PSScriptAnalyzer -Force -Scope CurrentUser + } $results = Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./PSScriptAnalyzerSettings.psd1 $results | Format-Table -AutoSize | Out-String -Width 200 | Write-Host if ($results) { exit 1 } diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 index 6df5094..810bfe5 100644 --- a/PSScriptAnalyzerSettings.psd1 +++ b/PSScriptAnalyzerSettings.psd1 @@ -4,6 +4,12 @@ # The scripts are interactive installers; Write-Host is the intended output channel. 'PSAvoidUsingWriteHost', # Start/Stop-ProxyAgent are internal helpers of a CLI that already takes an explicit command. - 'PSUseShouldProcessForStateChangingFunctions' + 'PSUseShouldProcessForStateChangingFunctions', + # `docker inspect` is used as an existence probe; its failure is the expected outcome. + 'PSAvoidUsingEmptyCatchBlock', + # Naming style only. + 'PSUseSingularNouns', + # Write-Log is a local helper; the rule flags it against a cmdlet of the same name in a PowerShell Core build. + 'PSAvoidOverwritingBuiltInCmdlets' ) } diff --git a/install.ps1 b/install.ps1 index bfa5d7a..725171e 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,4 +1,4 @@ -#Requires -Version 5.1 +#Requires -Version 5.1 # # Installer for Fivetran Proxy Agent on Windows using Docker # diff --git a/proxy-agent-manager.ps1 b/proxy-agent-manager.ps1 index 0c4546a..d0c0445 100644 --- a/proxy-agent-manager.ps1 +++ b/proxy-agent-manager.ps1 @@ -1,4 +1,4 @@ -#Requires -Version 5.1 +#Requires -Version 5.1 param( [Parameter(Position = 0)] [string]$Command diff --git a/tests/integration/windows.ps1 b/tests/integration/windows.ps1 index a5c6c91..3552f0e 100644 --- a/tests/integration/windows.ps1 +++ b/tests/integration/windows.ps1 @@ -87,7 +87,7 @@ try { '--port', $Port, '--root', $RepoRoot, '--tags', $tags ) $ready = $false - foreach ($_ in 1..40) { + foreach ($attempt in 1..40) { try { $null = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/proxy-agent-manager.ps1" -UseBasicParsing -TimeoutSec 2 $ready = $true @@ -145,8 +145,8 @@ try { Write-Host '== stop' $null = Invoke-Manager stop - docker inspect $Container 2>&1 | Out-Null - if ($LASTEXITCODE -eq 0) { Fail 'container still exists after stop' } + $remaining = docker ps -a --filter "name=$Container" --format '{{.Names}}' + if ($remaining) { Fail "container still exists after stop: $remaining" } Write-Host 'PASS' } finally { diff --git a/tests/pwsh/Manager.Tests.ps1 b/tests/pwsh/Manager.Tests.ps1 index fbe2d49..c38072d 100644 --- a/tests/pwsh/Manager.Tests.ps1 +++ b/tests/pwsh/Manager.Tests.ps1 @@ -5,8 +5,10 @@ Unit tests for proxy-agent-manager.ps1. .DESCRIPTION The script is run as a black box with docker, Invoke-RestMethod and -Get-CimInstance mocked, so no Docker daemon is needed. Mocks defined here are -visible to the invoked script because it runs in a child scope. +Get-CimInstance replaced by functions, so no Docker daemon is needed. Functions +defined in BeforeAll shadow the real commands for the invoked script because it +runs in a child scope. Plain functions are used instead of Pester mocks so the +stubs can read $args and share $script: state with the tests. #> BeforeAll { @@ -45,45 +47,52 @@ BeforeAll { function Get-DockerRunArgs { return ($script:DockerCalls | Where-Object { $_[0] -eq 'run' })[0] } + + # Stand-in for the docker CLI. Records every call and keeps one container's + # worth of state: LastImage is $null while no container "runs" and + # otherwise holds the image it was started from. + function docker { + $script:DockerCalls += ,@($args) + $global:LASTEXITCODE = 0 + switch ($args[0]) { + 'info' { return $script:OsType } + 'run' { + $script:LastImage = $args | Where-Object { $_ -like "*proxy-agent:*" } | Select-Object -First 1 + return 'container-id' + } + 'inspect' { + if (-not $script:LastImage) { + $global:LASTEXITCODE = 1 + return + } + if ($args[1] -eq '-f') { + if ($script:UnhealthyTag -and $script:LastImage -like "*$($script:UnhealthyTag)*") { return 'unhealthy' } + return 'healthy' + } + } + { $_ -in 'stop', 'rm' } { $script:LastImage = $null } + 'ps' { return 'NAMES IMAGE STATUS' } + } + } + + # Stand-in for the registry tag listing. + function Invoke-RestMethod { + return [pscustomobject]@{ tags = $script:Tags } + } + + # Stand-in for the host OS lookup, reporting Windows Server 2022. + function Get-CimInstance { + return [pscustomobject]@{ ProductType = 3; BuildNumber = 20348; Caption = 'Windows Server 2022' } + } } Describe 'proxy-agent-manager.ps1' { BeforeEach { - # Mock state. LastImage is $null while no container "runs" and - # otherwise holds the image the container was started from. $script:OsType = 'linux' $script:LastImage = $null $script:UnhealthyTag = $null $script:DockerCalls = @() $script:Tags = @('1.2.9-ubuntu-26.04', '1.2.10-ubuntu-26.04', '1.2.11', '1.2.10-windows-ltsc2022', '1.2.10-windows-ltsc2025') - - Mock docker { - $script:DockerCalls += ,@($args) - $global:LASTEXITCODE = 0 - switch ($args[0]) { - 'info' { return $script:OsType } - 'run' { - $script:LastImage = $args | Where-Object { $_ -like "*proxy-agent:*" } | Select-Object -First 1 - return 'container-id' - } - 'inspect' { - if (-not $script:LastImage) { - $global:LASTEXITCODE = 1 - return - } - if ($args[1] -eq '-f') { - if ($script:UnhealthyTag -and $script:LastImage -like "*$($script:UnhealthyTag)*") { return 'unhealthy' } - return 'healthy' - } - } - { $_ -in 'stop', 'rm' } { $script:LastImage = $null } - 'ps' { return 'NAMES IMAGE STATUS' } - } - } - Mock Invoke-RestMethod { return [pscustomobject]@{ tags = $script:Tags } } - if (Get-Command Get-CimInstance -ErrorAction SilentlyContinue) { - Mock Get-CimInstance { return [pscustomobject]@{ ProductType = 3; BuildNumber = 20348; Caption = 'Windows Server 2022' } } - } } AfterEach { From 541ab70459bf17d89f0d38b24ff549091fa9c40e Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 06:54:47 -0700 Subject: [PATCH 04/20] Keep Pester stub state in a global Functions are dynamically scoped in PowerShell, so inside the docker stub called from the manager script $script: resolved to the manager's scope and every stub returned nothing. The stubs now share state through $global:ProxyAgentStub, which both the tests and the invoked script see. Co-Authored-By: Claude Fable 5.1 --- tests/pwsh/Manager.Tests.ps1 | 66 +++++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/tests/pwsh/Manager.Tests.ps1 b/tests/pwsh/Manager.Tests.ps1 index c38072d..f72032b 100644 --- a/tests/pwsh/Manager.Tests.ps1 +++ b/tests/pwsh/Manager.Tests.ps1 @@ -7,8 +7,11 @@ Unit tests for proxy-agent-manager.ps1. The script is run as a black box with docker, Invoke-RestMethod and Get-CimInstance replaced by functions, so no Docker daemon is needed. Functions defined in BeforeAll shadow the real commands for the invoked script because it -runs in a child scope. Plain functions are used instead of Pester mocks so the -stubs can read $args and share $script: state with the tests. +runs in a child scope. + +The stubs keep their state in $global:ProxyAgentStub. PowerShell scopes +dynamically, so inside a stub called from the manager script $script: refers to +the manager's scope, not this file's; only a global is visible to both. #> BeforeAll { @@ -45,39 +48,40 @@ BeforeAll { } function Get-DockerRunArgs { - return ($script:DockerCalls | Where-Object { $_[0] -eq 'run' })[0] + return ($global:ProxyAgentStub.DockerCalls | Where-Object { $_[0] -eq 'run' })[0] } # Stand-in for the docker CLI. Records every call and keeps one container's # worth of state: LastImage is $null while no container "runs" and # otherwise holds the image it was started from. function docker { - $script:DockerCalls += ,@($args) + $stub = $global:ProxyAgentStub + $stub.DockerCalls += ,@($args) $global:LASTEXITCODE = 0 switch ($args[0]) { - 'info' { return $script:OsType } + 'info' { return $stub.OsType } 'run' { - $script:LastImage = $args | Where-Object { $_ -like "*proxy-agent:*" } | Select-Object -First 1 + $stub.LastImage = $args | Where-Object { $_ -like "*proxy-agent:*" } | Select-Object -First 1 return 'container-id' } 'inspect' { - if (-not $script:LastImage) { + if (-not $stub.LastImage) { $global:LASTEXITCODE = 1 return } if ($args[1] -eq '-f') { - if ($script:UnhealthyTag -and $script:LastImage -like "*$($script:UnhealthyTag)*") { return 'unhealthy' } + if ($stub.UnhealthyTag -and $stub.LastImage -like "*$($stub.UnhealthyTag)*") { return 'unhealthy' } return 'healthy' } } - { $_ -in 'stop', 'rm' } { $script:LastImage = $null } + { $_ -in 'stop', 'rm' } { $stub.LastImage = $null } 'ps' { return 'NAMES IMAGE STATUS' } } } # Stand-in for the registry tag listing. function Invoke-RestMethod { - return [pscustomobject]@{ tags = $script:Tags } + return [pscustomobject]@{ tags = $global:ProxyAgentStub.Tags } } # Stand-in for the host OS lookup, reporting Windows Server 2022. @@ -86,13 +90,19 @@ BeforeAll { } } +AfterAll { + Remove-Variable -Name ProxyAgentStub -Scope Global -ErrorAction SilentlyContinue +} + Describe 'proxy-agent-manager.ps1' { BeforeEach { - $script:OsType = 'linux' - $script:LastImage = $null - $script:UnhealthyTag = $null - $script:DockerCalls = @() - $script:Tags = @('1.2.9-ubuntu-26.04', '1.2.10-ubuntu-26.04', '1.2.11', '1.2.10-windows-ltsc2022', '1.2.10-windows-ltsc2025') + $global:ProxyAgentStub = @{ + OsType = 'linux' + LastImage = $null + UnhealthyTag = $null + DockerCalls = @() + Tags = @('1.2.9-ubuntu-26.04', '1.2.10-ubuntu-26.04', '1.2.11', '1.2.10-windows-ltsc2022', '1.2.10-windows-ltsc2025') + } } AfterEach { @@ -106,7 +116,7 @@ Describe 'proxy-agent-manager.ps1' { $manager = New-AgentDir $null = Invoke-Manager $manager start $LASTEXITCODE | Should -Be 0 - $script:LastImage | Should -Be "$($script:Image):1.2.9-ubuntu-26.04" + $global:ProxyAgentStub.LastImage | Should -Be "$($script:Image):1.2.9-ubuntu-26.04" Get-DockerRunArgs | Should -Contain '--memory=5120m' } @@ -148,25 +158,25 @@ Describe 'proxy-agent-manager.ps1' { $manager = New-AgentDir $null = Invoke-Manager $manager upgrade $LASTEXITCODE | Should -Be 0 - $script:LastImage | Should -Be "$($script:Image):1.2.10-ubuntu-26.04" + $global:ProxyAgentStub.LastImage | Should -Be "$($script:Image):1.2.10-ubuntu-26.04" Get-VersionFile | Should -BeLike '1.2.10*' } It 'does nothing on upgrade when already on the latest version' { - $script:Tags = @('1.2.9-ubuntu-26.04', '1.2.11') + $global:ProxyAgentStub.Tags = @('1.2.9-ubuntu-26.04', '1.2.11') $manager = New-AgentDir $output = Invoke-Manager $manager upgrade $LASTEXITCODE | Should -Be 0 $output | Should -Match 'Already running the latest version' - $script:DockerCalls | Where-Object { $_[0] -eq 'run' } | Should -BeNullOrEmpty + Get-DockerRunArgs | Should -BeNullOrEmpty } It 'rolls back when the upgraded container is unhealthy' { - $script:UnhealthyTag = '1.2.10' + $global:ProxyAgentStub.UnhealthyTag = '1.2.10' $manager = New-AgentDir $output = Invoke-Manager $manager upgrade $output | Should -Match 'rolling back' - $script:LastImage | Should -Be "$($script:Image):1.2.9-ubuntu-26.04" + $global:ProxyAgentStub.LastImage | Should -Be "$($script:Image):1.2.9-ubuntu-26.04" Get-VersionFile | Should -BeLike '1.2.9*' } @@ -180,27 +190,27 @@ Describe 'proxy-agent-manager.ps1' { $null = Invoke-Manager $manager start $null = Invoke-Manager $manager stop $LASTEXITCODE | Should -Be 0 - $script:LastImage | Should -BeNullOrEmpty - ($script:DockerCalls | Where-Object { $_[0] -eq 'rm' })[0] | Should -Contain 'proxy-agent-abc123' + $global:ProxyAgentStub.LastImage | Should -BeNullOrEmpty + ($global:ProxyAgentStub.DockerCalls | Where-Object { $_[0] -eq 'rm' })[0] | Should -Contain 'proxy-agent-abc123' } } - Context 'with Windows containers' -Skip:(-not (Get-Command Get-CimInstance -ErrorAction SilentlyContinue)) { + Context 'with Windows containers' { BeforeEach { - $script:OsType = 'windows' + $global:ProxyAgentStub.OsType = 'windows' } It 'starts the image for the detected LTSC version from a plain version' { $manager = New-AgentDir -Version '1.2.9' $null = Invoke-Manager $manager start $LASTEXITCODE | Should -Be 0 - $script:LastImage | Should -Be "$($script:Image):1.2.9-windows-ltsc2022" + $global:ProxyAgentStub.LastImage | Should -Be "$($script:Image):1.2.9-windows-ltsc2022" } It 'starts a pinned Windows image tag as is' { $manager = New-AgentDir -Version '1.2.9-windows-ltsc2025' $null = Invoke-Manager $manager start - $script:LastImage | Should -Be "$($script:Image):1.2.9-windows-ltsc2025" + $global:ProxyAgentStub.LastImage | Should -Be "$($script:Image):1.2.9-windows-ltsc2025" } It 'refuses an Ubuntu image tag' { @@ -212,7 +222,7 @@ Describe 'proxy-agent-manager.ps1' { $manager = New-AgentDir -Version '1.2.9-windows-ltsc2022' $null = Invoke-Manager $manager upgrade $LASTEXITCODE | Should -Be 0 - $script:LastImage | Should -Be "$($script:Image):1.2.10-windows-ltsc2022" + $global:ProxyAgentStub.LastImage | Should -Be "$($script:Image):1.2.10-windows-ltsc2022" Get-VersionFile | Should -BeLike '1.2.10*' } From 73edf72282b597a947356a2877ddfab415f86682 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 06:59:29 -0700 Subject: [PATCH 05/20] Fix Pester helpers that flattened recorded docker calls Piping the array of recorded calls through Where-Object unrolled each call into strings, so Get-DockerRunArgs returned 'run'. A foreach loop returns the argument list intact. The deliberate global stub state is suppressed for PSAvoidGlobalVars in the test file. Co-Authored-By: Claude Fable 5.1 --- tests/pwsh/Manager.Tests.ps1 | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/pwsh/Manager.Tests.ps1 b/tests/pwsh/Manager.Tests.ps1 index f72032b..0a4f9ad 100644 --- a/tests/pwsh/Manager.Tests.ps1 +++ b/tests/pwsh/Manager.Tests.ps1 @@ -13,6 +13,8 @@ The stubs keep their state in $global:ProxyAgentStub. PowerShell scopes dynamically, so inside a stub called from the manager script $script: refers to the manager's scope, not this file's; only a global is visible to both. #> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '', Justification = 'Stub state must be global; see the description above.')] +param() BeforeAll { $script:RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path @@ -47,8 +49,19 @@ BeforeAll { return (& $Manager $Command *>&1 | Out-String) } + # Returns the argument list of the first recorded docker call for the + # given subcommand, or $null. A foreach loop is used because piping the + # recorded calls through Where-Object would flatten them into strings. + function Get-DockerCall { + param([string]$Subcommand) + foreach ($call in $global:ProxyAgentStub.DockerCalls) { + if ($call[0] -eq $Subcommand) { return ,$call } + } + return $null + } + function Get-DockerRunArgs { - return ($global:ProxyAgentStub.DockerCalls | Where-Object { $_[0] -eq 'run' })[0] + return Get-DockerCall 'run' } # Stand-in for the docker CLI. Records every call and keeps one container's @@ -191,7 +204,7 @@ Describe 'proxy-agent-manager.ps1' { $null = Invoke-Manager $manager stop $LASTEXITCODE | Should -Be 0 $global:ProxyAgentStub.LastImage | Should -BeNullOrEmpty - ($global:ProxyAgentStub.DockerCalls | Where-Object { $_[0] -eq 'rm' })[0] | Should -Contain 'proxy-agent-abc123' + Get-DockerCall 'rm' | Should -Contain 'proxy-agent-abc123' } } From a93604ec5fdc73f620410a92fcc6cf36c9f52292 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 07:00:59 -0700 Subject: [PATCH 06/20] Return docker call arguments unwrapped in the Pester helper Piping the helper straight into Should kept the comma wrapper as an extra array level. Returning the arguments plainly works both piped and assigned. Co-Authored-By: Claude Fable 5.1 --- tests/pwsh/Manager.Tests.ps1 | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/pwsh/Manager.Tests.ps1 b/tests/pwsh/Manager.Tests.ps1 index 0a4f9ad..b110835 100644 --- a/tests/pwsh/Manager.Tests.ps1 +++ b/tests/pwsh/Manager.Tests.ps1 @@ -49,15 +49,14 @@ BeforeAll { return (& $Manager $Command *>&1 | Out-String) } - # Returns the argument list of the first recorded docker call for the - # given subcommand, or $null. A foreach loop is used because piping the + # Returns the arguments of the first recorded docker call for the given + # subcommand, or nothing. A foreach loop is used because piping the # recorded calls through Where-Object would flatten them into strings. function Get-DockerCall { param([string]$Subcommand) foreach ($call in $global:ProxyAgentStub.DockerCalls) { - if ($call[0] -eq $Subcommand) { return ,$call } + if ($call[0] -eq $Subcommand) { return $call } } - return $null } function Get-DockerRunArgs { From 71dff03364163f710b45f7b223a42973cbb1d808 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 07:12:30 -0700 Subject: [PATCH 07/20] Address review: stubs as files, spell out that tests never hit the registry The bats docker and curl stubs move from heredocs into tests/bash/stubs so the test file reads as tests. The image repository constants in the bats and Pester tests are renamed and commented to make clear they only build expected strings, and the integration test headers state that the only network access is pulling the stub's base image. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 2 +- tests/bash/manager.bats | 54 ++++++++--------------------------- tests/bash/stubs/curl | 7 +++++ tests/bash/stubs/docker | 31 ++++++++++++++++++++ tests/integration/linux.sh | 6 ++-- tests/integration/windows.ps1 | 8 ++++-- tests/pwsh/Manager.Tests.ps1 | 17 ++++++----- 7 files changed, 70 insertions(+), 55 deletions(-) create mode 100755 tests/bash/stubs/curl create mode 100755 tests/bash/stubs/docker diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e853a94..2f36890 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - run: shellcheck --severity=warning install.sh proxy-agent-manager.sh tests/integration/linux.sh tests/stub/heartbeat.sh + - run: shellcheck --severity=warning install.sh proxy-agent-manager.sh tests/integration/linux.sh tests/stub/heartbeat.sh tests/bash/stubs/docker tests/bash/stubs/curl psscriptanalyzer: runs-on: ubuntu-latest diff --git a/tests/bash/manager.bats b/tests/bash/manager.bats index f8d6ca0..c06f651 100644 --- a/tests/bash/manager.bats +++ b/tests/bash/manager.bats @@ -1,59 +1,29 @@ #!/usr/bin/env bats # # Unit tests for proxy-agent-manager.sh. The script is run as a black box with -# stub `docker` and `curl` commands on PATH, so no Docker daemon is needed. +# the stub `docker` and `curl` commands from ./stubs on PATH, so no Docker +# daemon or network access is needed. The versions below are fixtures and do +# not depend on what is published in the real registry. # REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" -IMAGE="us-docker.pkg.dev/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent" +# The repository the manager hardcodes; only used to build expected image +# strings, the docker stub never pulls anything. +IMAGE_REPO="us-docker.pkg.dev/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent" setup() { AGENT_DIR="$(mktemp -d)" - STUB_BIN="$AGENT_DIR/bin" - mkdir -p "$STUB_BIN" "$AGENT_DIR/config" + mkdir -p "$AGENT_DIR/config" cp "$REPO_ROOT/proxy-agent-manager.sh" "$AGENT_DIR/" echo '{"agent_id": "abc123"}' > "$AGENT_DIR/config/config.json" echo "1.2.9-ubuntu-26.04" > "$AGENT_DIR/version" - # State shared with the stubs. DOCKER_LAST_IMAGE exists while a container - # "runs" and holds the image it was started from. + # State shared with the stubs in ./stubs; see the comments there. export DOCKER_LOG="$AGENT_DIR/docker.log" export DOCKER_LAST_IMAGE="$AGENT_DIR/docker.image" export DOCKER_UNHEALTHY_TAG="" export STUB_TAGS='["1.2.9-ubuntu-26.04","1.2.10-ubuntu-26.04","1.2.11","1.2.10-windows-ltsc2022"]' - - cat > "$STUB_BIN/docker" <<'EOF' -#!/usr/bin/env bash -echo "$*" >> "$DOCKER_LOG" -case "$1" in - run) - for arg in "$@"; do - case "$arg" in *proxy-agent:*) echo "$arg" > "$DOCKER_LAST_IMAGE" ;; esac - done - ;; - inspect) - [ -f "$DOCKER_LAST_IMAGE" ] || exit 1 - if [ "$2" = "-f" ]; then - case "$(cat "$DOCKER_LAST_IMAGE")" in - *"${DOCKER_UNHEALTHY_TAG:-__none__}"*) echo unhealthy ;; - *) echo healthy ;; - esac - fi - ;; - stop|rm) - rm -f "$DOCKER_LAST_IMAGE" - ;; - ps) - echo "NAMES IMAGE STATUS" - ;; -esac -EOF - cat > "$STUB_BIN/curl" <<'EOF' -#!/usr/bin/env bash -echo "{\"tags\": $STUB_TAGS}" -EOF - chmod +x "$STUB_BIN/docker" "$STUB_BIN/curl" - export PATH="$STUB_BIN:$PATH" + export PATH="$REPO_ROOT/tests/bash/stubs:$PATH" } teardown() { @@ -71,7 +41,7 @@ last_image() { @test "start runs the pinned Ubuntu image with the default memory limit" { run manager start [ "$status" -eq 0 ] - [ "$(last_image)" = "$IMAGE:1.2.9-ubuntu-26.04" ] + [ "$(last_image)" = "$IMAGE_REPO:1.2.9-ubuntu-26.04" ] grep -q -- "--memory=5120m" "$DOCKER_LOG" } @@ -107,7 +77,7 @@ last_image() { @test "upgrade picks the newest Ubuntu tag and ignores bare and Windows tags" { run manager upgrade [ "$status" -eq 0 ] - [ "$(last_image)" = "$IMAGE:1.2.10-ubuntu-26.04" ] + [ "$(last_image)" = "$IMAGE_REPO:1.2.10-ubuntu-26.04" ] [[ "$(cat "$AGENT_DIR/version")" == 1.2.10* ]] } @@ -124,7 +94,7 @@ last_image() { run manager upgrade [ "$status" -eq 0 ] [[ "$output" == *"rolling back"* ]] - [ "$(last_image)" = "$IMAGE:1.2.9-ubuntu-26.04" ] + [ "$(last_image)" = "$IMAGE_REPO:1.2.9-ubuntu-26.04" ] [[ "$(cat "$AGENT_DIR/version")" == 1.2.9* ]] } diff --git a/tests/bash/stubs/curl b/tests/bash/stubs/curl new file mode 100755 index 0000000..dc0443d --- /dev/null +++ b/tests/bash/stubs/curl @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# +# Stub curl for tests/bash/manager.bats. The manager only uses curl for the +# registry tag listing, so this answers every call with the tags in +# $STUB_TAGS (a JSON array) and never touches the network. +# +echo "{\"tags\": $STUB_TAGS}" diff --git a/tests/bash/stubs/docker b/tests/bash/stubs/docker new file mode 100755 index 0000000..8c49f18 --- /dev/null +++ b/tests/bash/stubs/docker @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# +# Stub docker CLI for tests/bash/manager.bats. Records every call to +# $DOCKER_LOG and keeps one container's worth of state: $DOCKER_LAST_IMAGE +# exists while a container "runs" and holds the image it was started from. +# Nothing is pulled or run. If $DOCKER_UNHEALTHY_TAG is set and the running +# image contains it, the health check reports unhealthy. +# +echo "$*" >> "$DOCKER_LOG" +case "$1" in + run) + for arg in "$@"; do + case "$arg" in *proxy-agent:*) echo "$arg" > "$DOCKER_LAST_IMAGE" ;; esac + done + ;; + inspect) + [ -f "$DOCKER_LAST_IMAGE" ] || exit 1 + if [ "$2" = "-f" ]; then + case "$(cat "$DOCKER_LAST_IMAGE")" in + *"${DOCKER_UNHEALTHY_TAG:-__none__}"*) echo unhealthy ;; + *) echo healthy ;; + esac + fi + ;; + stop|rm) + rm -f "$DOCKER_LAST_IMAGE" + ;; + ps) + echo "NAMES IMAGE STATUS" + ;; +esac diff --git a/tests/integration/linux.sh b/tests/integration/linux.sh index ddff03a..494d7d6 100755 --- a/tests/integration/linux.sh +++ b/tests/integration/linux.sh @@ -1,8 +1,10 @@ #!/usr/bin/env bash # # Integration test for install.sh and proxy-agent-manager.sh on a real Docker -# daemon. Uses a stub agent image and a local stand-in for the registry and -# the script download, so it needs no Fivetran credentials or network access. +# daemon. Uses a stub agent image built here (tests/stub) and a local stand-in +# for the registry and the script download, so it needs no Fivetran +# credentials and never contacts the real registry; the versions below are +# fixtures. The only network access is pulling the stub's alpine base image. # # Usage: tests/integration/linux.sh # diff --git a/tests/integration/windows.ps1 b/tests/integration/windows.ps1 index 3552f0e..a97c4df 100644 --- a/tests/integration/windows.ps1 +++ b/tests/integration/windows.ps1 @@ -5,9 +5,11 @@ Integration test for install.ps1 and proxy-agent-manager.ps1 with Windows containers on a real Docker daemon. .DESCRIPTION -Uses a stub agent image built for the host's LTSC version and a local stand-in -for the registry and the script download, so it needs no Fivetran credentials -or network access beyond pulling the servercore base image. +Uses a stub agent image built here (tests\stub) for the host's LTSC version and +a local stand-in for the registry and the script download, so it needs no +Fivetran credentials and never contacts the real registry; the versions below +are fixtures. The only network access is pulling the stub's servercore base +image. #> param( [int]$Port = 8181 diff --git a/tests/pwsh/Manager.Tests.ps1 b/tests/pwsh/Manager.Tests.ps1 index b110835..8b80b4b 100644 --- a/tests/pwsh/Manager.Tests.ps1 +++ b/tests/pwsh/Manager.Tests.ps1 @@ -18,7 +18,10 @@ param() BeforeAll { $script:RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path - $script:Image = 'us-docker.pkg.dev/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent' + # The repository the manager hardcodes; only used to build expected image + # strings. The docker stub never pulls anything and the tag list below is + # a fixture, so these tests do not depend on what is published. + $script:ImageRepo = 'us-docker.pkg.dev/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent' # Creates an installation directory with the management script, a config # file and the given version file content, and returns the manager's path. @@ -128,7 +131,7 @@ Describe 'proxy-agent-manager.ps1' { $manager = New-AgentDir $null = Invoke-Manager $manager start $LASTEXITCODE | Should -Be 0 - $global:ProxyAgentStub.LastImage | Should -Be "$($script:Image):1.2.9-ubuntu-26.04" + $global:ProxyAgentStub.LastImage | Should -Be "$($script:ImageRepo):1.2.9-ubuntu-26.04" Get-DockerRunArgs | Should -Contain '--memory=5120m' } @@ -170,7 +173,7 @@ Describe 'proxy-agent-manager.ps1' { $manager = New-AgentDir $null = Invoke-Manager $manager upgrade $LASTEXITCODE | Should -Be 0 - $global:ProxyAgentStub.LastImage | Should -Be "$($script:Image):1.2.10-ubuntu-26.04" + $global:ProxyAgentStub.LastImage | Should -Be "$($script:ImageRepo):1.2.10-ubuntu-26.04" Get-VersionFile | Should -BeLike '1.2.10*' } @@ -188,7 +191,7 @@ Describe 'proxy-agent-manager.ps1' { $manager = New-AgentDir $output = Invoke-Manager $manager upgrade $output | Should -Match 'rolling back' - $global:ProxyAgentStub.LastImage | Should -Be "$($script:Image):1.2.9-ubuntu-26.04" + $global:ProxyAgentStub.LastImage | Should -Be "$($script:ImageRepo):1.2.9-ubuntu-26.04" Get-VersionFile | Should -BeLike '1.2.9*' } @@ -216,13 +219,13 @@ Describe 'proxy-agent-manager.ps1' { $manager = New-AgentDir -Version '1.2.9' $null = Invoke-Manager $manager start $LASTEXITCODE | Should -Be 0 - $global:ProxyAgentStub.LastImage | Should -Be "$($script:Image):1.2.9-windows-ltsc2022" + $global:ProxyAgentStub.LastImage | Should -Be "$($script:ImageRepo):1.2.9-windows-ltsc2022" } It 'starts a pinned Windows image tag as is' { $manager = New-AgentDir -Version '1.2.9-windows-ltsc2025' $null = Invoke-Manager $manager start - $global:ProxyAgentStub.LastImage | Should -Be "$($script:Image):1.2.9-windows-ltsc2025" + $global:ProxyAgentStub.LastImage | Should -Be "$($script:ImageRepo):1.2.9-windows-ltsc2025" } It 'refuses an Ubuntu image tag' { @@ -234,7 +237,7 @@ Describe 'proxy-agent-manager.ps1' { $manager = New-AgentDir -Version '1.2.9-windows-ltsc2022' $null = Invoke-Manager $manager upgrade $LASTEXITCODE | Should -Be 0 - $global:ProxyAgentStub.LastImage | Should -Be "$($script:Image):1.2.10-windows-ltsc2022" + $global:ProxyAgentStub.LastImage | Should -Be "$($script:ImageRepo):1.2.10-windows-ltsc2022" Get-VersionFile | Should -BeLike '1.2.10*' } From 0f0addfaf579b7db166a4aa7da01e4d16e099d11 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 07:16:46 -0700 Subject: [PATCH 08/20] Generalize the curl stub and wait for Docker on Windows runners The curl stub echoes STUB_CURL_RESPONSE so tests can return any body. The Windows integration job first starts the Docker service if needed and waits for the daemon, which was not running on one windows-2025 runner, and the test's cleanup no longer turns its own errors into the reported failure. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 13 +++++++++++++ tests/bash/manager.bats | 5 +++-- tests/bash/stubs/curl | 7 +++---- tests/integration/windows.ps1 | 2 ++ 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f36890..a213bc8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,19 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@v4 + - name: Wait for the Docker daemon + # The Docker service is not always running when a Windows job starts. + shell: powershell + run: | + $service = Get-Service docker -ErrorAction SilentlyContinue + if ($service -and $service.Status -ne 'Running') { Start-Service docker } + foreach ($attempt in 1..30) { + docker info *> $null + if ($LASTEXITCODE -eq 0) { exit 0 } + Start-Sleep -Seconds 2 + } + docker info + exit 1 - name: Run integration test shell: powershell run: tests/integration/windows.ps1 diff --git a/tests/bash/manager.bats b/tests/bash/manager.bats index c06f651..0213fae 100644 --- a/tests/bash/manager.bats +++ b/tests/bash/manager.bats @@ -22,7 +22,8 @@ setup() { export DOCKER_LOG="$AGENT_DIR/docker.log" export DOCKER_LAST_IMAGE="$AGENT_DIR/docker.image" export DOCKER_UNHEALTHY_TAG="" - export STUB_TAGS='["1.2.9-ubuntu-26.04","1.2.10-ubuntu-26.04","1.2.11","1.2.10-windows-ltsc2022"]' + # Registry tag listing returned by the curl stub. + export STUB_CURL_RESPONSE='{"tags": ["1.2.9-ubuntu-26.04","1.2.10-ubuntu-26.04","1.2.11","1.2.10-windows-ltsc2022"]}' export PATH="$REPO_ROOT/tests/bash/stubs:$PATH" } @@ -82,7 +83,7 @@ last_image() { } @test "upgrade is a no-op when already on the latest version" { - export STUB_TAGS='["1.2.9-ubuntu-26.04","1.2.11"]' + export STUB_CURL_RESPONSE='{"tags": ["1.2.9-ubuntu-26.04","1.2.11"]}' run manager upgrade [ "$status" -eq 0 ] [[ "$output" == *"Already running the latest version"* ]] diff --git a/tests/bash/stubs/curl b/tests/bash/stubs/curl index dc0443d..bb9b63c 100755 --- a/tests/bash/stubs/curl +++ b/tests/bash/stubs/curl @@ -1,7 +1,6 @@ #!/usr/bin/env bash # -# Stub curl for tests/bash/manager.bats. The manager only uses curl for the -# registry tag listing, so this answers every call with the tags in -# $STUB_TAGS (a JSON array) and never touches the network. +# Stub curl for tests/bash/manager.bats. Answers every call with +# $STUB_CURL_RESPONSE and never touches the network. # -echo "{\"tags\": $STUB_TAGS}" +echo "$STUB_CURL_RESPONSE" diff --git a/tests/integration/windows.ps1 b/tests/integration/windows.ps1 index a97c4df..23e8e01 100644 --- a/tests/integration/windows.ps1 +++ b/tests/integration/windows.ps1 @@ -152,6 +152,8 @@ try { Write-Host 'PASS' } finally { + # Best-effort cleanup; a failure here must not hide the test's own error. + $ErrorActionPreference = 'Continue' if ($Server -and -not $Server.HasExited) { Stop-Process -Id $Server.Id -Force } docker rm -f $Container 2>&1 | Out-Null if (Test-Path -LiteralPath $TempRoot) { Remove-Item -LiteralPath $TempRoot -Recurse -Force } From ec1257ee6db33aae6e72f8d6e933e2aac3a89eff Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 07:22:08 -0700 Subject: [PATCH 09/20] Harden CI workflow and tests after review The workflow gets read-only token permissions, cancels superseded runs of the same branch, sets timeouts on every job and pins Pester to 5.x. The Linux integration test fails fast if the stub registry dies on bind and no longer greps pipeline output under pipefail; the Windows one quotes paths passed to Start-Process. Both unit suites now assert the registry URL requested, including the PROXY_AGENT_REGISTRY_TAGS_URL override, and the Windows health check test decodes the command rather than matching its prefix. README lists the local prerequisites. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 24 ++++++++++++++++++++---- README.md | 2 +- tests/bash/manager.bats | 12 ++++++++++++ tests/bash/stubs/curl | 5 +++-- tests/integration/linux.sh | 9 +++++++-- tests/integration/windows.ps1 | 5 +++-- tests/pwsh/Manager.Tests.ps1 | 22 ++++++++++++++++++++-- 7 files changed, 66 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a213bc8..c9c2bb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,15 +5,26 @@ on: branches: [main] pull_request: +permissions: + contents: read + +# A new push cancels the previous run of the same branch, which otherwise +# keeps two Windows runners busy for up to 45 minutes. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: shellcheck: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - run: shellcheck --severity=warning install.sh proxy-agent-manager.sh tests/integration/linux.sh tests/stub/heartbeat.sh tests/bash/stubs/docker tests/bash/stubs/curl psscriptanalyzer: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - name: Run PSScriptAnalyzer @@ -29,6 +40,7 @@ jobs: bats: name: Unit tests (bash) runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - run: sudo apt-get update && sudo apt-get install -y bats @@ -41,22 +53,26 @@ jobs: matrix: os: [windows-2022, windows-2025] runs-on: ${{ matrix.os }} + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Run Pester # Windows PowerShell 5.1 is what customers run the scripts with, so the - # tests run there rather than under pwsh. + # tests run there rather than under pwsh. Pester is pinned to 5.x since + # the tests use its syntax. shell: powershell run: | - if (-not (Get-Module -ListAvailable Pester | Where-Object Version -ge '5.0')) { - Install-Module Pester -MinimumVersion 5.0 -Force -SkipPublisherCheck -Scope CurrentUser + $pester = Get-Module -ListAvailable Pester | Where-Object { $_.Version -ge '5.0' -and $_.Version -lt '6.0' } + if (-not $pester) { + Install-Module Pester -MinimumVersion 5.0 -MaximumVersion 5.99.99 -Force -SkipPublisherCheck -Scope CurrentUser } - Import-Module Pester -MinimumVersion 5.0 + Import-Module Pester -MinimumVersion 5.0 -MaximumVersion 5.99.99 Invoke-Pester -Path tests/pwsh -CI integration-linux: name: Integration (Linux containers) runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - run: tests/integration/linux.sh diff --git a/README.md b/README.md index 1cb0d6f..efc110c 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ CI (`.github/workflows/ci.yml`) runs on every pull request: - Unit tests for the management scripts: `tests/bash/manager.bats` (bats) and `tests/pwsh/Manager.Tests.ps1` (Pester 5 under Windows PowerShell 5.1). Both run the script with stubbed `docker` and registry calls, so they need no Docker daemon. - Integration tests that run the installer and management script against a real Docker daemon: `tests/integration/linux.sh` on Ubuntu, and `tests/integration/windows.ps1` on Windows Server 2022 and 2025 with Windows containers. They use a stub agent image (`tests/stub/`) that only writes the heartbeat file, and a local stand-in for the registry and the script download (`tests/integration/registry_server.py`), so they need no Fivetran credentials. -To run them locally: +To run them locally you need Docker for the integration tests, `python3` (`python` on Windows) for the registry stand-in, [bats-core](https://github.com/bats-core/bats-core) for the bash tests and [Pester 5](https://pester.dev) for the PowerShell tests. The stub images pull `alpine` and `servercore` base images, so the integration tests need network access for that. ```bash bats tests/bash diff --git a/tests/bash/manager.bats b/tests/bash/manager.bats index 0213fae..7fe924c 100644 --- a/tests/bash/manager.bats +++ b/tests/bash/manager.bats @@ -20,6 +20,7 @@ setup() { # State shared with the stubs in ./stubs; see the comments there. export DOCKER_LOG="$AGENT_DIR/docker.log" + export CURL_LOG="$AGENT_DIR/curl.log" export DOCKER_LAST_IMAGE="$AGENT_DIR/docker.image" export DOCKER_UNHEALTHY_TAG="" # Registry tag listing returned by the curl stub. @@ -82,6 +83,17 @@ last_image() { [[ "$(cat "$AGENT_DIR/version")" == 1.2.10* ]] } +@test "upgrade queries the registry tag listing, or its override" { + run manager upgrade + [ "$status" -eq 0 ] + grep -q "https://us-docker.pkg.dev/v2/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent/tags/list" "$CURL_LOG" + + export PROXY_AGENT_REGISTRY_TAGS_URL="http://127.0.0.1:1/v2/stub/tags/list" + run manager upgrade + [ "$status" -eq 0 ] + grep -q "http://127.0.0.1:1/v2/stub/tags/list" "$CURL_LOG" +} + @test "upgrade is a no-op when already on the latest version" { export STUB_CURL_RESPONSE='{"tags": ["1.2.9-ubuntu-26.04","1.2.11"]}' run manager upgrade diff --git a/tests/bash/stubs/curl b/tests/bash/stubs/curl index bb9b63c..fddcf6e 100755 --- a/tests/bash/stubs/curl +++ b/tests/bash/stubs/curl @@ -1,6 +1,7 @@ #!/usr/bin/env bash # -# Stub curl for tests/bash/manager.bats. Answers every call with -# $STUB_CURL_RESPONSE and never touches the network. +# Stub curl for tests/bash/manager.bats. Records its arguments to $CURL_LOG, +# answers every call with $STUB_CURL_RESPONSE and never touches the network. # +echo "$*" >> "$CURL_LOG" echo "$STUB_CURL_RESPONSE" diff --git a/tests/integration/linux.sh b/tests/integration/linux.sh index 494d7d6..4bc66b4 100755 --- a/tests/integration/linux.sh +++ b/tests/integration/linux.sh @@ -71,6 +71,9 @@ python3 "$REPO_ROOT/tests/integration/registry_server.py" --port "$PORT" --root --tags "$OLD_VERSION,$OLD_VERSION$SUFFIX,$NEW_VERSION,$NEW_VERSION$SUFFIX,$NEW_VERSION-windows-ltsc2022" & SERVER_PID=$! for _ in $(seq 1 40); do + # The liveness check catches a server that died on bind (port in use), so + # the readiness check below cannot succeed against some other listener. + kill -0 "$SERVER_PID" 2>/dev/null || fail "stub registry exited; is port $PORT in use?" curl -sf "http://127.0.0.1:$PORT/proxy-agent-manager.sh" >/dev/null && break sleep 0.25 done @@ -96,7 +99,8 @@ cmp -s "$REPO_ROOT/tests/integration/fixtures/config.json" "$INSTALL_DIR/config/ MANAGER="$INSTALL_DIR/proxy-agent-manager.sh" echo "== status" -"$MANAGER" status | grep -q "$CONTAINER" || fail "status does not list the container" +status_output=$("$MANAGER" status) +[[ "$status_output" == *"$CONTAINER"* ]] || fail "status does not list the container" echo "== Rolling back to $OLD_VERSION via the version file" echo "$OLD_VERSION" > "$INSTALL_DIR/version" @@ -114,7 +118,8 @@ assert_healthy assert_version_file "$NEW_VERSION" echo "== Upgrading again is a no-op" -"$MANAGER" upgrade | grep -q "Already running the latest version" || fail "second upgrade did not report already latest" +upgrade_output=$("$MANAGER" upgrade) +[[ "$upgrade_output" == *"Already running the latest version"* ]] || fail "second upgrade did not report already latest" assert_image "$STUB_IMAGE:$NEW_VERSION$SUFFIX" echo "== restart" diff --git a/tests/integration/windows.ps1 b/tests/integration/windows.ps1 index 23e8e01..ae40f30 100644 --- a/tests/integration/windows.ps1 +++ b/tests/integration/windows.ps1 @@ -84,9 +84,10 @@ try { Write-Host "== Starting stub registry on port $Port" $tags = "$oldTag,$newTag,$NewVersion-ubuntu-26.04,$NewVersion,$NewVersion-windows-ltsc2016" + # Start-Process does not quote arguments, so paths are quoted here. $Server = Start-Process -FilePath python -PassThru -NoNewWindow -ArgumentList @( - (Join-Path $RepoRoot 'tests\integration\registry_server.py'), - '--port', $Port, '--root', $RepoRoot, '--tags', $tags + "`"$(Join-Path $RepoRoot 'tests\integration\registry_server.py')`"", + '--port', $Port, '--root', "`"$RepoRoot`"", '--tags', $tags ) $ready = $false foreach ($attempt in 1..40) { diff --git a/tests/pwsh/Manager.Tests.ps1 b/tests/pwsh/Manager.Tests.ps1 index 8b80b4b..dac3154 100644 --- a/tests/pwsh/Manager.Tests.ps1 +++ b/tests/pwsh/Manager.Tests.ps1 @@ -94,8 +94,10 @@ BeforeAll { } } - # Stand-in for the registry tag listing. + # Stand-in for the registry tag listing. Records the requested URL. function Invoke-RestMethod { + param([string]$Uri, [string]$Method) + $global:ProxyAgentStub.RegistryUrl = $Uri return [pscustomobject]@{ tags = $global:ProxyAgentStub.Tags } } @@ -116,11 +118,13 @@ Describe 'proxy-agent-manager.ps1' { LastImage = $null UnhealthyTag = $null DockerCalls = @() + RegistryUrl = $null Tags = @('1.2.9-ubuntu-26.04', '1.2.10-ubuntu-26.04', '1.2.11', '1.2.10-windows-ltsc2022', '1.2.10-windows-ltsc2025') } } AfterEach { + Remove-Item Env:\PROXY_AGENT_REGISTRY_TAGS_URL -ErrorAction SilentlyContinue if ($script:AgentDir -and (Test-Path -LiteralPath $script:AgentDir)) { Remove-Item -LiteralPath $script:AgentDir -Recurse -Force } @@ -177,6 +181,16 @@ Describe 'proxy-agent-manager.ps1' { Get-VersionFile | Should -BeLike '1.2.10*' } + It 'queries the registry tag listing, or its override, on upgrade' { + $manager = New-AgentDir + $null = Invoke-Manager $manager upgrade + $global:ProxyAgentStub.RegistryUrl | Should -Be "https://us-docker.pkg.dev/v2/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent/tags/list" + + $env:PROXY_AGENT_REGISTRY_TAGS_URL = 'http://127.0.0.1:1/v2/stub/tags/list' + $null = Invoke-Manager $manager upgrade + $global:ProxyAgentStub.RegistryUrl | Should -Be 'http://127.0.0.1:1/v2/stub/tags/list' + } + It 'does nothing on upgrade when already on the latest version' { $global:ProxyAgentStub.Tags = @('1.2.9-ubuntu-26.04', '1.2.11') $manager = New-AgentDir @@ -246,7 +260,11 @@ Describe 'proxy-agent-manager.ps1' { $null = Invoke-Manager $manager start $runArgs = Get-DockerRunArgs ($runArgs | Where-Object { $_ -like '*:C:/config:ro' }) | Should -Not -BeNullOrEmpty - ($runArgs | Where-Object { $_ -like 'powershell.exe -NoProfile -EncodedCommand *' }) | Should -Not -BeNullOrEmpty + $healthCmd = $runArgs | Where-Object { $_ -like 'powershell.exe -NoProfile -EncodedCommand *' } + $healthCmd | Should -Not -BeNullOrEmpty + $encoded = ($healthCmd -split ' ')[-1] + $decoded = [System.Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encoded)) + $decoded | Should -Match 'HEARTBEAT_EXPIRE_AT' } } } From 20a9a3e2e8a5bfeac8f350628fa622f5e3f0521c Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 07:30:55 -0700 Subject: [PATCH 10/20] Apply delta review: liveness check, Docker wait error mode, cancel only PRs The Windows integration test fails fast if the stub registry exits during the readiness loop, the Docker-daemon wait step keeps retrying instead of stopping on docker's first stderr line, runs on main are no longer cancelled by a later push, and the Pester default-URL test clears PROXY_AGENT_REGISTRY_TAGS_URL before running. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 10 +++++++--- tests/integration/windows.ps1 | 3 +++ tests/pwsh/Manager.Tests.ps1 | 2 ++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9c2bb1..3260d4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,11 +8,12 @@ on: permissions: contents: read -# A new push cancels the previous run of the same branch, which otherwise -# keeps two Windows runners busy for up to 45 minutes. +# A new push to a pull request cancels its previous run, which otherwise keeps +# two Windows runners busy for up to 45 minutes. Runs on main are never +# cancelled so every merged commit gets a full result. concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: shellcheck: @@ -91,6 +92,9 @@ jobs: # The Docker service is not always running when a Windows job starts. shell: powershell run: | + # The default 'Stop' preference would turn docker's stderr into a + # terminating error on the first failed attempt. + $ErrorActionPreference = 'Continue' $service = Get-Service docker -ErrorAction SilentlyContinue if ($service -and $service.Status -ne 'Running') { Start-Service docker } foreach ($attempt in 1..30) { diff --git a/tests/integration/windows.ps1 b/tests/integration/windows.ps1 index ae40f30..c6134e3 100644 --- a/tests/integration/windows.ps1 +++ b/tests/integration/windows.ps1 @@ -91,6 +91,9 @@ try { ) $ready = $false foreach ($attempt in 1..40) { + # A server that died on bind (port in use) must not let the readiness + # check succeed against some other listener. + if ($Server.HasExited) { Fail "stub registry exited; is port $Port in use?" } try { $null = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/proxy-agent-manager.ps1" -UseBasicParsing -TimeoutSec 2 $ready = $true diff --git a/tests/pwsh/Manager.Tests.ps1 b/tests/pwsh/Manager.Tests.ps1 index dac3154..35c2d7d 100644 --- a/tests/pwsh/Manager.Tests.ps1 +++ b/tests/pwsh/Manager.Tests.ps1 @@ -113,6 +113,8 @@ AfterAll { Describe 'proxy-agent-manager.ps1' { BeforeEach { + # A developer's environment must not leak into the default-URL assertion. + Remove-Item Env:\PROXY_AGENT_REGISTRY_TAGS_URL -ErrorAction SilentlyContinue $global:ProxyAgentStub = @{ OsType = 'linux' LastImage = $null From 336e9c61ed985ca8547f6e3bd4d260fce982ee36 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 07:35:33 -0700 Subject: [PATCH 11/20] Make the stub agent require the real agent's invocation The stub exits unless started with `-i ` pointing at a readable file, so a regression in how the management scripts pass the config to the agent fails the integration tests instead of passing against a stub that ignored its arguments. Co-Authored-By: Claude Fable 5.1 --- tests/stub/heartbeat.ps1 | 16 +++++++++++++--- tests/stub/heartbeat.sh | 16 +++++++++++++--- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/tests/stub/heartbeat.ps1 b/tests/stub/heartbeat.ps1 index 0bfdf3f..0748558 100644 --- a/tests/stub/heartbeat.ps1 +++ b/tests/stub/heartbeat.ps1 @@ -1,11 +1,21 @@ # Stub proxy agent: writes the heartbeat file the management script's health -# check reads, then idles. Any arguments (the real agent's -i ) are -# ignored. +# check reads, then idles. It expects to be invoked like the real agent, with +# `-i ` pointing at a readable file, and exits otherwise so the +# container never becomes healthy and the test fails. +if ($args.Count -lt 2 -or $args[0] -ne '-i') { + Write-Error "stub proxy agent: expected '-i ', got: $args" + exit 1 +} +if (-not (Test-Path -LiteralPath $args[1] -PathType Leaf)) { + Write-Error "stub proxy agent: config file not readable: $($args[1])" + exit 1 +} + $path = $env:HEARTBEAT_PATH if (-not $path) { throw 'HEARTBEAT_PATH must be set' } $expiry = if ($env:HEARTBEAT_EXPIRY_SECONDS) { [int]$env:HEARTBEAT_EXPIRY_SECONDS } else { 30 } -Write-Host "stub proxy agent started with args: $args" +Write-Host "stub proxy agent started with config $($args[1])" $null = New-Item -ItemType Directory -Force -Path (Split-Path -Parent $path) while ($true) { $expireAt = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $expiry diff --git a/tests/stub/heartbeat.sh b/tests/stub/heartbeat.sh index 7a3341a..44dfced 100755 --- a/tests/stub/heartbeat.sh +++ b/tests/stub/heartbeat.sh @@ -1,15 +1,25 @@ #!/bin/sh # # Stub proxy agent: writes the heartbeat file the management script's health -# check reads, then idles. Any arguments (the real agent's -i ) are -# ignored. +# check reads, then idles. It expects to be invoked like the real agent, with +# `-i ` pointing at a readable file, and exits otherwise so the +# container never becomes healthy and the test fails. # set -eu +if [ "${1:-}" != "-i" ] || [ -z "${2:-}" ]; then + echo "stub proxy agent: expected '-i ', got: $*" >&2 + exit 1 +fi +if [ ! -r "$2" ]; then + echo "stub proxy agent: config file not readable: $2" >&2 + exit 1 +fi + path="${HEARTBEAT_PATH:?HEARTBEAT_PATH must be set}" expiry="${HEARTBEAT_EXPIRY_SECONDS:-30}" -echo "stub proxy agent started with args: $*" +echo "stub proxy agent started with config $2" while true; do echo "HEARTBEAT_EXPIRE_AT=$(( $(date +%s) + expiry ))" > "$path" sleep 5 From dc299189a94ea3c7ebebebd5231ffda8b2c214d6 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 07:39:37 -0700 Subject: [PATCH 12/20] Cover the TOKEN and WebSocket migration install paths The local server now answers POST /proxy-agent/configure with the fixture config when the Basic credential matches and 401 otherwise, and both installers are pointed at it through FIVETRAN_API_URL. The integration tests install with a TOKEN, check that a wrong TOKEN fails with HTTP 401 without writing a config, and install from a legacy WebSocket config, which the installer migrates through the same call. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- .../fixtures/config-websocket.json | 5 ++ tests/integration/linux.sh | 45 +++++++++++--- tests/integration/registry_server.py | 43 +++++++++---- tests/integration/windows.ps1 | 62 ++++++++++++++++--- 5 files changed, 129 insertions(+), 28 deletions(-) create mode 100644 tests/integration/fixtures/config-websocket.json diff --git a/README.md b/README.md index efc110c..8c8e0b3 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ CI (`.github/workflows/ci.yml`) runs on every pull request: - `shellcheck` on the shell scripts and PSScriptAnalyzer (settings in `PSScriptAnalyzerSettings.psd1`) on the PowerShell scripts. - Unit tests for the management scripts: `tests/bash/manager.bats` (bats) and `tests/pwsh/Manager.Tests.ps1` (Pester 5 under Windows PowerShell 5.1). Both run the script with stubbed `docker` and registry calls, so they need no Docker daemon. -- Integration tests that run the installer and management script against a real Docker daemon: `tests/integration/linux.sh` on Ubuntu, and `tests/integration/windows.ps1` on Windows Server 2022 and 2025 with Windows containers. They use a stub agent image (`tests/stub/`) that only writes the heartbeat file, and a local stand-in for the registry and the script download (`tests/integration/registry_server.py`), so they need no Fivetran credentials. +- Integration tests that run the installer and management script against a real Docker daemon: `tests/integration/linux.sh` on Ubuntu, and `tests/integration/windows.ps1` on Windows Server 2022 and 2025 with Windows containers. They use a stub agent image (`tests/stub/`) that only writes the heartbeat file, and a local stand-in for the registry, the script download and the configure endpoint (`tests/integration/registry_server.py`), so they need no Fivetran credentials. They cover installing from a config file, from a `TOKEN`, and from a legacy WebSocket config, then rollback, upgrade, restart and stop. To run them locally you need Docker for the integration tests, `python3` (`python` on Windows) for the registry stand-in, [bats-core](https://github.com/bats-core/bats-core) for the bash tests and [Pester 5](https://pester.dev) for the PowerShell tests. The stub images pull `alpine` and `servercore` base images, so the integration tests need network access for that. diff --git a/tests/integration/fixtures/config-websocket.json b/tests/integration/fixtures/config-websocket.json new file mode 100644 index 0000000..d85f52a --- /dev/null +++ b/tests/integration/fixtures/config-websocket.json @@ -0,0 +1,5 @@ +{ + "agent_id": "test-agent", + "auth_token": "secret", + "proxy_server_uri": "wss://old-proxy.invalid" +} diff --git a/tests/integration/linux.sh b/tests/integration/linux.sh index 4bc66b4..c3d0e24 100755 --- a/tests/integration/linux.sh +++ b/tests/integration/linux.sh @@ -16,9 +16,14 @@ STUB_IMAGE="proxy-agent-stub" SUFFIX="-ubuntu-26.04" OLD_VERSION="1.2.3" NEW_VERSION="1.2.4" -AGENT_ID="test-agent" # matches fixtures/config.json +AGENT_ID="test-agent" # matches the fixtures CONTAINER="proxy-agent-${AGENT_ID}" -INSTALL_DIR="$(mktemp -d)/fivetran-proxy-agent" +TEMP_ROOT="$(mktemp -d)" +INSTALL_DIR="$TEMP_ROOT/fivetran-proxy-agent" +FIXTURES="$REPO_ROOT/tests/integration/fixtures" +# Credential the stub configure endpoint accepts: agent_id:auth_token from +# fixtures/config-websocket.json, base64 encoded as the dashboard does. +TOKEN_VALUE="$(printf 'test-agent:secret' | base64 | tr -d '\n')" SERVER_PID="" cleanup() { @@ -27,7 +32,7 @@ cleanup() { wait "$SERVER_PID" 2>/dev/null || true fi docker rm -f "$CONTAINER" >/dev/null 2>&1 || true - rm -rf "$(dirname "$INSTALL_DIR")" + rm -rf "$TEMP_ROOT" } trap cleanup EXIT @@ -68,7 +73,8 @@ done echo "== Starting stub registry on port $PORT" python3 "$REPO_ROOT/tests/integration/registry_server.py" --port "$PORT" --root "$REPO_ROOT" \ - --tags "$OLD_VERSION,$OLD_VERSION$SUFFIX,$NEW_VERSION,$NEW_VERSION$SUFFIX,$NEW_VERSION-windows-ltsc2022" & + --tags "$OLD_VERSION,$OLD_VERSION$SUFFIX,$NEW_VERSION,$NEW_VERSION$SUFFIX,$NEW_VERSION-windows-ltsc2022" \ + --token "$TOKEN_VALUE" --config "$FIXTURES/config.json" & SERVER_PID=$! for _ in $(seq 1 40); do # The liveness check catches a server that died on bind (port in use), so @@ -84,9 +90,10 @@ export MEMORY_ALLOCATION_MB=256 export PROXY_AGENT_IMAGE="$STUB_IMAGE" export PROXY_AGENT_REGISTRY_TAGS_URL="http://127.0.0.1:$PORT/v2/stub/tags/list" export PROXY_AGENT_SCRIPT_URL="http://127.0.0.1:$PORT/proxy-agent-manager.sh" +export FIVETRAN_API_URL="http://127.0.0.1:$PORT" -echo "== Installing" -"$REPO_ROOT/install.sh" "$REPO_ROOT/tests/integration/fixtures/config.json" --install-dir "$INSTALL_DIR" +echo "== Installing from a config file" +"$REPO_ROOT/install.sh" "$FIXTURES/config.json" --install-dir "$INSTALL_DIR" assert_image "$STUB_IMAGE:$NEW_VERSION$SUFFIX" assert_healthy assert_version_file "$NEW_VERSION" @@ -94,7 +101,7 @@ grep -qx "MEMORY_ALLOCATION_MB=256" "$INSTALL_DIR/settings.sh" || fail "settings [ "$(docker inspect -f '{{.HostConfig.Memory}}' "$CONTAINER")" = "$((256 * 1024 * 1024))" ] || fail "container memory limit not applied" config_mode=$(stat -c %a "$INSTALL_DIR/config/config.json" 2>/dev/null || stat -f %Lp "$INSTALL_DIR/config/config.json") [ "$config_mode" = "600" ] || fail "config.json is not mode 600 (got $config_mode)" -cmp -s "$REPO_ROOT/tests/integration/fixtures/config.json" "$INSTALL_DIR/config/config.json" || fail "config.json was not copied verbatim" +cmp -s "$FIXTURES/config.json" "$INSTALL_DIR/config/config.json" || fail "config.json was not copied verbatim" MANAGER="$INSTALL_DIR/proxy-agent-manager.sh" @@ -132,4 +139,28 @@ if docker inspect "$CONTAINER" >/dev/null 2>&1; then fail "container still exists after stop" fi +echo "== Installing with TOKEN fetches the config from the configure endpoint" +TOKEN_INSTALL_DIR="$TEMP_ROOT/fivetran-proxy-agent-token" +TOKEN="$TOKEN_VALUE" "$REPO_ROOT/install.sh" --install-dir "$TOKEN_INSTALL_DIR" +cmp -s "$FIXTURES/config.json" "$TOKEN_INSTALL_DIR/config/config.json" || fail "config.json does not match the configure endpoint's response" +assert_image "$STUB_IMAGE:$NEW_VERSION$SUFFIX" +assert_healthy +"$TOKEN_INSTALL_DIR/proxy-agent-manager.sh" stop + +echo "== Installing with a wrong TOKEN fails" +BAD_TOKEN_INSTALL_DIR="$TEMP_ROOT/fivetran-proxy-agent-bad-token" +if bad_token_output=$(TOKEN="$(printf 'test-agent:wrong' | base64 | tr -d '\n')" "$REPO_ROOT/install.sh" --install-dir "$BAD_TOKEN_INSTALL_DIR" 2>&1); then + fail "install.sh succeeded with a wrong TOKEN" +fi +[[ "$bad_token_output" == *"HTTP 401"* ]] || fail "expected an HTTP 401 error, got: $bad_token_output" +[ ! -s "$BAD_TOKEN_INSTALL_DIR/config/config.json" ] || fail "config.json was written despite the failed configure call" + +echo "== Installing from a WebSocket config migrates it through the configure endpoint" +WS_INSTALL_DIR="$TEMP_ROOT/fivetran-proxy-agent-websocket" +"$REPO_ROOT/install.sh" "$FIXTURES/config-websocket.json" --install-dir "$WS_INSTALL_DIR" +cmp -s "$FIXTURES/config.json" "$WS_INSTALL_DIR/config/config.json" || fail "WebSocket config was not replaced by the configure endpoint's response" +assert_image "$STUB_IMAGE:$NEW_VERSION$SUFFIX" +assert_healthy +"$WS_INSTALL_DIR/proxy-agent-manager.sh" stop + echo "PASS" diff --git a/tests/integration/registry_server.py b/tests/integration/registry_server.py index 1405719..636b4a6 100644 --- a/tests/integration/registry_server.py +++ b/tests/integration/registry_server.py @@ -1,14 +1,21 @@ #!/usr/bin/env python3 -"""Stand-in for the two remote endpoints the installers call. +"""Stand-in for the remote endpoints the installers call. -Serves ``/v2//tags/list`` as a container registry tag listing with -the tags given on the command line, and every other path as a static file from -``--root`` (the repository checkout), which is how the installers download the -management script under test instead of the one on GitHub's main branch. +- ``GET /v2//tags/list``: a container registry tag listing with the + tags given on the command line. +- ``POST /proxy-agent-configure``: see below; the Fivetran configure endpoint. + Returns the ``--config`` file as the agent configuration when the request + carries ``Authorization: Basic <--token>``, and 401 otherwise. +- Anything else: a static file from ``--root`` (the repository checkout), which + is how the installers download the management script under test instead of + the one on GitHub's main branch. """ import argparse import json from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +CONFIGURE_PATH = "/proxy-agent/configure" def main(): @@ -16,24 +23,38 @@ def main(): parser.add_argument("--port", type=int, default=8181) parser.add_argument("--root", required=True, help="directory to serve static files from") parser.add_argument("--tags", required=True, help="comma-separated image tags to list") + parser.add_argument("--token", required=True, help="base64 credential the configure endpoint accepts") + parser.add_argument("--config", required=True, help="config.json the configure endpoint returns") args = parser.parse_args() tags = args.tags.split(",") + config_body = Path(args.config).read_bytes() class Handler(SimpleHTTPRequestHandler): def __init__(self, *handler_args, **handler_kwargs): super().__init__(*handler_args, directory=args.root, **handler_kwargs) + def send_json(self, status, body): + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def do_GET(self): if self.path.startswith("/v2/") and self.path.endswith("/tags/list"): - body = json.dumps({"name": "stub", "tags": tags}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) + self.send_json(200, json.dumps({"name": "stub", "tags": tags}).encode()) return super().do_GET() + def do_POST(self): + if self.path != CONFIGURE_PATH: + self.send_error(404) + return + if self.headers.get("Authorization") == f"Basic {args.token}": + self.send_json(200, config_body) + else: + self.send_json(401, json.dumps({"message": "unauthorized"}).encode()) + def log_message(self, fmt, *log_args): pass diff --git a/tests/integration/windows.ps1 b/tests/integration/windows.ps1 index c6134e3..15df7a5 100644 --- a/tests/integration/windows.ps1 +++ b/tests/integration/windows.ps1 @@ -21,10 +21,14 @@ $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $StubImage = 'proxy-agent-stub' $OldVersion = '1.2.3' $NewVersion = '1.2.4' -$AgentId = 'test-agent' # matches fixtures\config.json +$AgentId = 'test-agent' # matches the fixtures $Container = "proxy-agent-$AgentId" $TempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "proxy-agent-test-$PID" $InstallDir = Join-Path $TempRoot 'fivetran-proxy-agent' +$Fixtures = Join-Path $RepoRoot 'tests\integration\fixtures' +# Credential the stub configure endpoint accepts: agent_id:auth_token from +# fixtures\config-websocket.json, base64 encoded as the dashboard does. +$TokenValue = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('test-agent:secret')) $Server = $null function Fail { @@ -64,13 +68,20 @@ function Assert-VersionFile { } function Invoke-Manager { - param([string]$Command) - $output = & (Join-Path $InstallDir 'proxy-agent-manager.ps1') $Command *>&1 | Out-String + param([string]$Command, [string]$Dir = $InstallDir) + $output = & (Join-Path $Dir 'proxy-agent-manager.ps1') $Command *>&1 | Out-String Write-Host $output if ($LASTEXITCODE -ne 0) { Fail "manager $Command exited with $LASTEXITCODE" } return $output } +function Assert-ConfigMatches { + param([string]$Dir, [string]$Fixture) + $expected = Get-Content -LiteralPath (Join-Path $Fixtures $Fixture) -Raw + $actual = Get-Content -LiteralPath (Join-Path $Dir 'config\config.json') -Raw + if ($actual -ne $expected) { Fail "config.json in $Dir does not match $Fixture" } +} + try { $null = New-Item -ItemType Directory -Force -Path $TempRoot $ltsc = Get-LtscVersion @@ -87,7 +98,8 @@ try { # Start-Process does not quote arguments, so paths are quoted here. $Server = Start-Process -FilePath python -PassThru -NoNewWindow -ArgumentList @( "`"$(Join-Path $RepoRoot 'tests\integration\registry_server.py')`"", - '--port', $Port, '--root', "`"$RepoRoot`"", '--tags', $tags + '--port', $Port, '--root', "`"$RepoRoot`"", '--tags', $tags, + '--token', $TokenValue, '--config', "`"$(Join-Path $Fixtures 'config.json')`"" ) $ready = $false foreach ($attempt in 1..40) { @@ -109,9 +121,10 @@ try { $env:PROXY_AGENT_IMAGE = $StubImage $env:PROXY_AGENT_REGISTRY_TAGS_URL = "http://127.0.0.1:$Port/v2/stub/tags/list" $env:PROXY_AGENT_SCRIPT_URL = "http://127.0.0.1:$Port/proxy-agent-manager.ps1" + $env:FIVETRAN_API_URL = "http://127.0.0.1:$Port" - Write-Host '== Installing' - & (Join-Path $RepoRoot 'install.ps1') (Join-Path $RepoRoot 'tests\integration\fixtures\config.json') -InstallDir $InstallDir + Write-Host '== Installing from a config file' + & (Join-Path $RepoRoot 'install.ps1') (Join-Path $Fixtures 'config.json') -InstallDir $InstallDir if ($LASTEXITCODE -ne 0) { Fail "install.ps1 exited with $LASTEXITCODE" } Assert-Image "${StubImage}:$newTag" Assert-Healthy @@ -120,9 +133,7 @@ try { if ($settings -notmatch '\$MEMORY_ALLOCATION_MB = 512') { Fail 'settings.ps1 does not record the memory allocation' } $memory = [int64](docker inspect -f '{{.HostConfig.Memory}}' $Container).Trim() if ($memory -ne 512MB) { Fail "container memory limit not applied (got $memory)" } - $expectedConfig = Get-Content -LiteralPath (Join-Path $RepoRoot 'tests\integration\fixtures\config.json') -Raw - $actualConfig = Get-Content -LiteralPath (Join-Path $InstallDir 'config\config.json') -Raw - if ($actualConfig -ne $expectedConfig) { Fail 'config.json was not copied verbatim' } + Assert-ConfigMatches $InstallDir 'config.json' Write-Host '== status' $status = Invoke-Manager status @@ -154,6 +165,39 @@ try { $remaining = docker ps -a --filter "name=$Container" --format '{{.Names}}' if ($remaining) { Fail "container still exists after stop: $remaining" } + Write-Host '== Installing with TOKEN fetches the config from the configure endpoint' + $tokenInstallDir = Join-Path $TempRoot 'fivetran-proxy-agent-token' + $env:TOKEN = $TokenValue + & (Join-Path $RepoRoot 'install.ps1') -InstallDir $tokenInstallDir + if ($LASTEXITCODE -ne 0) { Fail "install.ps1 with TOKEN exited with $LASTEXITCODE" } + Remove-Item Env:\TOKEN -ErrorAction SilentlyContinue + Assert-ConfigMatches $tokenInstallDir 'config.json' + Assert-Image "${StubImage}:$newTag" + Assert-Healthy + $null = Invoke-Manager stop -Dir $tokenInstallDir + + Write-Host '== Installing with a wrong TOKEN fails' + $badTokenInstallDir = Join-Path $TempRoot 'fivetran-proxy-agent-bad-token' + $env:TOKEN = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('test-agent:wrong')) + $badTokenOutput = & (Join-Path $RepoRoot 'install.ps1') -InstallDir $badTokenInstallDir *>&1 | Out-String + $badTokenExit = $LASTEXITCODE + Remove-Item Env:\TOKEN -ErrorAction SilentlyContinue + if ($badTokenExit -eq 0) { Fail 'install.ps1 succeeded with a wrong TOKEN' } + if ($badTokenOutput -notmatch 'HTTP 401') { Fail "expected an HTTP 401 error, got: $badTokenOutput" } + $badConfig = Join-Path $badTokenInstallDir 'config\config.json' + if ((Test-Path -LiteralPath $badConfig) -and (Get-Item -LiteralPath $badConfig).Length -gt 0) { + Fail 'config.json was written despite the failed configure call' + } + + Write-Host '== Installing from a WebSocket config migrates it through the configure endpoint' + $wsInstallDir = Join-Path $TempRoot 'fivetran-proxy-agent-websocket' + & (Join-Path $RepoRoot 'install.ps1') (Join-Path $Fixtures 'config-websocket.json') -InstallDir $wsInstallDir + if ($LASTEXITCODE -ne 0) { Fail "install.ps1 with a WebSocket config exited with $LASTEXITCODE" } + Assert-ConfigMatches $wsInstallDir 'config.json' + Assert-Image "${StubImage}:$newTag" + Assert-Healthy + $null = Invoke-Manager stop -Dir $wsInstallDir + Write-Host 'PASS' } finally { # Best-effort cleanup; a failure here must not hide the test's own error. From e0e66ad974490daeef3d3e04edf94556871b3e06 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 07:40:51 -0700 Subject: [PATCH 13/20] Record the HTTP method in the Invoke-RestMethod stub PSScriptAnalyzer flagged the parameter as unused; the stub needs it to accept the manager's call. Co-Authored-By: Claude Fable 5.1 --- tests/pwsh/Manager.Tests.ps1 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/pwsh/Manager.Tests.ps1 b/tests/pwsh/Manager.Tests.ps1 index 35c2d7d..07c8b6f 100644 --- a/tests/pwsh/Manager.Tests.ps1 +++ b/tests/pwsh/Manager.Tests.ps1 @@ -94,10 +94,11 @@ BeforeAll { } } - # Stand-in for the registry tag listing. Records the requested URL. + # Stand-in for the registry tag listing. Records the request. function Invoke-RestMethod { param([string]$Uri, [string]$Method) $global:ProxyAgentStub.RegistryUrl = $Uri + $global:ProxyAgentStub.RegistryMethod = $Method return [pscustomobject]@{ tags = $global:ProxyAgentStub.Tags } } @@ -121,7 +122,8 @@ Describe 'proxy-agent-manager.ps1' { UnhealthyTag = $null DockerCalls = @() RegistryUrl = $null - Tags = @('1.2.9-ubuntu-26.04', '1.2.10-ubuntu-26.04', '1.2.11', '1.2.10-windows-ltsc2022', '1.2.10-windows-ltsc2025') + RegistryMethod = $null + Tags = @('1.2.9-ubuntu-26.04', '1.2.10-ubuntu-26.04', '1.2.11', '1.2.10-windows-ltsc2022', '1.2.10-windows-ltsc2025') } } From fe4d44b344fe258fb2cb069c9564bffe5ca9080c Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Mon, 2 Nov 2026 09:59:28 -0800 Subject: [PATCH 14/20] Cover the Windows installer and manager failure paths Add a Pester suite for install.ps1, run as a black box with docker, the registry, the script download and the host lookups stubbed. It covers LTSC detection and the -WindowsVersion override, unsupported and client hosts, missing tags for the LTSC version, Linux containers, and the disk space checks against Docker's data root (including a drive root and the install directory fallback). The manager tests now also cover rollback, missing tags and unsupported hosts with Windows containers, and the bash manager test covers a bare version that matches the latest Ubuntu tag. Co-Authored-By: Claude Opus 5.5 --- tests/bash/manager.bats | 9 ++ tests/pwsh/Install.Tests.ps1 | 232 +++++++++++++++++++++++++++++++++++ tests/pwsh/Manager.Tests.ps1 | 37 +++++- 3 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 tests/pwsh/Install.Tests.ps1 diff --git a/tests/bash/manager.bats b/tests/bash/manager.bats index 7fe924c..b846675 100644 --- a/tests/bash/manager.bats +++ b/tests/bash/manager.bats @@ -102,6 +102,15 @@ last_image() { ! grep -q "^run" "$DOCKER_LOG" } +@test "upgrade is a no-op when a bare version file entry matches the latest Ubuntu tag" { + export STUB_CURL_RESPONSE='{"tags": ["1.2.10-ubuntu-26.04","1.2.11"]}' + echo "1.2.10" > "$AGENT_DIR/version" + run manager upgrade + [ "$status" -eq 0 ] + [[ "$output" == *"Already running the latest version"* ]] + ! grep -q "^run" "$DOCKER_LOG" +} + @test "upgrade rolls back when the new container is unhealthy" { export DOCKER_UNHEALTHY_TAG="1.2.10" run manager upgrade diff --git a/tests/pwsh/Install.Tests.ps1 b/tests/pwsh/Install.Tests.ps1 new file mode 100644 index 0000000..2d963a0 --- /dev/null +++ b/tests/pwsh/Install.Tests.ps1 @@ -0,0 +1,232 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' } +<# +.SYNOPSIS +Unit tests for install.ps1. + +.DESCRIPTION +The installer is run as a black box with docker, Invoke-RestMethod, +Invoke-WebRequest, Get-CimInstance and Get-PSDrive replaced by functions, so no +Docker daemon or network access is needed. See Manager.Tests.ps1 for why the +stubs shadow the real commands and keep their state in $global:ProxyAgentStub. + +The management script the installer downloads is replaced by one that only +records that it was started. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '', Justification = 'Stub state must be global; see Manager.Tests.ps1.')] +param() + +BeforeAll { + $script:Installer = (Resolve-Path (Join-Path $PSScriptRoot '..\..\install.ps1')).Path + $script:WindowsServer2022 = [pscustomobject]@{ ProductType = 3; BuildNumber = 20348; Caption = 'Windows Server 2022' } + + # Creates a config file and an install directory path under TestDrive, and + # returns the arguments to run the installer with. + function New-InstallArgs { + $dir = Join-Path $TestDrive ([guid]::NewGuid()) + $null = New-Item -ItemType Directory -Force -Path $dir + $config = Join-Path $dir 'config.json' + Set-Content -LiteralPath $config -Value '{"agent_id": "abc123"}' -Encoding ascii + $script:InstallDir = Join-Path $dir 'fivetran-proxy-agent' + return @{ ConfigPath = $config; InstallDir = $script:InstallDir } + } + + # Runs the installer, capturing every output stream as one string. + function Invoke-Installer { + param([hashtable]$Arguments) + return (& $script:Installer @Arguments *>&1 | Out-String) + } + + function Get-VersionFile { + return (Get-Content -LiteralPath (Join-Path $script:InstallDir 'version') -Raw).Trim() + } + + function Test-VersionFile { + return Test-Path -LiteralPath (Join-Path $script:InstallDir 'version') + } + + # Stand-in for the docker CLI, covering the calls the installer makes. + function docker { + $stub = $global:ProxyAgentStub + $global:LASTEXITCODE = 0 + if ($args[0] -eq '--version') { return 'Docker version 27.0.0, build 0000000' } + if ($args[0] -eq 'info' -and $args[1] -eq '--format') { + switch ($args[2]) { + '{{.OSType}}' { return $stub.OsType } + '{{.DockerRootDir}}' { + if (-not $stub.DockerRootDir) { $global:LASTEXITCODE = 1 } + return $stub.DockerRootDir + } + } + } + } + + # Stand-in for the registry tag listing. + function Invoke-RestMethod { + return [pscustomobject]@{ tags = $global:ProxyAgentStub.Tags } + } + + # Stand-in for the management script download. The downloaded script only + # records that the installer started it. The arguments other than OutFile + # are left unbound, in $args. + function Invoke-WebRequest { + param([string]$OutFile) + Set-Content -LiteralPath $OutFile -Value '$global:ProxyAgentStub.ManagerArgs = $args' -Encoding ascii + } + + # Stand-in for the host lookups: the OS from the stub state, and a host + # with enough memory not to warn. + function Get-CimInstance { + [CmdletBinding()] + param([Parameter(Position = 0)][string]$ClassName) + if ($ClassName -eq 'Win32_OperatingSystem') { return $global:ProxyAgentStub.OperatingSystem } + return [pscustomobject]@{ TotalPhysicalMemory = 64GB } + } + + # Stand-in for the free space lookup. Records the drives queried and reports + # FreeMB for a drive, or plenty of space for any other. + function Get-PSDrive { + param([Parameter(Position = 0)][string]$Name) + $stub = $global:ProxyAgentStub + $stub.DrivesQueried += $Name + $freeMB = if ($stub.FreeMB.ContainsKey($Name)) { $stub.FreeMB[$Name] } else { 100000 } + return [pscustomobject]@{ Name = $Name; Free = $freeMB * 1MB } + } +} + +AfterAll { + Remove-Variable -Name ProxyAgentStub -Scope Global -ErrorAction SilentlyContinue +} + +Describe 'install.ps1' { + BeforeEach { + $env:RUNTIME = 'docker' + $env:MEMORY_ALLOCATION_MB = '5120' + Remove-Item Env:\TOKEN -ErrorAction SilentlyContinue + $global:ProxyAgentStub = @{ + OsType = 'windows' + DockerRootDir = $null + OperatingSystem = $script:WindowsServer2022 + ManagerArgs = $null + DrivesQueried = @() + FreeMB = @{} + Tags = @( + '1.2.9-windows-ltsc2022', '1.2.10-windows-ltsc2022', '1.2.11-windows-ltsc2025', + '1.2.12-ubuntu-26.04', '1.2.13' + ) + } + } + + AfterEach { + Remove-Item Env:\RUNTIME, Env:\MEMORY_ALLOCATION_MB -ErrorAction SilentlyContinue + } + + Context 'with Windows containers' { + It 'pins the newest tag for the LTSC version detected from the host and starts the agent' { + $output = Invoke-Installer (New-InstallArgs) + $LASTEXITCODE | Should -Be 0 + $output | Should -Match 'Using Windows image variant ltsc2022' + Get-VersionFile | Should -Be '1.2.10-windows-ltsc2022' + $global:ProxyAgentStub.ManagerArgs | Should -Be @('start') + } + + It 'uses -WindowsVersion instead of detecting the host, even on an unsupported build' { + $global:ProxyAgentStub.OperatingSystem = [pscustomobject]@{ ProductType = 3; BuildNumber = 99999; Caption = 'Windows Server vNext' } + $arguments = New-InstallArgs + $arguments.WindowsVersion = 'ltsc2025' + $null = Invoke-Installer $arguments + $LASTEXITCODE | Should -Be 0 + Get-VersionFile | Should -Be '1.2.11-windows-ltsc2025' + } + + It 'fails on an unsupported Windows Server build' { + $global:ProxyAgentStub.OperatingSystem = [pscustomobject]@{ ProductType = 3; BuildNumber = 99999; Caption = 'Windows Server vNext' } + $output = Invoke-Installer (New-InstallArgs) + $LASTEXITCODE | Should -Be 1 + $output | Should -Match "Unsupported Windows Server build '99999'" + Test-VersionFile | Should -BeFalse + } + + It 'fails on a Windows client host' { + $global:ProxyAgentStub.OperatingSystem = [pscustomobject]@{ ProductType = 1; BuildNumber = 26100; Caption = 'Windows 11 Pro' } + $output = Invoke-Installer (New-InstallArgs) + $LASTEXITCODE | Should -Be 1 + $output | Should -Match 'Windows Server is required' + Test-VersionFile | Should -BeFalse + } + + It 'fails when no tags are published for the LTSC version' { + $global:ProxyAgentStub.Tags = @('1.2.11-windows-ltsc2025', '1.2.12-ubuntu-26.04') + $output = Invoke-Installer (New-InstallArgs) + $LASTEXITCODE | Should -Be 1 + $output | Should -Match 'No published Windows image tags found for ltsc2022' + $global:ProxyAgentStub.ManagerArgs | Should -BeNullOrEmpty + } + + It "checks Docker's data root drive against the Windows disk space minimum" { + $null = New-Item -ItemType Directory -Force -Path 'TestDrive:\docker' + $global:ProxyAgentStub.DockerRootDir = 'TestDrive:\docker' + $global:ProxyAgentStub.FreeMB['TestDrive'] = 10000 + $output = Invoke-Installer (New-InstallArgs) + $LASTEXITCODE | Should -Be 0 + $global:ProxyAgentStub.DrivesQueried | Should -Be @('TestDrive') + $output | Should -Match 'drive TestDrive: \(10000MB\) is below the recommended minimum of 12288MB' + } + + It "checks a data root that is a drive root" { + $global:ProxyAgentStub.DockerRootDir = 'TestDrive:\' + $global:ProxyAgentStub.FreeMB['TestDrive'] = 10000 + $output = Invoke-Installer (New-InstallArgs) + $output | Should -Match 'drive TestDrive: \(10000MB\) is below the recommended minimum of 12288MB' + $output | Should -Not -Match 'does not exist' + } + + It "does not warn when Docker's data root has enough space" { + $global:ProxyAgentStub.DockerRootDir = 'TestDrive:\' + $output = Invoke-Installer (New-InstallArgs) + $output | Should -Not -Match 'disk space' + } + + It "falls back to the install directory's drive when Docker's data root is unknown" { + $arguments = New-InstallArgs + $installDrive = (Split-Path -Qualifier $arguments.InstallDir).TrimEnd(':') + $global:ProxyAgentStub.FreeMB[$installDrive] = 10000 + $output = Invoke-Installer $arguments + $LASTEXITCODE | Should -Be 0 + $output | Should -Match "Unable to determine Docker's data root" + $output | Should -Match "drive ${installDrive}: \(10000MB\) is below the recommended minimum of 12288MB" + } + } + + Context 'with Linux containers' { + BeforeEach { + $global:ProxyAgentStub.OsType = 'linux' + } + + It 'pins the newest Ubuntu tag and ignores Windows and bare tags' { + $output = Invoke-Installer (New-InstallArgs) + $LASTEXITCODE | Should -Be 0 + $output | Should -Match 'Using Linux container image' + Get-VersionFile | Should -Be '1.2.12-ubuntu-26.04' + $global:ProxyAgentStub.ManagerArgs | Should -Be @('start') + } + + It 'rejects -WindowsVersion' { + $arguments = New-InstallArgs + $arguments.WindowsVersion = 'ltsc2022' + $output = Invoke-Installer $arguments + $LASTEXITCODE | Should -Be 1 + $output | Should -Match '-WindowsVersion can only be used when Docker is running Windows containers' + Test-VersionFile | Should -BeFalse + } + + It "checks the install directory's drive against the Linux disk space minimum" { + $arguments = New-InstallArgs + $installDrive = (Split-Path -Qualifier $arguments.InstallDir).TrimEnd(':') + $global:ProxyAgentStub.FreeMB[$installDrive] = 1000 + $output = Invoke-Installer $arguments + $LASTEXITCODE | Should -Be 0 + $output | Should -Match "drive ${installDrive}: \(1000MB\) is below the recommended minimum of 2048MB" + $output | Should -Not -Match "Docker's data root" + } + } +} diff --git a/tests/pwsh/Manager.Tests.ps1 b/tests/pwsh/Manager.Tests.ps1 index 07c8b6f..3e7fb48 100644 --- a/tests/pwsh/Manager.Tests.ps1 +++ b/tests/pwsh/Manager.Tests.ps1 @@ -102,9 +102,9 @@ BeforeAll { return [pscustomobject]@{ tags = $global:ProxyAgentStub.Tags } } - # Stand-in for the host OS lookup, reporting Windows Server 2022. + # Stand-in for the host OS lookup, reporting $global:ProxyAgentStub.OperatingSystem. function Get-CimInstance { - return [pscustomobject]@{ ProductType = 3; BuildNumber = 20348; Caption = 'Windows Server 2022' } + return $global:ProxyAgentStub.OperatingSystem } } @@ -123,6 +123,7 @@ Describe 'proxy-agent-manager.ps1' { DockerCalls = @() RegistryUrl = $null RegistryMethod = $null + OperatingSystem = [pscustomobject]@{ ProductType = 3; BuildNumber = 20348; Caption = 'Windows Server 2022' } Tags = @('1.2.9-ubuntu-26.04', '1.2.10-ubuntu-26.04', '1.2.11', '1.2.10-windows-ltsc2022', '1.2.10-windows-ltsc2025') } } @@ -259,6 +260,38 @@ Describe 'proxy-agent-manager.ps1' { Get-VersionFile | Should -BeLike '1.2.10*' } + It 'rolls back when the upgraded container is unhealthy' { + $global:ProxyAgentStub.UnhealthyTag = '1.2.10' + $manager = New-AgentDir -Version '1.2.9-windows-ltsc2022' + $output = Invoke-Manager $manager upgrade + $output | Should -Match 'rolling back' + $global:ProxyAgentStub.LastImage | Should -Be "$($script:ImageRepo):1.2.9-windows-ltsc2022" + Get-VersionFile | Should -Be '1.2.9-windows-ltsc2022' + } + + It 'fails on upgrade when no tags are published for the LTSC version' { + $global:ProxyAgentStub.Tags = @('1.2.10-ubuntu-26.04', '1.2.10-windows-ltsc2025') + $manager = New-AgentDir -Version '1.2.9-windows-ltsc2022' + $output = Invoke-Manager $manager upgrade + $LASTEXITCODE | Should -Be 1 + $output | Should -Match 'No published Windows image tags found for ltsc2022' + Get-DockerRunArgs | Should -BeNullOrEmpty + } + + It 'refuses to start on an unsupported Windows Server build' { + $global:ProxyAgentStub.OperatingSystem = [pscustomobject]@{ ProductType = 3; BuildNumber = 99999; Caption = 'Windows Server vNext' } + $manager = New-AgentDir -Version '1.2.9' + { & $manager start } | Should -Throw "*Unsupported Windows Server build '99999'*" + Get-DockerRunArgs | Should -BeNullOrEmpty + } + + It 'refuses to start on a Windows client host' { + $global:ProxyAgentStub.OperatingSystem = [pscustomobject]@{ ProductType = 1; BuildNumber = 26100; Caption = 'Windows 11 Pro' } + $manager = New-AgentDir -Version '1.2.9' + { & $manager start } | Should -Throw '*Windows Server is required*' + Get-DockerRunArgs | Should -BeNullOrEmpty + } + It 'mounts the config directory and uses the Windows health check' { $manager = New-AgentDir -Version '1.2.9' $null = Invoke-Manager $manager start From e37f16c01ece7b4660d4dd6b3a7651fa2936efb8 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Mon, 2 Nov 2026 10:03:25 -0800 Subject: [PATCH 15/20] Fail failed upgrades, time out registry queries, validate settings - Both managers now exit 1 after an upgrade that had to roll back, and log when the rollback itself fails; previously they exited 0, so automation could not detect a failed upgrade. - Registry tag queries in install.sh and both managers time out after 30 seconds, as install.ps1 already did, instead of hanging. - Both managers reject a MEMORY_ALLOCATION_MB setting that is not a positive integer instead of passing it to docker run. - proxy-agent-manager.sh strips a UTF-8 byte order mark from the version file. Tests cover each of these, plus ltsc2019 detection in the installer and the manager, since no CI runner has Windows Server 2019. The Linux integration test also runs on arm64, which the published image supports. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 9 +++++++-- install.sh | 2 +- proxy-agent-manager.ps1 | 11 +++++++++-- proxy-agent-manager.sh | 14 +++++++++++--- tests/bash/manager.bats | 27 +++++++++++++++++++++++++-- tests/pwsh/Install.Tests.ps1 | 8 ++++++++ tests/pwsh/Manager.Tests.ps1 | 36 +++++++++++++++++++++++++++++++++--- 7 files changed, 94 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3260d4b..8c89bba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,8 +71,13 @@ jobs: Invoke-Pester -Path tests/pwsh -CI integration-linux: - name: Integration (Linux containers) - runs-on: ubuntu-latest + name: Integration (Linux containers, ${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + # The published Linux image is multi-architecture (amd64 and arm64). + os: [ubuntu-latest, ubuntu-24.04-arm] + runs-on: ${{ matrix.os }} timeout-minutes: 15 steps: - uses: actions/checkout@v4 diff --git a/install.sh b/install.sh index c140b37..50c2c3a 100755 --- a/install.sh +++ b/install.sh @@ -224,7 +224,7 @@ report_warnings_and_errors() { get_latest_version() { local tags_json - tags_json=$(curl -s -f "$REGISTRY_TAGS_URL") || die "Unable to query image registry for latest version" + tags_json=$(curl -s -f --max-time 30 "$REGISTRY_TAGS_URL") || die "Unable to query image registry for latest version" local version version=$(echo "$tags_json" \ diff --git a/proxy-agent-manager.ps1 b/proxy-agent-manager.ps1 index d0c0445..6246747 100644 --- a/proxy-agent-manager.ps1 +++ b/proxy-agent-manager.ps1 @@ -20,6 +20,10 @@ if (Test-Path -LiteralPath $SETTINGS_FILE) { . $SETTINGS_FILE } if (-not $MEMORY_ALLOCATION_MB) { $MEMORY_ALLOCATION_MB = 5120 } +if ("$MEMORY_ALLOCATION_MB" -notmatch '^\d+$' -or [int64]"$MEMORY_ALLOCATION_MB" -lt 1) { + Write-Host "ERROR: Invalid `$MEMORY_ALLOCATION_MB in ${SETTINGS_FILE}: $MEMORY_ALLOCATION_MB (must be a positive integer, in MB)" -ForegroundColor Red + exit 1 +} # ── Startup validation ─────────────────────────────────────────────────────── @@ -180,7 +184,7 @@ function Get-LatestVersion { } try { - $tagsJson = Invoke-RestMethod -Uri $registryUrl -Method Get + $tagsJson = Invoke-RestMethod -Uri $registryUrl -Method Get -TimeoutSec 30 } catch { Write-Host "ERROR: Unable to query image registry for latest version: $_" -ForegroundColor Red exit 1 @@ -385,7 +389,10 @@ function Invoke-Upgrade { if (-not (Start-ProxyAgent -Version $latestVersion -ContainerOsType $containerOsType)) { Write-Log "Upgrade failed, rolling back to $currentVersion..." Set-Content -Path $VERSION_FILE -Value $currentVersion -Encoding ascii - Start-ProxyAgent -Version $currentVersion -ContainerOsType $containerOsType | Out-Null + if (-not (Start-ProxyAgent -Version $currentVersion -ContainerOsType $containerOsType)) { + Write-Log "Rollback to $currentVersion failed." + } + exit 1 } } diff --git a/proxy-agent-manager.sh b/proxy-agent-manager.sh index 8439eb1..002ccab 100755 --- a/proxy-agent-manager.sh +++ b/proxy-agent-manager.sh @@ -38,7 +38,8 @@ if [ ! -f "$VERSION_FILE" ]; then echo "ERROR: $VERSION_FILE not found." exit 1 fi -CURRENT_VERSION=$(tr -d '[:space:]' < "$VERSION_FILE") +# Also strips the UTF-8 byte order mark an editor on Windows may add. +CURRENT_VERSION=$(LC_ALL=C tr -d '[:space:]\357\273\277' < "$VERSION_FILE") if [ -z "$CURRENT_VERSION" ]; then echo "ERROR: $VERSION_FILE is empty." exit 1 @@ -60,13 +61,17 @@ if [ -f "$SETTINGS_FILE" ]; then source "$SETTINGS_FILE" fi MEMORY_ALLOCATION_MB="${MEMORY_ALLOCATION_MB:-5120}" +if ! [[ "$MEMORY_ALLOCATION_MB" =~ ^[0-9]+$ ]] || [ "$MEMORY_ALLOCATION_MB" -lt 1 ]; then + echo "ERROR: Invalid MEMORY_ALLOCATION_MB in $SETTINGS_FILE: $MEMORY_ALLOCATION_MB (must be a positive integer, in MB)" + exit 1 +fi get_latest_version() { local registry_host="${IMAGE%%/*}" local repository_path="${IMAGE#*/}" local registry_url="${PROXY_AGENT_REGISTRY_TAGS_URL:-https://${registry_host}/v2/${repository_path}/tags/list}" local tags_json - tags_json=$(curl -s -f "$registry_url") || { + tags_json=$(curl -s -f --max-time 30 "$registry_url") || { echo "ERROR: Unable to query image registry for latest version" >&2 return 1 } @@ -109,7 +114,10 @@ upgrade_agent() { if ! start_agent "$latest_version"; then log "Upgrade failed, rolling back to $CURRENT_VERSION..." echo "$CURRENT_VERSION" > "$VERSION_FILE" - start_agent "$CURRENT_VERSION" + if ! start_agent "$CURRENT_VERSION"; then + log "Rollback to $CURRENT_VERSION failed." + fi + exit 1 fi } diff --git a/tests/bash/manager.bats b/tests/bash/manager.bats index b846675..1209fff 100644 --- a/tests/bash/manager.bats +++ b/tests/bash/manager.bats @@ -47,6 +47,21 @@ last_image() { grep -q -- "--memory=5120m" "$DOCKER_LOG" } +@test "start rejects an invalid MEMORY_ALLOCATION_MB in settings.sh" { + echo "MEMORY_ALLOCATION_MB=4GB" > "$AGENT_DIR/settings.sh" + run manager start + [ "$status" -eq 1 ] + [[ "$output" == *"Invalid MEMORY_ALLOCATION_MB"*"4GB"* ]] + [ ! -f "$DOCKER_LAST_IMAGE" ] +} + +@test "start accepts a version file with a byte order mark and CRLF line ending" { + printf '\xEF\xBB\xBF1.2.9-ubuntu-26.04\r\n' > "$AGENT_DIR/version" + run manager start + [ "$status" -eq 0 ] + [ "$(last_image)" = "$IMAGE_REPO:1.2.9-ubuntu-26.04" ] +} + @test "start honors MEMORY_ALLOCATION_MB from settings.sh" { echo "MEMORY_ALLOCATION_MB=2048" > "$AGENT_DIR/settings.sh" run manager start @@ -87,6 +102,7 @@ last_image() { run manager upgrade [ "$status" -eq 0 ] grep -q "https://us-docker.pkg.dev/v2/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent/tags/list" "$CURL_LOG" + grep -q -- "--max-time 30" "$CURL_LOG" export PROXY_AGENT_REGISTRY_TAGS_URL="http://127.0.0.1:1/v2/stub/tags/list" run manager upgrade @@ -111,15 +127,22 @@ last_image() { ! grep -q "^run" "$DOCKER_LOG" } -@test "upgrade rolls back when the new container is unhealthy" { +@test "upgrade rolls back and fails when the new container is unhealthy" { export DOCKER_UNHEALTHY_TAG="1.2.10" run manager upgrade - [ "$status" -eq 0 ] + [ "$status" -eq 1 ] [[ "$output" == *"rolling back"* ]] [ "$(last_image)" = "$IMAGE_REPO:1.2.9-ubuntu-26.04" ] [[ "$(cat "$AGENT_DIR/version")" == 1.2.9* ]] } +@test "upgrade reports a failed rollback" { + export DOCKER_UNHEALTHY_TAG="1.2." + run manager upgrade + [ "$status" -eq 1 ] + [[ "$output" == *"Rollback to 1.2.9-ubuntu-26.04 failed"* ]] +} + @test "upgrade rejects an invalid version file entry" { echo "2.0" > "$AGENT_DIR/version" run manager upgrade diff --git a/tests/pwsh/Install.Tests.ps1 b/tests/pwsh/Install.Tests.ps1 index 2d963a0..479c0b9 100644 --- a/tests/pwsh/Install.Tests.ps1 +++ b/tests/pwsh/Install.Tests.ps1 @@ -129,6 +129,14 @@ Describe 'install.ps1' { $global:ProxyAgentStub.ManagerArgs | Should -Be @('start') } + It 'pins the ltsc2019 tag on Windows Server 2019' { + $global:ProxyAgentStub.OperatingSystem = [pscustomobject]@{ ProductType = 3; BuildNumber = 17763; Caption = 'Windows Server 2019' } + $global:ProxyAgentStub.Tags += '1.2.8-windows-ltsc2019' + $null = Invoke-Installer (New-InstallArgs) + $LASTEXITCODE | Should -Be 0 + Get-VersionFile | Should -Be '1.2.8-windows-ltsc2019' + } + It 'uses -WindowsVersion instead of detecting the host, even on an unsupported build' { $global:ProxyAgentStub.OperatingSystem = [pscustomobject]@{ ProductType = 3; BuildNumber = 99999; Caption = 'Windows Server vNext' } $arguments = New-InstallArgs diff --git a/tests/pwsh/Manager.Tests.ps1 b/tests/pwsh/Manager.Tests.ps1 index 3e7fb48..e5406c7 100644 --- a/tests/pwsh/Manager.Tests.ps1 +++ b/tests/pwsh/Manager.Tests.ps1 @@ -96,9 +96,10 @@ BeforeAll { # Stand-in for the registry tag listing. Records the request. function Invoke-RestMethod { - param([string]$Uri, [string]$Method) + param([string]$Uri, [string]$Method, [int]$TimeoutSec) $global:ProxyAgentStub.RegistryUrl = $Uri $global:ProxyAgentStub.RegistryMethod = $Method + $global:ProxyAgentStub.RegistryTimeoutSec = $TimeoutSec return [pscustomobject]@{ tags = $global:ProxyAgentStub.Tags } } @@ -123,6 +124,7 @@ Describe 'proxy-agent-manager.ps1' { DockerCalls = @() RegistryUrl = $null RegistryMethod = $null + RegistryTimeoutSec = $null OperatingSystem = [pscustomobject]@{ ProductType = 3; BuildNumber = 20348; Caption = 'Windows Server 2022' } Tags = @('1.2.9-ubuntu-26.04', '1.2.10-ubuntu-26.04', '1.2.11', '1.2.10-windows-ltsc2022', '1.2.10-windows-ltsc2025') } @@ -144,6 +146,23 @@ Describe 'proxy-agent-manager.ps1' { Get-DockerRunArgs | Should -Contain '--memory=5120m' } + It 'rejects an invalid MEMORY_ALLOCATION_MB in settings.ps1' { + $manager = New-AgentDir -Settings "`$MEMORY_ALLOCATION_MB = '4G'" + $output = Invoke-Manager $manager start + $LASTEXITCODE | Should -Be 1 + $output | Should -Match 'Invalid \$MEMORY_ALLOCATION_MB .*: 4G' + Get-DockerRunArgs | Should -BeNullOrEmpty + } + + It 'accepts a version file with a byte order mark and CRLF line ending' { + $manager = New-AgentDir + $path = Join-Path $script:AgentDir 'version' + [System.IO.File]::WriteAllText($path, "1.2.9-ubuntu-26.04`r`n", [System.Text.UTF8Encoding]::new($true)) + $null = Invoke-Manager $manager start + $LASTEXITCODE | Should -Be 0 + $global:ProxyAgentStub.LastImage | Should -Be "$($script:ImageRepo):1.2.9-ubuntu-26.04" + } + It 'honors MEMORY_ALLOCATION_MB from settings.ps1' { $manager = New-AgentDir -Settings '$MEMORY_ALLOCATION_MB = 2048' $null = Invoke-Manager $manager start @@ -190,6 +209,7 @@ Describe 'proxy-agent-manager.ps1' { $manager = New-AgentDir $null = Invoke-Manager $manager upgrade $global:ProxyAgentStub.RegistryUrl | Should -Be "https://us-docker.pkg.dev/v2/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent/tags/list" + $global:ProxyAgentStub.RegistryTimeoutSec | Should -Be 30 $env:PROXY_AGENT_REGISTRY_TAGS_URL = 'http://127.0.0.1:1/v2/stub/tags/list' $null = Invoke-Manager $manager upgrade @@ -205,10 +225,11 @@ Describe 'proxy-agent-manager.ps1' { Get-DockerRunArgs | Should -BeNullOrEmpty } - It 'rolls back when the upgraded container is unhealthy' { + It 'rolls back and fails when the upgraded container is unhealthy' { $global:ProxyAgentStub.UnhealthyTag = '1.2.10' $manager = New-AgentDir $output = Invoke-Manager $manager upgrade + $LASTEXITCODE | Should -Be 1 $output | Should -Match 'rolling back' $global:ProxyAgentStub.LastImage | Should -Be "$($script:ImageRepo):1.2.9-ubuntu-26.04" Get-VersionFile | Should -BeLike '1.2.9*' @@ -260,10 +281,11 @@ Describe 'proxy-agent-manager.ps1' { Get-VersionFile | Should -BeLike '1.2.10*' } - It 'rolls back when the upgraded container is unhealthy' { + It 'rolls back and fails when the upgraded container is unhealthy' { $global:ProxyAgentStub.UnhealthyTag = '1.2.10' $manager = New-AgentDir -Version '1.2.9-windows-ltsc2022' $output = Invoke-Manager $manager upgrade + $LASTEXITCODE | Should -Be 1 $output | Should -Match 'rolling back' $global:ProxyAgentStub.LastImage | Should -Be "$($script:ImageRepo):1.2.9-windows-ltsc2022" Get-VersionFile | Should -Be '1.2.9-windows-ltsc2022' @@ -278,6 +300,14 @@ Describe 'proxy-agent-manager.ps1' { Get-DockerRunArgs | Should -BeNullOrEmpty } + It 'starts the ltsc2019 image on Windows Server 2019' { + $global:ProxyAgentStub.OperatingSystem = [pscustomobject]@{ ProductType = 3; BuildNumber = 17763; Caption = 'Windows Server 2019' } + $manager = New-AgentDir -Version '1.2.9' + $null = Invoke-Manager $manager start + $LASTEXITCODE | Should -Be 0 + $global:ProxyAgentStub.LastImage | Should -Be "$($script:ImageRepo):1.2.9-windows-ltsc2019" + } + It 'refuses to start on an unsupported Windows Server build' { $global:ProxyAgentStub.OperatingSystem = [pscustomobject]@{ ProductType = 3; BuildNumber = 99999; Caption = 'Windows Server vNext' } $manager = New-AgentDir -Version '1.2.9' From 83b4ea356e5de3d7e1878a7932b1422a1af756bd Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Tue, 3 Nov 2026 06:38:46 -0800 Subject: [PATCH 16/20] Run the PowerShell tests under PowerShell 7 too The Pester suites run under both Windows PowerShell 5.1 and PowerShell 7 on both Windows runners, and the Windows integration test also runs under PowerShell 7 on windows-2025, since customers may use either host. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 26 +++++++++++++++++++------- README.md | 4 ++-- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c89bba..c671fce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,20 +48,24 @@ jobs: - run: bats tests/bash pester: - name: Unit tests (PowerShell 5.1, ${{ matrix.os }}) + name: Unit tests (${{ matrix.shell }}, ${{ matrix.os }}) strategy: fail-fast: false matrix: os: [windows-2022, windows-2025] + # Customers may run the scripts with Windows PowerShell 5.1 + # (powershell) or PowerShell 7 (pwsh); the scripts under test run in + # the same host as Pester. + shell: [powershell, pwsh] runs-on: ${{ matrix.os }} timeout-minutes: 15 + defaults: + run: + shell: ${{ matrix.shell }} steps: - uses: actions/checkout@v4 - name: Run Pester - # Windows PowerShell 5.1 is what customers run the scripts with, so the - # tests run there rather than under pwsh. Pester is pinned to 5.x since - # the tests use its syntax. - shell: powershell + # Pester is pinned to 5.x since the tests use its syntax. run: | $pester = Get-Module -ListAvailable Pester | Where-Object { $_.Version -ge '5.0' -and $_.Version -lt '6.0' } if (-not $pester) { @@ -84,13 +88,22 @@ jobs: - run: tests/integration/linux.sh integration-windows: - name: Integration (Windows containers, ${{ matrix.os }}) + name: Integration (Windows containers, ${{ matrix.shell }}, ${{ matrix.os }}) strategy: fail-fast: false matrix: os: [windows-2022, windows-2025] + shell: [powershell] + # PowerShell 7 runs on one OS only, since these jobs are slow. + include: + - os: windows-2025 + shell: pwsh runs-on: ${{ matrix.os }} timeout-minutes: 45 + defaults: + run: + # The installer and manager run in the same host as the test. + shell: ${{ matrix.shell }} steps: - uses: actions/checkout@v4 - name: Wait for the Docker daemon @@ -110,5 +123,4 @@ jobs: docker info exit 1 - name: Run integration test - shell: powershell run: tests/integration/windows.ps1 diff --git a/README.md b/README.md index 8c8e0b3..f02f740 100644 --- a/README.md +++ b/README.md @@ -142,8 +142,8 @@ Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser CI (`.github/workflows/ci.yml`) runs on every pull request: - `shellcheck` on the shell scripts and PSScriptAnalyzer (settings in `PSScriptAnalyzerSettings.psd1`) on the PowerShell scripts. -- Unit tests for the management scripts: `tests/bash/manager.bats` (bats) and `tests/pwsh/Manager.Tests.ps1` (Pester 5 under Windows PowerShell 5.1). Both run the script with stubbed `docker` and registry calls, so they need no Docker daemon. -- Integration tests that run the installer and management script against a real Docker daemon: `tests/integration/linux.sh` on Ubuntu, and `tests/integration/windows.ps1` on Windows Server 2022 and 2025 with Windows containers. They use a stub agent image (`tests/stub/`) that only writes the heartbeat file, and a local stand-in for the registry, the script download and the configure endpoint (`tests/integration/registry_server.py`), so they need no Fivetran credentials. They cover installing from a config file, from a `TOKEN`, and from a legacy WebSocket config, then rollback, upgrade, restart and stop. +- Unit tests for the management scripts and the Windows installer: `tests/bash/manager.bats` (bats), and `tests/pwsh/Manager.Tests.ps1` and `tests/pwsh/Install.Tests.ps1` (Pester 5, under both Windows PowerShell 5.1 and PowerShell 7). They run the scripts with stubbed `docker`, registry and host calls, so they need no Docker daemon. +- Integration tests that run the installer and management script against a real Docker daemon: `tests/integration/linux.sh` on Ubuntu (amd64 and arm64), and `tests/integration/windows.ps1` on Windows Server 2022 and 2025 with Windows containers (on 2025 also under PowerShell 7). They use a stub agent image (`tests/stub/`) that only writes the heartbeat file, and a local stand-in for the registry, the script download and the configure endpoint (`tests/integration/registry_server.py`), so they need no Fivetran credentials. They cover installing from a config file, from a `TOKEN`, and from a legacy WebSocket config, then rollback, upgrade, restart and stop. To run them locally you need Docker for the integration tests, `python3` (`python` on Windows) for the registry stand-in, [bats-core](https://github.com/bats-core/bats-core) for the bash tests and [Pester 5](https://pester.dev) for the PowerShell tests. The stub images pull `alpine` and `servercore` base images, so the integration tests need network access for that. From 11c8cde5963d3922acb7453b8d743258ff570611 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Tue, 3 Nov 2026 06:40:42 -0800 Subject: [PATCH 17/20] Set the config file ACL with Set-Acl [System.IO.File]::SetAccessControl does not exist in PowerShell 7, so install.ps1 failed there after creating config.json. Set-Acl works in both Windows PowerShell 5.1 and PowerShell 7. A new test checks the resulting ACL. Co-Authored-By: Claude Opus 5.5 --- install.ps1 | 3 ++- tests/pwsh/Install.Tests.ps1 | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/install.ps1 b/install.ps1 index 725171e..dea0c86 100644 --- a/install.ps1 +++ b/install.ps1 @@ -410,7 +410,8 @@ try { 'NT AUTHORITY\SYSTEM', 'Read', 'Allow'))) $configAcl.SetAccessRule((New-Object Security.AccessControl.FileSystemAccessRule( (New-Object Security.Principal.SecurityIdentifier('S-1-5-32-544')), 'Read', 'Allow'))) - [System.IO.File]::SetAccessControl($configDest, $configAcl) + # Set-Acl rather than [System.IO.File]::SetAccessControl, which PowerShell 7 does not have. + Set-Acl -LiteralPath $configDest -AclObject $configAcl if ($ConfigPath) { [System.IO.File]::WriteAllText($configDest, $configFileContent, [System.Text.UTF8Encoding]::new($false)) diff --git a/tests/pwsh/Install.Tests.ps1 b/tests/pwsh/Install.Tests.ps1 index 479c0b9..3e9ccf4 100644 --- a/tests/pwsh/Install.Tests.ps1 +++ b/tests/pwsh/Install.Tests.ps1 @@ -205,6 +205,20 @@ Describe 'install.ps1' { } } + It 'restricts config.json to the installing user, SYSTEM and Administrators' { + $null = Invoke-Installer (New-InstallArgs) + $LASTEXITCODE | Should -Be 0 + $acl = Get-Acl -LiteralPath (Join-Path $script:InstallDir 'config\config.json') + $acl.AreAccessRulesProtected | Should -BeTrue + $identities = $acl.Access | ForEach-Object { $_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value } + $expected = @( + [Security.Principal.WindowsIdentity]::GetCurrent().User.Value, + 'S-1-5-18', # SYSTEM + 'S-1-5-32-544' # Administrators + ) + ($identities | Sort-Object) | Should -Be ($expected | Sort-Object) + } + Context 'with Linux containers' { BeforeEach { $global:ProxyAgentStub.OsType = 'linux' From 731a129f3a346af9b12d918da0a881210cd855b8 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Tue, 3 Nov 2026 08:28:10 -0800 Subject: [PATCH 18/20] Exit install.ps1 with 0 after a successful install The script ended without an explicit exit, so its exit code was the last $LASTEXITCODE. A tolerated failure, like docker info not reporting the data root, then made a successful install exit with 1, failing the install.ps1 Pester tests on Windows. Co-Authored-By: Claude Opus 5.5 --- install.ps1 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/install.ps1 b/install.ps1 index dea0c86..1b55549 100644 --- a/install.ps1 +++ b/install.ps1 @@ -474,6 +474,9 @@ try { Write-Host "`nInstallation complete." Write-Host "Install directory: $InstallDir" + # Otherwise the script would exit with whatever $LASTEXITCODE a tolerated + # failure left, such as an unknown Docker data root. + exit 0 } finally { Remove-Item Env:\TOKEN -ErrorAction SilentlyContinue } From 9ae7fb6cd4a1b8ccd539f64dc7d806e30b7219d2 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Tue, 3 Nov 2026 08:30:09 -0800 Subject: [PATCH 19/20] Keep Windows PowerShell 5.1 from wrapping test output Out-String wraps lines at the console width on Windows PowerShell 5.1, which split paths in the captured output and failed the tests that match them. Co-Authored-By: Claude Opus 5.5 --- tests/pwsh/Install.Tests.ps1 | 6 ++++-- tests/pwsh/Manager.Tests.ps1 | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/pwsh/Install.Tests.ps1 b/tests/pwsh/Install.Tests.ps1 index 3e9ccf4..5f6aec5 100644 --- a/tests/pwsh/Install.Tests.ps1 +++ b/tests/pwsh/Install.Tests.ps1 @@ -30,10 +30,12 @@ BeforeAll { return @{ ConfigPath = $config; InstallDir = $script:InstallDir } } - # Runs the installer, capturing every output stream as one string. + # Runs the installer, capturing every output stream as one string. The width + # keeps Windows PowerShell 5.1 from wrapping long lines, such as paths, at + # the console width. function Invoke-Installer { param([hashtable]$Arguments) - return (& $script:Installer @Arguments *>&1 | Out-String) + return (& $script:Installer @Arguments *>&1 | Out-String -Width 4096) } function Get-VersionFile { diff --git a/tests/pwsh/Manager.Tests.ps1 b/tests/pwsh/Manager.Tests.ps1 index e5406c7..8607fd7 100644 --- a/tests/pwsh/Manager.Tests.ps1 +++ b/tests/pwsh/Manager.Tests.ps1 @@ -46,10 +46,12 @@ BeforeAll { return (Get-Content -LiteralPath (Join-Path $script:AgentDir 'version') -Raw).Trim() } - # Runs the manager, capturing every output stream as one string. + # Runs the manager, capturing every output stream as one string. The width + # keeps Windows PowerShell 5.1 from wrapping long lines, such as paths, at + # the console width. function Invoke-Manager { param([string]$Manager, [string]$Command) - return (& $Manager $Command *>&1 | Out-String) + return (& $Manager $Command *>&1 | Out-String -Width 4096) } # Returns the arguments of the first recorded docker call for the given From 442a6c31935f3d03d37e9bd8211e98aae9ec6a37 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Tue, 3 Nov 2026 13:48:26 -0800 Subject: [PATCH 20/20] Check the memory setting only for commands that start the agent An invalid MEMORY_ALLOCATION_MB stopped every command, so it could keep stop from stopping a running agent. Now only start, restart and upgrade check it, before they touch the running agent. The PowerShell manager also treated 0 as unset and used the default, instead of rejecting it as the bash manager does. Co-Authored-By: Claude Opus 5.5 --- proxy-agent-manager.ps1 | 19 ++++++++++++++----- proxy-agent-manager.sh | 16 ++++++++++++---- tests/bash/manager.bats | 23 +++++++++++++++++++++++ tests/pwsh/Manager.Tests.ps1 | 30 +++++++++++++++++++++++++++--- 4 files changed, 76 insertions(+), 12 deletions(-) diff --git a/proxy-agent-manager.ps1 b/proxy-agent-manager.ps1 index 6246747..6eb00a8 100644 --- a/proxy-agent-manager.ps1 +++ b/proxy-agent-manager.ps1 @@ -19,11 +19,8 @@ $SETTINGS_FILE = Join-Path $BASE_DIR 'settings.ps1' if (Test-Path -LiteralPath $SETTINGS_FILE) { . $SETTINGS_FILE } -if (-not $MEMORY_ALLOCATION_MB) { $MEMORY_ALLOCATION_MB = 5120 } -if ("$MEMORY_ALLOCATION_MB" -notmatch '^\d+$' -or [int64]"$MEMORY_ALLOCATION_MB" -lt 1) { - Write-Host "ERROR: Invalid `$MEMORY_ALLOCATION_MB in ${SETTINGS_FILE}: $MEMORY_ALLOCATION_MB (must be a positive integer, in MB)" -ForegroundColor Red - exit 1 -} +# Unset or empty only, so 0 is rejected like any other invalid setting. +if ("$MEMORY_ALLOCATION_MB" -eq '') { $MEMORY_ALLOCATION_MB = 5120 } # ── Startup validation ─────────────────────────────────────────────────────── @@ -218,6 +215,15 @@ function Compare-SemVer { return $aVer.CompareTo($bVer) } +# Only the commands that start a container check the setting, so stop, status +# and logs still work with an invalid one. +function Assert-MemoryAllocation { + if ("$MEMORY_ALLOCATION_MB" -notmatch '^\d+$' -or [int64]"$MEMORY_ALLOCATION_MB" -lt 1) { + Write-Host "ERROR: Invalid `$MEMORY_ALLOCATION_MB in ${SETTINGS_FILE}: $MEMORY_ALLOCATION_MB (must be a positive integer, in MB)" -ForegroundColor Red + exit 1 + } +} + function Stop-ProxyAgent { try { docker inspect $CONTAINER_NAME 2>&1 | Out-Null } catch {} if ($LASTEXITCODE -eq 0) { @@ -400,6 +406,7 @@ function Invoke-Upgrade { switch ($Command) { 'start' { + Assert-MemoryAllocation if (-not (Start-CurrentProxyAgent)) { exit 1 } } 'stop' { @@ -407,10 +414,12 @@ switch ($Command) { Stop-ProxyAgent } 'restart' { + Assert-MemoryAllocation Write-Log "Restarting $CONTAINER_NAME..." if (-not (Start-CurrentProxyAgent)) { exit 1 } } 'upgrade' { + Assert-MemoryAllocation Invoke-Upgrade } 'status' { diff --git a/proxy-agent-manager.sh b/proxy-agent-manager.sh index 002ccab..75ecbb7 100755 --- a/proxy-agent-manager.sh +++ b/proxy-agent-manager.sh @@ -61,10 +61,15 @@ if [ -f "$SETTINGS_FILE" ]; then source "$SETTINGS_FILE" fi MEMORY_ALLOCATION_MB="${MEMORY_ALLOCATION_MB:-5120}" -if ! [[ "$MEMORY_ALLOCATION_MB" =~ ^[0-9]+$ ]] || [ "$MEMORY_ALLOCATION_MB" -lt 1 ]; then - echo "ERROR: Invalid MEMORY_ALLOCATION_MB in $SETTINGS_FILE: $MEMORY_ALLOCATION_MB (must be a positive integer, in MB)" - exit 1 -fi + +# Only the commands that start a container check the setting, so stop, status +# and logs still work with an invalid one. +check_memory_allocation() { + if ! [[ "$MEMORY_ALLOCATION_MB" =~ ^[0-9]+$ ]] || [ "$MEMORY_ALLOCATION_MB" -lt 1 ]; then + echo "ERROR: Invalid MEMORY_ALLOCATION_MB in $SETTINGS_FILE: $MEMORY_ALLOCATION_MB (must be a positive integer, in MB)" + exit 1 + fi +} get_latest_version() { local registry_host="${IMAGE%%/*}" @@ -188,6 +193,7 @@ start_agent() { case "${1:-}" in start) + check_memory_allocation start_agent "$CURRENT_VERSION" ;; @@ -201,12 +207,14 @@ case "${1:-}" in ;; restart) + check_memory_allocation log "Restarting ${CONTAINER_NAME}..." stop_agent start_agent "$CURRENT_VERSION" ;; upgrade) + check_memory_allocation upgrade_agent ;; diff --git a/tests/bash/manager.bats b/tests/bash/manager.bats index 1209fff..a643401 100644 --- a/tests/bash/manager.bats +++ b/tests/bash/manager.bats @@ -55,6 +55,29 @@ last_image() { [ ! -f "$DOCKER_LAST_IMAGE" ] } +@test "stop works with an invalid MEMORY_ALLOCATION_MB in settings.sh" { + manager start + echo "MEMORY_ALLOCATION_MB=4GB" > "$AGENT_DIR/settings.sh" + run manager stop + [ "$status" -eq 0 ] + [ ! -f "$DOCKER_LAST_IMAGE" ] +} + +@test "restart and upgrade reject an invalid MEMORY_ALLOCATION_MB without touching the agent" { + manager start + echo "MEMORY_ALLOCATION_MB=4GB" > "$AGENT_DIR/settings.sh" + local image version + image=$(last_image) + version=$(cat "$AGENT_DIR/version") + for command in restart upgrade; do + run manager "$command" + [ "$status" -eq 1 ] + [[ "$output" == *"Invalid MEMORY_ALLOCATION_MB"* ]] + done + [ "$(last_image)" = "$image" ] + [ "$(cat "$AGENT_DIR/version")" = "$version" ] +} + @test "start accepts a version file with a byte order mark and CRLF line ending" { printf '\xEF\xBB\xBF1.2.9-ubuntu-26.04\r\n' > "$AGENT_DIR/version" run manager start diff --git a/tests/pwsh/Manager.Tests.ps1 b/tests/pwsh/Manager.Tests.ps1 index 8607fd7..005ec0f 100644 --- a/tests/pwsh/Manager.Tests.ps1 +++ b/tests/pwsh/Manager.Tests.ps1 @@ -148,14 +148,38 @@ Describe 'proxy-agent-manager.ps1' { Get-DockerRunArgs | Should -Contain '--memory=5120m' } - It 'rejects an invalid MEMORY_ALLOCATION_MB in settings.ps1' { - $manager = New-AgentDir -Settings "`$MEMORY_ALLOCATION_MB = '4G'" + It 'rejects MEMORY_ALLOCATION_MB = in settings.ps1' -ForEach @( + @{ Setting = "'4G'"; Shown = '4G' } + @{ Setting = '0'; Shown = '0' } + ) { + $manager = New-AgentDir -Settings "`$MEMORY_ALLOCATION_MB = $Setting" $output = Invoke-Manager $manager start $LASTEXITCODE | Should -Be 1 - $output | Should -Match 'Invalid \$MEMORY_ALLOCATION_MB .*: 4G' + $output | Should -Match "Invalid \`$MEMORY_ALLOCATION_MB .*: $Shown " Get-DockerRunArgs | Should -BeNullOrEmpty } + It 'stops the agent with an invalid MEMORY_ALLOCATION_MB in settings.ps1' { + $manager = New-AgentDir + $null = Invoke-Manager $manager start + Set-Content -LiteralPath (Join-Path $script:AgentDir 'settings.ps1') -Value "`$MEMORY_ALLOCATION_MB = '4G'" -Encoding ascii + $null = Invoke-Manager $manager stop + $LASTEXITCODE | Should -Be 0 + $global:ProxyAgentStub.LastImage | Should -BeNullOrEmpty + } + + It 'rejects an invalid MEMORY_ALLOCATION_MB on <_> without touching the agent' -ForEach @('restart', 'upgrade') { + $manager = New-AgentDir + $null = Invoke-Manager $manager start + Set-Content -LiteralPath (Join-Path $script:AgentDir 'settings.ps1') -Value "`$MEMORY_ALLOCATION_MB = '4G'" -Encoding ascii + $version = Get-VersionFile + $output = Invoke-Manager $manager $_ + $LASTEXITCODE | Should -Be 1 + $output | Should -Match 'Invalid \$MEMORY_ALLOCATION_MB' + Get-DockerCall 'stop' | Should -BeNullOrEmpty + Get-VersionFile | Should -Be $version + } + It 'accepts a version file with a byte order mark and CRLF line ending' { $manager = New-AgentDir $path = Join-Path $script:AgentDir 'version'