From 66c7eab872e53577024bd49ed7831d8e3a73b988 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 06:29:52 -0700 Subject: [PATCH 01/13] 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 0fbbd50..ddd08f6 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,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 92523c7..048303f 100644 --- a/install.ps1 +++ b/install.ps1 @@ -26,8 +26,10 @@ $MIN_RECOMMENDED_CONTAINER_MEMORY_MB = 5120 # proxy agent container should g $HOST_RESERVED_MEMORY_MB = 3072 # leave at least this much RAM for the host on Windows $MIN_RECOMMENDED_DISK_SPACE_MB = 2048 $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 0b2e63c2b9a66f5873e06f8dc1ade6dfa9484478 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 06:42:18 -0700 Subject: [PATCH 02/13] 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 ddd08f6..cf0ff2b 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,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 a5479e57b79dab26aa093ac525f04d8185e0de53 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 06:52:45 -0700 Subject: [PATCH 03/13] 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 048303f..554bd30 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 66cf6e2f6be9e39e321195103cfbcd673bf41ef2 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 06:54:47 -0700 Subject: [PATCH 04/13] 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 830f395501d07553cc430865639481c96e93d603 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 06:59:29 -0700 Subject: [PATCH 05/13] 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 5523048805c7886c6475cb4ef02e35dd6dc1cba7 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 07:00:59 -0700 Subject: [PATCH 06/13] 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 82a42d3b01a7b7eaa4784c795c0313b7382ba11d Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 07:12:30 -0700 Subject: [PATCH 07/13] 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 b14350fbb7b9dd6768ac1915cd9c8bc3788a0553 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 07:16:46 -0700 Subject: [PATCH 08/13] 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 252121425e8db6d8fbb7385fbebe0d900f231500 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 07:22:08 -0700 Subject: [PATCH 09/13] 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 cf0ff2b..bcc6d1c 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,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 18fbca81277bd589ac9068e5459ed6c42466fb4b Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 07:30:55 -0700 Subject: [PATCH 10/13] 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 e8abccf1051d4f67ab4f2e52b40fdddb80600545 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 07:35:33 -0700 Subject: [PATCH 11/13] 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 0b078743333374645b10b2907bbb2ce595befd27 Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 07:39:37 -0700 Subject: [PATCH 12/13] 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 bcc6d1c..288f73f 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,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 e017e14b22f250f0c2bbba269d05b6222f5fe71c Mon Sep 17 00:00:00 2001 From: Paul Harper Date: Fri, 30 Oct 2026 07:40:51 -0700 Subject: [PATCH 13/13] 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') } }